@oxygen-agent/cli 1.1010.650 → 1.1010.721

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 (50) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +1 -1
  3. package/dist/inbox-needs-reply-notice.d.ts +12 -0
  4. package/dist/inbox-needs-reply-notice.js +51 -0
  5. package/dist/index.js +186 -45
  6. package/dist/skills.js +48 -22
  7. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +33 -2
  8. package/node_modules/@oxygen/shared/dist/billing-anchors.js +67 -2
  9. package/node_modules/@oxygen/shared/dist/billing.d.ts +63 -9
  10. package/node_modules/@oxygen/shared/dist/billing.js +96 -14
  11. package/node_modules/@oxygen/shared/dist/capability-discovery.js +11 -1
  12. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
  13. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
  14. package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +25 -0
  15. package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +27 -0
  16. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +6 -1
  17. package/node_modules/@oxygen/shared/dist/feature-gates.js +7 -1
  18. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -1
  19. package/node_modules/@oxygen/shared/dist/index.js +2 -1
  20. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +114 -0
  21. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +150 -0
  22. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +19 -2
  23. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +118 -0
  24. package/node_modules/@oxygen/shared/dist/plan-band.js +147 -0
  25. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +131 -120
  26. package/node_modules/@oxygen/shared/dist/plan-limits.js +80 -71
  27. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +79 -14
  28. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +61 -12
  29. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +4 -3
  30. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +9 -3
  31. package/node_modules/@oxygen/shared/dist/process-resource.d.ts +4 -0
  32. package/node_modules/@oxygen/shared/dist/process-resource.js +25 -0
  33. package/node_modules/@oxygen/shared/dist/repricing.d.ts +130 -0
  34. package/node_modules/@oxygen/shared/dist/repricing.js +320 -0
  35. package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +32 -0
  36. package/node_modules/@oxygen/shared/dist/sending-limits.js +49 -0
  37. package/node_modules/@oxygen/shared/dist/sequence-failures.js +4 -1
  38. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +24 -0
  39. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +58 -1
  40. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +39 -10
  41. package/node_modules/@oxygen/shared/dist/table-capacity.js +68 -4
  42. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +9 -0
  43. package/node_modules/@oxygen/shared/dist/telemetry.js +36 -2
  44. package/node_modules/@oxygen/shared/dist/trace-context.d.ts +29 -0
  45. package/node_modules/@oxygen/shared/dist/trace-context.js +88 -0
  46. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  47. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  48. package/package.json +1 -1
  49. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +0 -64
  50. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +0 -90
package/dist/skills.js CHANGED
@@ -397,21 +397,36 @@ async function stageAuthenticatedSkills(dir, source, targets, fetchImpl) {
397
397
  // installer reads a skill's files from `<root>/<name>/<file>`.
398
398
  const skillsRoot = source.index_url.replace(/\/index\.json$/i, "");
399
399
  for (const entry of wanted) {
400
- // allSettled, not all: on a failed fetch, Promise.all would reject while
401
- // sibling fetches are still writing — the caller's finally rmSyncs the temp
402
- // dir and a late mkdirSync would silently recreate it as orphan litter.
403
- // Waiting for every settle guarantees no write is in flight when we throw.
404
- const staged = await Promise.allSettled(entry.files.map(async (file) => {
405
- const content = await fetchStagedSkillFile(`${skillsRoot}/${entry.name}/${file}`, fetchImpl);
406
- const destination = join(dir, entry.name, ...file.split("/"));
407
- mkdirSync(dirname(destination), { recursive: true });
408
- writeFileSync(destination, content);
400
+ // Every worker settles before we throw: on a failed fetch, rejecting early
401
+ // would leave sibling fetches still writing — the caller's finally rmSyncs the
402
+ // temp dir and a late mkdirSync would silently recreate it as orphan litter.
403
+ // Bounded, not one fetch per file: oxygen-gtm alone is ~80 files, and that
404
+ // burst against the self-hosted production web reset connections, failing
405
+ // roughly one install in three with a bare "fetch failed" (2026-09-26).
406
+ let failure;
407
+ let next = 0;
408
+ await Promise.all(Array.from({ length: Math.min(entry.files.length, STAGED_FILE_CONCURRENCY) }, async () => {
409
+ while (failure === undefined && next < entry.files.length) {
410
+ const file = entry.files[next++];
411
+ try {
412
+ const content = await fetchStagedSkillFile(`${skillsRoot}/${entry.name}/${file}`, fetchImpl);
413
+ const destination = join(dir, entry.name, ...file.split("/"));
414
+ mkdirSync(dirname(destination), { recursive: true });
415
+ writeFileSync(destination, content);
416
+ }
417
+ catch (error) {
418
+ failure ??= error;
419
+ }
420
+ }
409
421
  }));
410
- const failed = staged.find((result) => result.status === "rejected");
411
- if (failed)
412
- throw failed.reason;
422
+ if (failure !== undefined)
423
+ throw failure;
413
424
  }
414
425
  }
426
+ const STAGED_FILE_CONCURRENCY = 6;
427
+ const STAGED_FILE_TIMEOUT_MS = 30_000;
428
+ /** Backoff before each retry of a transient failure; its length is the retry budget. */
429
+ const STAGED_FILE_RETRY_DELAYS_MS = [250, 1_000];
415
430
  async function fetchStagedSkillEntries(indexUrl, fetchImpl) {
416
431
  let response;
417
432
  try {
@@ -444,18 +459,29 @@ async function fetchStagedSkillEntries(indexUrl, fetchImpl) {
444
459
  }
445
460
  return entries;
446
461
  }
462
+ /** A transport failure, a timeout, 429 or 5xx is retried; any other status is final. */
447
463
  async function fetchStagedSkillFile(url, fetchImpl) {
448
- let response;
449
- try {
450
- response = await fetchImpl(url);
451
- }
452
- catch (error) {
453
- throw stagingFailure("fetch_file", { url, message: errorMessage(error) });
454
- }
455
- if (!response.ok) {
456
- throw stagingFailure("fetch_file", { url, status: response.status });
464
+ for (let attempt = 0;; attempt += 1) {
465
+ let details;
466
+ try {
467
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(STAGED_FILE_TIMEOUT_MS) });
468
+ if (response.ok)
469
+ return await response.text();
470
+ if (response.status !== 429 && response.status < 500) {
471
+ throw stagingFailure("fetch_file", { url, status: response.status });
472
+ }
473
+ details = { url, status: response.status };
474
+ }
475
+ catch (error) {
476
+ if (error instanceof OxygenError)
477
+ throw error;
478
+ details = { url, message: errorMessage(error) };
479
+ }
480
+ const delay = STAGED_FILE_RETRY_DELAYS_MS[attempt];
481
+ if (delay === undefined)
482
+ throw stagingFailure("fetch_file", { ...details, attempts: attempt + 1 });
483
+ await new Promise((resolve) => setTimeout(resolve, delay));
457
484
  }
458
- return response.text();
459
485
  }
460
486
  // Mirror the installer-failure error so a staging failure surfaces identically
461
487
  // (code, message, exitCode) to the caller — never as a silent URL fallback.
@@ -45,8 +45,10 @@ export declare function occurrencesInPeriod(from: Date, to: Date, anchorDay: num
45
45
  * The block is a STANDING one-month-per-resource reserve (see
46
46
  * credit-commitments.ts): because every anchor is at most one month out, holding
47
47
  * one month per active resource always covers every fixed charge falling due
48
- * inside any coming subscription period of <= 1 month. Longer periods (annual
49
- * plans) need proportionally more held, or the block runs dry mid-term.
48
+ * inside any coming subscription period of <= 1 month. Longer periods need
49
+ * proportionally more held, or the block runs dry mid-term. A yearly plan is
50
+ * not such a period: it grants monthly slices, so its caller passes the current
51
+ * slice's length (see recomputeOrgCommitmentBlock, P-23).
50
52
  *
51
53
  * Returns at least 1 — a missing/zero/garbage period length must never collapse
52
54
  * the block to nothing, which would silently disable the whole feature.
@@ -58,3 +60,32 @@ export declare function blockPeriodsForSubscription(periodDays: number | null |
58
60
  * the millisecond math (and get it wrong across DST by using local dates).
59
61
  */
60
62
  export declare function periodLengthDays(start: Date | null | undefined, end: Date | null | undefined): number | null;
63
+ /** Longest period that is still ONE monthly grant (matches the allowance-bar guard). */
64
+ export declare const SINGLE_GRANT_PERIOD_MAX_DAYS = 35;
65
+ export type SubscriptionGrantSlice = {
66
+ /** 0 for the slice the paid invoice starts; the grant key of slice 0 is the period key. */
67
+ index: number;
68
+ start: Date;
69
+ /** Exclusive. The last slice always ends exactly at the period end. */
70
+ end: Date;
71
+ };
72
+ /**
73
+ * The monthly grant slices of one paid subscription period.
74
+ *
75
+ * A period of at most {@link SINGLE_GRANT_PERIOD_MAX_DAYS} days is a single
76
+ * slice covering the whole period, which is exactly today's monthly grant. A
77
+ * longer period is cut on its start's anchor day. The slice count is bounded by
78
+ * the period's length in average months (12 for a year, including leap years
79
+ * and clamped anchors), and the last slice absorbs any remainder, so a period
80
+ * end whose time of day differs from its start by seconds can never produce a
81
+ * thirteenth sliver.
82
+ */
83
+ export declare function subscriptionGrantSlices(periodStart: Date, periodEnd: Date): SubscriptionGrantSlice[];
84
+ /**
85
+ * The slices of a period that have started by `now`, in order. Nothing after the
86
+ * period end is ever due: a subscription set to cancel at period end, or a row
87
+ * whose renewal webhook has not arrived yet, stops at its last paid slice.
88
+ */
89
+ export declare function dueSubscriptionGrantSlices(periodStart: Date, periodEnd: Date, now: Date): SubscriptionGrantSlice[];
90
+ /** The slice `now` falls in, or null outside the period. */
91
+ export declare function currentSubscriptionGrantSlice(periodStart: Date, periodEnd: Date, now: Date): SubscriptionGrantSlice | null;
@@ -108,8 +108,10 @@ export function occurrencesInPeriod(from, to, anchorDay) {
108
108
  * The block is a STANDING one-month-per-resource reserve (see
109
109
  * credit-commitments.ts): because every anchor is at most one month out, holding
110
110
  * one month per active resource always covers every fixed charge falling due
111
- * inside any coming subscription period of <= 1 month. Longer periods (annual
112
- * plans) need proportionally more held, or the block runs dry mid-term.
111
+ * inside any coming subscription period of <= 1 month. Longer periods need
112
+ * proportionally more held, or the block runs dry mid-term. A yearly plan is
113
+ * not such a period: it grants monthly slices, so its caller passes the current
114
+ * slice's length (see recomputeOrgCommitmentBlock, P-23).
113
115
  *
114
116
  * Returns at least 1 — a missing/zero/garbage period length must never collapse
115
117
  * the block to nothing, which would silently disable the whole feature.
@@ -133,3 +135,66 @@ export function periodLengthDays(start, end) {
133
135
  return null;
134
136
  return Math.floor(ms / DAY_MS);
135
137
  }
138
+ // ---------------------------------------------------------------------------
139
+ // Monthly credit slices of a longer (annual) subscription period
140
+ // ---------------------------------------------------------------------------
141
+ //
142
+ // Repricing 2026-09, decision 1.2: every plan size can be paid annually, and the
143
+ // credits are STILL granted monthly. One paid annual period therefore funds a
144
+ // sequence of monthly grant slices. P-8 (PROPOSED default) anchors the slices to
145
+ // the subscription period start, walked with the same immutable-anchor clamp as
146
+ // the commitment anchors above: a period starting 31 Jan slices at 28 Feb,
147
+ // 31 Mar, 30 Apr ... and never decays to the 28th.
148
+ /** Average Gregorian month length in days: 365.2425 / 12. */
149
+ const AVERAGE_MONTH_DAYS = 365.2425 / 12;
150
+ /** Longest period that is still ONE monthly grant (matches the allowance-bar guard). */
151
+ export const SINGLE_GRANT_PERIOD_MAX_DAYS = 35;
152
+ /**
153
+ * The monthly grant slices of one paid subscription period.
154
+ *
155
+ * A period of at most {@link SINGLE_GRANT_PERIOD_MAX_DAYS} days is a single
156
+ * slice covering the whole period, which is exactly today's monthly grant. A
157
+ * longer period is cut on its start's anchor day. The slice count is bounded by
158
+ * the period's length in average months (12 for a year, including leap years
159
+ * and clamped anchors), and the last slice absorbs any remainder, so a period
160
+ * end whose time of day differs from its start by seconds can never produce a
161
+ * thirteenth sliver.
162
+ */
163
+ export function subscriptionGrantSlices(periodStart, periodEnd) {
164
+ if (!(periodStart instanceof Date) || !(periodEnd instanceof Date))
165
+ return [];
166
+ if (Number.isNaN(periodStart.getTime()) || Number.isNaN(periodEnd.getTime()))
167
+ return [];
168
+ if (periodEnd <= periodStart)
169
+ return [];
170
+ const periodDays = (periodEnd.getTime() - periodStart.getTime()) / DAY_MS;
171
+ if (periodDays <= SINGLE_GRANT_PERIOD_MAX_DAYS) {
172
+ return [{ index: 0, start: periodStart, end: periodEnd }];
173
+ }
174
+ const maxSlices = Math.max(1, Math.round(periodDays / AVERAGE_MONTH_DAYS));
175
+ const anchorDay = anchorDayOf(periodStart);
176
+ const slices = [];
177
+ let cursor = periodStart;
178
+ for (let index = 0; index < maxSlices; index += 1) {
179
+ const next = nextAnchorDueAt(cursor, anchorDay);
180
+ if (index === maxSlices - 1 || next >= periodEnd) {
181
+ slices.push({ index, start: cursor, end: periodEnd });
182
+ break;
183
+ }
184
+ slices.push({ index, start: cursor, end: next });
185
+ cursor = next;
186
+ }
187
+ return slices;
188
+ }
189
+ /**
190
+ * The slices of a period that have started by `now`, in order. Nothing after the
191
+ * period end is ever due: a subscription set to cancel at period end, or a row
192
+ * whose renewal webhook has not arrived yet, stops at its last paid slice.
193
+ */
194
+ export function dueSubscriptionGrantSlices(periodStart, periodEnd, now) {
195
+ return subscriptionGrantSlices(periodStart, periodEnd).filter((slice) => slice.start.getTime() <= now.getTime());
196
+ }
197
+ /** The slice `now` falls in, or null outside the period. */
198
+ export function currentSubscriptionGrantSlice(periodStart, periodEnd, now) {
199
+ return (subscriptionGrantSlices(periodStart, periodEnd).find((slice) => slice.start.getTime() <= now.getTime() && now.getTime() < slice.end.getTime()) ?? null);
200
+ }
@@ -60,8 +60,8 @@ export declare const CREDIT_TOPUP_DEFAULT_CREDITS = 2000;
60
60
  export declare function isValidCreditTopupCredits(credits: number): boolean;
61
61
  /**
62
62
  * A shortfall expressed as an amount `oxygen billing topup` will actually SELL:
63
- * rounded up onto the 1,000-credit step and clamped into the purchasable
64
- * [8,000 .. 1,000,000] band. A next_action (or an alert email) naming a number the
63
+ * rounded up onto CREDIT_TOPUP_STEP_CREDITS (100) and clamped into the purchasable
64
+ * [CREDIT_TOPUP_MIN_CREDITS .. CREDIT_TOPUP_MAX_CREDITS] band (800 .. 100,000). A next_action (or an alert email) naming a number the
65
65
  * top-up route refuses with `invalid_topup_amount` is not a next action, it is a
66
66
  * second dead end during the incident it exists to end.
67
67
  *
@@ -76,15 +76,41 @@ export declare function isValidCreditTopupCredits(credits: number): boolean;
76
76
  */
77
77
  export declare function creditTopupAmountForShortfall(credits: number): number;
78
78
  export declare function creditTopupUsdCents(credits: number): number | null;
79
+ /**
80
+ * Price of a custom top-up at an explicit rate. A checkout session records the
81
+ * rate it was quoted at (`CREDIT_TOPUP_RATE_METADATA_KEY`), and fulfillment
82
+ * re-prices it at THAT rate, never the rate in force when Stripe confirms the
83
+ * payment: a session opened before a rate change and paid after it must still
84
+ * be credited.
85
+ */
86
+ export declare function creditTopupUsdCentsAtRate(credits: number, usdCentsPer100: number): number | null;
87
+ /** Checkout-session metadata key carrying the rate a top-up was quoted at. */
88
+ export declare const CREDIT_TOPUP_RATE_METADATA_KEY = "usd_cents_per_100";
89
+ /**
90
+ * Every top-up rate (US cents per 100 credits) a checkout has ever been minted
91
+ * at. Append when the rate changes; never remove one, because an unpaid session
92
+ * quoted at it can still be paid. The first entry is also the rate of sessions
93
+ * created before the rate was written into their metadata.
94
+ */
95
+ export declare const CREDIT_TOPUP_SOLD_USD_CENTS_PER_100: readonly [125];
96
+ export declare const CREDIT_TOPUP_LEGACY_USD_CENTS_PER_100: number;
97
+ /**
98
+ * Whether fulfillment may price a session at `usdCentsPer100`: a rate OXYGEN has
99
+ * sold at, or the rate in force now. Anything else is metadata no OXYGEN server
100
+ * wrote, and it is refused rather than credited at a cheaper rate.
101
+ */
102
+ export declare function isAcceptedCreditTopupRate(usdCentsPer100: number, currentUsdCentsPer100?: number): boolean;
79
103
  export declare const AUTOMATION_ACTION_CREDITS = 0.001;
80
104
  /** Kinds of resource that carry a fixed monthly credit commitment. */
81
- export declare const CREDIT_COMMITMENT_KINDS: readonly ["sending_mailbox", "managed_mailbox", "mailbox_warmup", "deliverability_unit", "linkedin_account", "whatsapp_account"];
105
+ export declare const CREDIT_COMMITMENT_KINDS: readonly ["sending_mailbox", "managed_mailbox", "mailbox_warmup", "deliverability_unit", "linkedin_account", "whatsapp_account", "x_account", "phone_number"];
82
106
  export type CreditCommitmentKind = (typeof CREDIT_COMMITMENT_KINDS)[number];
83
107
  /**
84
108
  * Sequencer platform fee per CONNECTED SENDING MAILBOX per month, in credits
85
109
  * ($1.00). Charged on EVERY mailbox wired to the sequencer — BYOK/self-connected
86
110
  * Gmail and Microsoft inboxes included — and it STACKS on Oxygen-sold mailboxes,
87
- * which additionally pay their own mailbox/warmup/placement lines.
111
+ * which additionally pay their own mailbox/warmup/placement lines. Read from the
112
+ * pricing seed (`commitment.sending_mailbox`) through the generated snapshot, so
113
+ * there is one definition of the price.
88
114
  */
89
115
  export declare const SENDING_MAILBOX_MONTHLY_CREDITS = 100;
90
116
  /**
@@ -93,12 +119,12 @@ export declare const SENDING_MAILBOX_MONTHLY_CREDITS = 100;
93
119
  */
94
120
  export declare const MANAGED_MAILBOX_MONTHLY_CREDITS = 300;
95
121
  /**
96
- * Grace window after a commitment goes past_due before the owning subsystem may
97
- * suspend the resource. Notify -> pause -> lapse; never auto-cancel. Aligned with
98
- * the subscription entitlement grace so a customer never hits two different
99
- * clocks for the same missed payment.
122
+ * Grace window after a commitment goes past_due before the account disconnects.
123
+ * Repricing 2026-09 decision 2.8: sending from the account pauses at once, and
124
+ * after 7 days without funds the account disconnects (replacing the earlier 14
125
+ * days with no auto-cancel). The pause and the disconnect sweep are slice S23.
100
126
  */
101
- export declare const COMMITMENT_PAST_DUE_GRACE_DAYS = 14;
127
+ export declare const COMMITMENT_PAST_DUE_GRACE_DAYS = 7;
102
128
  /**
103
129
  * Reconnect window in which an ENDED commitment resumes instead of starting a new
104
130
  * one. The single most important anti-double-charge rule: a Unipile re-auth, a
@@ -361,6 +387,13 @@ export declare function getCurrentBillingCycleKey(date?: Date): string;
361
387
  * billing-anchor shift without admitting a quarterly or annual period.
362
388
  */
363
389
  export declare const CREDIT_CYCLE_MAX_PERIOD_DAYS = 35;
390
+ /**
391
+ * Annual billing (Repricing 2026-09, decision 1.2): a year costs ten months of
392
+ * the monthly price, and the credits are still granted monthly.
393
+ */
394
+ export declare const ANNUAL_BILLING_MONTHS_CHARGED = 10;
395
+ /** The yearly price of a rung, in cents: ten times its monthly price. */
396
+ export declare function annualPlanPriceCents(monthlyPriceCents: number): number;
364
397
  export type CreditCycleSource = "subscription_period" | "calendar_month";
365
398
  export type CreditCycleWindow = {
366
399
  start: Date;
@@ -389,6 +422,21 @@ export declare function isStripeManagedSubscription(subscription: {
389
422
  stripeSubscriptionId?: string | null;
390
423
  metadata?: Record<string, unknown> | null;
391
424
  }): boolean;
425
+ /**
426
+ * Whether a Stripe subscription is scheduled to end. A classic-billing-mode
427
+ * period-end cancel sets `cancel_at_period_end`; a Billing Portal cancel on a
428
+ * flexible-billing-mode subscription sets only `cancel_at` (to the period end)
429
+ * and leaves `cancel_at_period_end` false (Stripe billing-mode comparison docs;
430
+ * reproduced in test mode 2026-09-26). Reading the boolean alone showed a
431
+ * Portal-cancelled plan as renewing. `cancel_at` on a subscription that has
432
+ * already ended records when it ended, not a pending cancellation, so it only
433
+ * counts while the subscription is still live.
434
+ */
435
+ export declare function stripeSubscriptionCancellationScheduled(subscription: {
436
+ status: string;
437
+ cancel_at_period_end?: boolean | null;
438
+ cancel_at?: number | null;
439
+ }): boolean;
392
440
  /**
393
441
  * The window the credit-allowance bar measures: "this month's credits", resolved
394
442
  * to the boundary at which the plan's grant actually renews.
@@ -412,6 +460,12 @@ export declare function resolveCreditCycleWindow(input: {
412
460
  currentPeriodEnd: Date | null;
413
461
  /** isOffStripeSubscription(subscription.metadata) — resolved by the caller. */
414
462
  offStripe: boolean;
463
+ /**
464
+ * planBillingIntervalForPriceId(subscription.stripePriceId). A yearly
465
+ * period grants its credits in monthly slices, so the cycle the bar measures
466
+ * is the current slice, anchored to the period start (P-8).
467
+ */
468
+ billingInterval?: "month" | "year" | null;
415
469
  } | null;
416
470
  now?: Date;
417
471
  }): CreditCycleWindow;
@@ -1,4 +1,5 @@
1
- import { AUTOMATION_ACTION_CREDITS as SNAPSHOT_AUTOMATION_ACTION_CREDITS, CREDITS_PER_USD as SNAPSHOT_CREDITS_PER_USD, PRICING_PLAN_SNAPSHOT, TOPUP_USD_CENTS_PER_100_CREDITS as SNAPSHOT_TOPUP_USD_CENTS_PER_100_CREDITS, } from "./pricing-snapshot.generated.js";
1
+ import { AUTOMATION_ACTION_CREDITS as SNAPSHOT_AUTOMATION_ACTION_CREDITS, CREDITS_PER_USD as SNAPSHOT_CREDITS_PER_USD, PRICING_PLAN_SNAPSHOT, SENDING_MAILBOX_CREDITS_PER_MONTH as SNAPSHOT_SENDING_MAILBOX_CREDITS_PER_MONTH, TOPUP_USD_CENTS_PER_100_CREDITS as SNAPSHOT_TOPUP_USD_CENTS_PER_100_CREDITS, } from "./pricing-snapshot.generated.js";
2
+ import { currentSubscriptionGrantSlice } from "./billing-anchors.js";
2
3
  import { freeTierEntitlementEnabled } from "./plan-capabilities.js";
3
4
  import { asRecordOrNull } from "./type-guards.js";
4
5
  export const WEEKLY_USAGE_WINDOW_DAYS = 7;
@@ -65,8 +66,8 @@ export function isValidCreditTopupCredits(credits) {
65
66
  }
66
67
  /**
67
68
  * A shortfall expressed as an amount `oxygen billing topup` will actually SELL:
68
- * rounded up onto the 1,000-credit step and clamped into the purchasable
69
- * [8,000 .. 1,000,000] band. A next_action (or an alert email) naming a number the
69
+ * rounded up onto CREDIT_TOPUP_STEP_CREDITS (100) and clamped into the purchasable
70
+ * [CREDIT_TOPUP_MIN_CREDITS .. CREDIT_TOPUP_MAX_CREDITS] band (800 .. 100,000). A next_action (or an alert email) naming a number the
70
71
  * top-up route refuses with `invalid_topup_amount` is not a next action, it is a
71
72
  * second dead end during the incident it exists to end.
72
73
  *
@@ -84,9 +85,42 @@ export function creditTopupAmountForShortfall(credits) {
84
85
  return Math.min(CREDIT_TOPUP_MAX_CREDITS, Math.max(CREDIT_TOPUP_MIN_CREDITS, stepped));
85
86
  }
86
87
  export function creditTopupUsdCents(credits) {
88
+ return creditTopupUsdCentsAtRate(credits, TOPUP_USD_CENTS_PER_100_CREDITS);
89
+ }
90
+ /**
91
+ * Price of a custom top-up at an explicit rate. A checkout session records the
92
+ * rate it was quoted at (`CREDIT_TOPUP_RATE_METADATA_KEY`), and fulfillment
93
+ * re-prices it at THAT rate, never the rate in force when Stripe confirms the
94
+ * payment: a session opened before a rate change and paid after it must still
95
+ * be credited.
96
+ */
97
+ export function creditTopupUsdCentsAtRate(credits, usdCentsPer100) {
87
98
  if (!isValidCreditTopupCredits(credits))
88
99
  return null;
89
- return (credits / 100) * TOPUP_USD_CENTS_PER_100_CREDITS;
100
+ if (!Number.isInteger(usdCentsPer100) || usdCentsPer100 <= 0)
101
+ return null;
102
+ return (credits / 100) * usdCentsPer100;
103
+ }
104
+ /** Checkout-session metadata key carrying the rate a top-up was quoted at. */
105
+ export const CREDIT_TOPUP_RATE_METADATA_KEY = "usd_cents_per_100";
106
+ /**
107
+ * Every top-up rate (US cents per 100 credits) a checkout has ever been minted
108
+ * at. Append when the rate changes; never remove one, because an unpaid session
109
+ * quoted at it can still be paid. The first entry is also the rate of sessions
110
+ * created before the rate was written into their metadata.
111
+ */
112
+ export const CREDIT_TOPUP_SOLD_USD_CENTS_PER_100 = [125];
113
+ export const CREDIT_TOPUP_LEGACY_USD_CENTS_PER_100 = CREDIT_TOPUP_SOLD_USD_CENTS_PER_100[0];
114
+ /**
115
+ * Whether fulfillment may price a session at `usdCentsPer100`: a rate OXYGEN has
116
+ * sold at, or the rate in force now. Anything else is metadata no OXYGEN server
117
+ * wrote, and it is refused rather than credited at a cheaper rate.
118
+ */
119
+ export function isAcceptedCreditTopupRate(usdCentsPer100, currentUsdCentsPer100 = TOPUP_USD_CENTS_PER_100_CREDITS) {
120
+ if (!Number.isInteger(usdCentsPer100) || usdCentsPer100 <= 0)
121
+ return false;
122
+ return usdCentsPer100 === currentUsdCentsPer100
123
+ || CREDIT_TOPUP_SOLD_USD_CENTS_PER_100.includes(usdCentsPer100);
90
124
  }
91
125
  // Workflow/automation actions (steps, tool calls, row writes, retries) draw
92
126
  // from the same credit pool at a flat capacity-guard rate — the old separate
@@ -122,26 +156,33 @@ export const CREDIT_COMMITMENT_KINDS = [
122
156
  "deliverability_unit",
123
157
  "linkedin_account",
124
158
  "whatsapp_account",
159
+ // Repricing 2026-09 (S20, decisions 2.2 and 2.4). Priced in the pricing seed
160
+ // (seat.x_account, seat.phone_number) and reserve nothing until the
161
+ // repricing's effective-date switch.
162
+ "x_account",
163
+ "phone_number",
125
164
  ];
126
165
  /**
127
166
  * Sequencer platform fee per CONNECTED SENDING MAILBOX per month, in credits
128
167
  * ($1.00). Charged on EVERY mailbox wired to the sequencer — BYOK/self-connected
129
168
  * Gmail and Microsoft inboxes included — and it STACKS on Oxygen-sold mailboxes,
130
- * which additionally pay their own mailbox/warmup/placement lines.
169
+ * which additionally pay their own mailbox/warmup/placement lines. Read from the
170
+ * pricing seed (`commitment.sending_mailbox`) through the generated snapshot, so
171
+ * there is one definition of the price.
131
172
  */
132
- export const SENDING_MAILBOX_MONTHLY_CREDITS = 100;
173
+ export const SENDING_MAILBOX_MONTHLY_CREDITS = SNAPSHOT_SENDING_MAILBOX_CREDITS_PER_MONTH;
133
174
  /**
134
175
  * Oxygen-sold managed mailbox per month, in credits ($3.00). Flat, superseding
135
176
  * the dynamic vendor-COGS x 1.25 quote for the recurring mailbox line.
136
177
  */
137
178
  export const MANAGED_MAILBOX_MONTHLY_CREDITS = 300;
138
179
  /**
139
- * Grace window after a commitment goes past_due before the owning subsystem may
140
- * suspend the resource. Notify -> pause -> lapse; never auto-cancel. Aligned with
141
- * the subscription entitlement grace so a customer never hits two different
142
- * clocks for the same missed payment.
180
+ * Grace window after a commitment goes past_due before the account disconnects.
181
+ * Repricing 2026-09 decision 2.8: sending from the account pauses at once, and
182
+ * after 7 days without funds the account disconnects (replacing the earlier 14
183
+ * days with no auto-cancel). The pause and the disconnect sweep are slice S23.
143
184
  */
144
- export const COMMITMENT_PAST_DUE_GRACE_DAYS = 14;
185
+ export const COMMITMENT_PAST_DUE_GRACE_DAYS = 7;
145
186
  /**
146
187
  * Reconnect window in which an ENDED commitment resumes instead of starting a new
147
188
  * one. The single most important anti-double-charge rule: a Unipile re-auth, a
@@ -187,11 +228,13 @@ export function ratifiedCommitmentCredits(kind) {
187
228
  case "mailbox_warmup":
188
229
  case "deliverability_unit":
189
230
  return null;
190
- // Signed in control-db (LINKEDIN_ACCOUNT_MONTHLY_CREDITS /
191
- // WHATSAPP_ACCOUNT_MONTHLY_CREDITS) — control-db cannot import @oxygen/shared,
192
- // so the constants live there and the caller passes them.
231
+ // Connected-account reservations are priced in the pricing seed
232
+ // (seat.*_account, seat.phone_number) and resolved, through the 2026-09
233
+ // repricing switch, by commitmentPrice in @oxygen/integrations.
193
234
  case "linkedin_account":
194
235
  case "whatsapp_account":
236
+ case "x_account":
237
+ case "phone_number":
195
238
  return null;
196
239
  }
197
240
  }
@@ -751,6 +794,15 @@ export function getCurrentBillingCycleKey(date = new Date()) {
751
794
  * billing-anchor shift without admitting a quarterly or annual period.
752
795
  */
753
796
  export const CREDIT_CYCLE_MAX_PERIOD_DAYS = 35;
797
+ /**
798
+ * Annual billing (Repricing 2026-09, decision 1.2): a year costs ten months of
799
+ * the monthly price, and the credits are still granted monthly.
800
+ */
801
+ export const ANNUAL_BILLING_MONTHS_CHARGED = 10;
802
+ /** The yearly price of a rung, in cents: ten times its monthly price. */
803
+ export function annualPlanPriceCents(monthlyPriceCents) {
804
+ return monthlyPriceCents * ANNUAL_BILLING_MONTHS_CHARGED;
805
+ }
754
806
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
755
807
  function startOfUtcMonth(date) {
756
808
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
@@ -800,6 +852,22 @@ export function isStripeManagedSubscription(subscription) {
800
852
  && metadata.off_stripe !== "true"
801
853
  && metadata.synthetic !== true);
802
854
  }
855
+ /**
856
+ * Whether a Stripe subscription is scheduled to end. A classic-billing-mode
857
+ * period-end cancel sets `cancel_at_period_end`; a Billing Portal cancel on a
858
+ * flexible-billing-mode subscription sets only `cancel_at` (to the period end)
859
+ * and leaves `cancel_at_period_end` false (Stripe billing-mode comparison docs;
860
+ * reproduced in test mode 2026-09-26). Reading the boolean alone showed a
861
+ * Portal-cancelled plan as renewing. `cancel_at` on a subscription that has
862
+ * already ended records when it ended, not a pending cancellation, so it only
863
+ * counts while the subscription is still live.
864
+ */
865
+ export function stripeSubscriptionCancellationScheduled(subscription) {
866
+ if (subscription.cancel_at_period_end === true)
867
+ return true;
868
+ const ended = subscription.status === "canceled" || subscription.status === "incomplete_expired";
869
+ return !ended && subscription.cancel_at != null;
870
+ }
803
871
  /**
804
872
  * The window the credit-allowance bar measures: "this month's credits", resolved
805
873
  * to the boundary at which the plan's grant actually renews.
@@ -822,6 +890,20 @@ export function resolveCreditCycleWindow(input) {
822
890
  const subscription = input.subscription;
823
891
  const start = subscription?.currentPeriodStart ?? null;
824
892
  const end = subscription?.currentPeriodEnd ?? null;
893
+ if (subscription?.billingInterval === "year"
894
+ && !subscription.offStripe
895
+ && start != null
896
+ && end != null) {
897
+ const slice = currentSubscriptionGrantSlice(start, end, now);
898
+ if (slice) {
899
+ return {
900
+ start: slice.start,
901
+ end: slice.end,
902
+ source: "subscription_period",
903
+ label: `${formatUtcDay(slice.start)} – ${formatUtcDay(slice.end)}`,
904
+ };
905
+ }
906
+ }
825
907
  const usesPeriod = subscription != null
826
908
  && !subscription.offStripe
827
909
  && start != null
@@ -505,7 +505,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
505
505
  gatewayCommands: ["linkedin network setup", "linkedin invitations setup", "senders list", "connections import", "inbox list"],
506
506
  skills: ["oxygen-linkedin-marketing", "oxygen-sequencer", "oxygen-unibox"],
507
507
  endpointSections: ["linkedin"],
508
- intentTerms: ["my linkedin", "our linkedin", "own linkedin", "connected linkedin", "connections", "mutual connections", "shared connections", "connections in common", "followers", "my network", "whole network", "linkedin network", "connections and followers", "profile viewers", "sales navigator", "recruiter", "linkedin inbox"],
508
+ intentTerms: ["my linkedin", "our linkedin", "own linkedin", "connected linkedin", "connections", "mutual connections", "shared connections", "connections in common", "followers", "my network", "whole network", "linkedin network", "connections and followers", "profile viewers", "sales navigator", "recruiter", "linkedin inbox", "working hours", "sending hours", "linkedin limits"],
509
509
  },
510
510
  {
511
511
  id: "connected-whatsapp",
@@ -974,6 +974,12 @@ function recommendationsFor(card, query) {
974
974
  exactCommand: LOCAL_BUSINESS_SCRAPE_COMMAND,
975
975
  };
976
976
  }
977
+ if (card.id === "connected-linkedin" && isLinkedInAccountLimitsIntent(query)) {
978
+ return {
979
+ tools: ["oxygen_senders_limits_get", "oxygen_senders_limits_set", "oxygen_senders_list"],
980
+ commands: ["senders limits get", "senders limits set", "senders list"],
981
+ };
982
+ }
977
983
  if (card.id === "connected-linkedin" && isMutualLinkedInConnectionsIntent(query)) {
978
984
  return {
979
985
  tools: ["oxygen_tools_get", "oxygen_tools_run_live", "oxygen_senders_list"],
@@ -1322,6 +1328,10 @@ function isSenderProfileIntent(query) {
1322
1328
  || (/\bprofiles?\b.{0,64}\b(?:linkedin|whatsapp|mailboxes?|inboxes?|email)\b/.test(query)
1323
1329
  && /\b(?:sender|sending|unif(?:y|ied))\b/.test(query));
1324
1330
  }
1331
+ /** The account's own working hours or daily caps (`senders limits`), not its network. */
1332
+ function isLinkedInAccountLimitsIntent(query) {
1333
+ return /\b(working hours|sending hours|business hours|office hours|weekdays?|time ?zone|linkedin limits|daily limits?)\b/.test(query);
1334
+ }
1325
1335
  function isMutualLinkedInConnectionsIntent(query) {
1326
1336
  const linkedInContext = /\blinkedin\b|\bprofiles?\b/.test(query);
1327
1337
  const mutualContext = /\b(mutual|shared|in common)\b.{0,48}\b(connections?|relations?)\b/.test(query)