@oxygen-agent/cli 1.1010.905 → 1.1010.950

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 (28) hide show
  1. package/README.md +1 -1
  2. package/dist/contract-flags.d.ts +42 -0
  3. package/dist/contract-flags.js +220 -0
  4. package/dist/index.js +43 -184
  5. package/dist/sender-limits-flags.d.ts +14 -0
  6. package/dist/sender-limits-flags.js +157 -0
  7. package/dist/ugc-commands.js +16 -2
  8. package/node_modules/@oxygen/cli-ugc/dist/commands.js +3 -3
  9. package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +4 -0
  10. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +20 -8
  11. package/node_modules/@oxygen/cli-ugc/dist/index.d.ts +1 -0
  12. package/node_modules/@oxygen/cli-ugc/dist/index.js +1 -0
  13. package/node_modules/@oxygen/shared/dist/capability-discovery.js +2 -2
  14. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +39 -0
  15. package/node_modules/@oxygen/shared/dist/contracts/operations.js +11 -0
  16. package/node_modules/@oxygen/shared/dist/contracts/schema.d.ts +186 -0
  17. package/node_modules/@oxygen/shared/dist/contracts/schema.js +260 -0
  18. package/node_modules/@oxygen/shared/dist/contracts/sender-limits.d.ts +67 -0
  19. package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +88 -0
  20. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +11 -0
  21. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +41 -0
  22. package/node_modules/@oxygen/shared/dist/product-briefing-rules.js +4 -2
  23. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +2 -1
  24. package/node_modules/@oxygen/shared/dist/table-capacity.js +18 -3
  25. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  26. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  27. package/node_modules/@oxygen/shared/dist/version.js +3 -0
  28. package/package.json +1 -1
@@ -0,0 +1,260 @@
1
+ /**
2
+ * One contract per editable resource, in the plain JSON-schema dialect MCP tool
3
+ * input schemas already use (founder decision 2026-09-28: no zod, no new
4
+ * dependency). The same object generates the CLI flags
5
+ * (`packages/cli/src/contract-flags.ts`), the MCP input schema
6
+ * (`packages/mcp-server/src/tools/contract-tool.ts`) and validates the API
7
+ * route body (`validateContract`), so a field cannot exist on one surface and
8
+ * not another.
9
+ *
10
+ * Leaf and browser-safe: no imports, no IO.
11
+ */
12
+ function described(shape, description) {
13
+ return description === undefined ? shape : { ...shape, description };
14
+ }
15
+ function numeric(type, options = {}) {
16
+ const { description, minimum, maximum, clamp } = options;
17
+ return {
18
+ type,
19
+ ...(minimum !== undefined ? { minimum } : {}),
20
+ ...(maximum !== undefined ? { maximum } : {}),
21
+ ...(clamp ? { "x-clamp": true } : {}),
22
+ ...(description !== undefined ? { description } : {}),
23
+ };
24
+ }
25
+ export function int(options) {
26
+ return numeric("integer", options);
27
+ }
28
+ export function num(options) {
29
+ return numeric("number", options);
30
+ }
31
+ export function bool(options = {}) {
32
+ return described({ type: "boolean" }, options.description);
33
+ }
34
+ export function str(options = {}) {
35
+ const { description, ...rest } = options;
36
+ return described({ type: "string", ...rest }, description);
37
+ }
38
+ export function enumOf(values, options = {}) {
39
+ return described({ type: "string", enum: values }, options.description);
40
+ }
41
+ export function arr(items, options = {}) {
42
+ const { description, ...rest } = options;
43
+ return described({ type: "array", items, ...rest }, description);
44
+ }
45
+ export function obj(properties, options = {}) {
46
+ const { description, required } = options;
47
+ return {
48
+ type: "object",
49
+ ...(description !== undefined ? { description } : {}),
50
+ additionalProperties: false,
51
+ properties,
52
+ ...(required && required.length > 0 ? { required } : {}),
53
+ };
54
+ }
55
+ /** `null` is accepted and passed through; it means "clear this field". */
56
+ export function nullable(property) {
57
+ const { description, ...inner } = property;
58
+ return {
59
+ oneOf: [inner, { type: "null" }],
60
+ ...(description !== undefined ? { description } : {}),
61
+ };
62
+ }
63
+ /** A property's non-null branch, and whether null is allowed. */
64
+ export function unwrapNullable(property) {
65
+ if ("oneOf" in property && property.oneOf.length === 2) {
66
+ const nonNull = property.oneOf.filter((branch) => !("type" in branch && branch.type === "null"));
67
+ if (nonNull.length === 1) {
68
+ const inner = nonNull[0];
69
+ return {
70
+ property: property.description !== undefined && !("$ref" in inner)
71
+ ? { ...inner, description: property.description }
72
+ : inner,
73
+ nullable: true,
74
+ };
75
+ }
76
+ }
77
+ return { property, nullable: false };
78
+ }
79
+ /** Every non-object field of a contract, depth first in declaration order. */
80
+ export function contractLeafPaths(contract, prefix = []) {
81
+ const leaves = [];
82
+ for (const [name, raw] of Object.entries(contract.properties ?? {})) {
83
+ const path = [...prefix, name];
84
+ const { property, nullable } = unwrapNullable(raw);
85
+ if ("type" in property && property.type === "object" && property.properties) {
86
+ leaves.push(...contractLeafPaths(property, path));
87
+ }
88
+ else {
89
+ leaves.push({ key: path.join("."), path, property, nullable });
90
+ }
91
+ }
92
+ return leaves;
93
+ }
94
+ function kindOf(value) {
95
+ if (value === null)
96
+ return "null";
97
+ if (Array.isArray(value))
98
+ return "array";
99
+ return typeof value;
100
+ }
101
+ function isRecord(value) {
102
+ return typeof value === "object" && value !== null && !Array.isArray(value);
103
+ }
104
+ const TYPE_NOUN = {
105
+ integer: "a whole number",
106
+ number: "a number",
107
+ string: "a string",
108
+ boolean: "true or false",
109
+ array: "a list",
110
+ object: "an object",
111
+ };
112
+ function childPath(parent, key) {
113
+ return parent ? `${parent}.${key}` : key;
114
+ }
115
+ function walkObject(schema, value, path, walk) {
116
+ const out = {};
117
+ const properties = schema.properties ?? {};
118
+ for (const key of schema.required ?? []) {
119
+ if (value[key] === undefined)
120
+ walk.issues.push({ path: childPath(path, key), message: `${childPath(path, key)} is required.` });
121
+ }
122
+ for (const [key, entry] of Object.entries(value)) {
123
+ if (entry === undefined)
124
+ continue;
125
+ const at = childPath(path, key);
126
+ const property = properties[key];
127
+ if (property !== undefined) {
128
+ out[key] = walkValue(property, entry, at, walk);
129
+ continue;
130
+ }
131
+ const extra = schema.additionalProperties;
132
+ if (extra === false) {
133
+ if (walk.options.unknown === "reject") {
134
+ walk.issues.push({ path: at, message: `${at} is not a recognised field.` });
135
+ }
136
+ else {
137
+ walk.unknownKeys.push(at);
138
+ out[key] = entry;
139
+ }
140
+ }
141
+ else if (extra && typeof extra === "object") {
142
+ out[key] = walkValue(extra, entry, at, walk);
143
+ }
144
+ else {
145
+ out[key] = entry;
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+ function walkValue(schema, value, path, walk) {
151
+ if ("$ref" in schema)
152
+ return value;
153
+ if ("oneOf" in schema) {
154
+ const { property, nullable } = unwrapNullable(schema);
155
+ if (value === null) {
156
+ if (nullable)
157
+ return null;
158
+ walk.issues.push({ path, message: `${path} must not be null.` });
159
+ return value;
160
+ }
161
+ if (nullable)
162
+ return walkValue(property, value, path, walk);
163
+ for (const branch of schema.oneOf) {
164
+ const trial = { options: walk.options, issues: [], unknownKeys: [], clamped: [] };
165
+ const result = walkValue(branch, value, path, trial);
166
+ if (trial.issues.length === 0) {
167
+ walk.unknownKeys.push(...trial.unknownKeys);
168
+ walk.clamped.push(...trial.clamped);
169
+ return result;
170
+ }
171
+ }
172
+ walk.issues.push({ path, message: `${path} does not match any accepted shape.` });
173
+ return value;
174
+ }
175
+ if (schema.type === "null") {
176
+ if (value !== null)
177
+ walk.issues.push({ path, message: `${path} must be null.` });
178
+ return value;
179
+ }
180
+ if (value === null) {
181
+ walk.issues.push({ path, message: `${path} must not be null.` });
182
+ return value;
183
+ }
184
+ const mismatch = () => {
185
+ walk.issues.push({ path, message: `${path} must be ${TYPE_NOUN[schema.type]}, not ${kindOf(value)}.` });
186
+ return value;
187
+ };
188
+ switch (schema.type) {
189
+ case "boolean":
190
+ return typeof value === "boolean" ? value : mismatch();
191
+ case "string": {
192
+ if (typeof value !== "string")
193
+ return mismatch();
194
+ if (schema.enum && !schema.enum.includes(value)) {
195
+ walk.issues.push({ path, message: `${path} must be one of: ${schema.enum.join(", ")}.` });
196
+ }
197
+ else if (schema.minLength !== undefined && value.length < schema.minLength) {
198
+ walk.issues.push({ path, message: `${path} must be at least ${schema.minLength} characters.` });
199
+ }
200
+ else if (schema.maxLength !== undefined && value.length > schema.maxLength) {
201
+ walk.issues.push({ path, message: `${path} must be at most ${schema.maxLength} characters.` });
202
+ }
203
+ return value;
204
+ }
205
+ case "integer":
206
+ case "number": {
207
+ if (typeof value !== "number" || !Number.isFinite(value))
208
+ return mismatch();
209
+ if (schema.type === "integer" && !Number.isInteger(value))
210
+ return mismatch();
211
+ if (schema.minimum !== undefined && value < schema.minimum) {
212
+ walk.issues.push({ path, message: `${path} must be at least ${schema.minimum}.` });
213
+ return value;
214
+ }
215
+ if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) {
216
+ walk.issues.push({ path, message: `${path} must be greater than ${schema.exclusiveMinimum}.` });
217
+ return value;
218
+ }
219
+ if (schema.maximum !== undefined && value > schema.maximum) {
220
+ if (schema["x-clamp"] && walk.options.overMax === "clamp") {
221
+ walk.clamped.push(path);
222
+ return schema.maximum;
223
+ }
224
+ walk.issues.push({ path, message: `${path} must be at most ${schema.maximum}.` });
225
+ }
226
+ return value;
227
+ }
228
+ case "array": {
229
+ if (!Array.isArray(value))
230
+ return mismatch();
231
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
232
+ walk.issues.push({ path, message: `${path} must list at least ${schema.minItems}.` });
233
+ }
234
+ else if (schema.maxItems !== undefined && value.length > schema.maxItems) {
235
+ walk.issues.push({ path, message: `${path} must list at most ${schema.maxItems}.` });
236
+ }
237
+ return value.map((item, index) => walkValue(schema.items, item, `${path}[${index}]`, walk));
238
+ }
239
+ case "object":
240
+ return isRecord(value) ? walkObject(schema, value, path, walk) : mismatch();
241
+ default:
242
+ return value;
243
+ }
244
+ }
245
+ /**
246
+ * Check a request body against its contract. Absent (`undefined`) fields are
247
+ * skipped, `null` passes only where the field is `nullable`, and every issue is
248
+ * collected rather than stopping at the first. On success `value` is a copy with
249
+ * clamps applied (see the options for what happens to unknown keys).
250
+ */
251
+ export function validateContract(contract, value, options) {
252
+ const walk = { options, issues: [], unknownKeys: [], clamped: [] };
253
+ if (!isRecord(value)) {
254
+ return { ok: false, issues: [{ path: "", message: "The request body must be a JSON object." }] };
255
+ }
256
+ const out = walkObject(contract, value, "", walk);
257
+ if (walk.issues.length > 0)
258
+ return { ok: false, issues: walk.issues };
259
+ return { ok: true, value: out, unknownKeys: walk.unknownKeys, clamped: walk.clamped };
260
+ }
@@ -0,0 +1,67 @@
1
+ import type { OperationSpec } from "./operations.js";
2
+ import { type Infer } from "./schema.js";
3
+ export declare const SENDER_LIMITS_CONTRACT: import("./schema.js").ContractObject<Record<"action_spacing_jitter_seconds" | "api_ingest_reads_per_day" | "api_reads_per_day" | "comments_per_day" | "endorsements_per_day" | "follows_per_day" | "inmails_per_day" | "inmails_per_month" | "interactive_min_spacing_seconds" | "interactive_sends_per_hour" | "interactive_spacing_jitter_seconds" | "invites_per_day" | "invites_per_week" | "likes_per_day" | "messages_ingest_reads_per_day" | "messages_per_day" | "messages_reads_per_day" | "min_action_spacing_seconds" | "network_backfill_reads_per_day" | "network_delta_reads_per_day" | "posts_per_day" | "profile_lookups_per_day" | "profile_views_per_day" | "relations_ingest_reads_per_day" | "relations_reads_per_day" | "sales_nav_search_results_per_day" | "searches_per_day" | "total_actions_per_day" | "total_reads_per_day" | "withdrawals_per_day", {
4
+ type: "integer";
5
+ description?: string;
6
+ minimum?: number;
7
+ maximum?: number;
8
+ "x-clamp"?: true;
9
+ }>, never>;
10
+ export declare const SENDER_WORKING_HOURS_CONTRACT: import("./schema.js").ContractObject<{
11
+ readonly timezone: import("./schema.js").StringProperty;
12
+ readonly days: import("./schema.js").ArrayProperty<{
13
+ type: "integer";
14
+ description?: string;
15
+ minimum?: number;
16
+ maximum?: number;
17
+ "x-clamp"?: true;
18
+ }>;
19
+ readonly start: import("./schema.js").StringProperty;
20
+ readonly end: import("./schema.js").StringProperty;
21
+ }, never>;
22
+ export declare const SENDER_WARMUP_CONTRACT: import("./schema.js").ContractObject<{
23
+ readonly enabled: import("./schema.js").BooleanProperty;
24
+ readonly start_date: {
25
+ oneOf: [import("./schema.js").StringProperty, import("./schema.js").NullProperty];
26
+ description?: string;
27
+ };
28
+ }, never>;
29
+ export declare const LINKEDIN_WARMUP_PRESETS: readonly ["conservative", "standard", "fast"];
30
+ export declare const SENDER_LIMITS_PATCH_CONTRACT: import("./schema.js").ContractObject<{
31
+ readonly limits: import("./schema.js").ContractObject<Record<"action_spacing_jitter_seconds" | "api_ingest_reads_per_day" | "api_reads_per_day" | "comments_per_day" | "endorsements_per_day" | "follows_per_day" | "inmails_per_day" | "inmails_per_month" | "interactive_min_spacing_seconds" | "interactive_sends_per_hour" | "interactive_spacing_jitter_seconds" | "invites_per_day" | "invites_per_week" | "likes_per_day" | "messages_ingest_reads_per_day" | "messages_per_day" | "messages_reads_per_day" | "min_action_spacing_seconds" | "network_backfill_reads_per_day" | "network_delta_reads_per_day" | "posts_per_day" | "profile_lookups_per_day" | "profile_views_per_day" | "relations_ingest_reads_per_day" | "relations_reads_per_day" | "sales_nav_search_results_per_day" | "searches_per_day" | "total_actions_per_day" | "total_reads_per_day" | "withdrawals_per_day", {
32
+ type: "integer";
33
+ description?: string;
34
+ minimum?: number;
35
+ maximum?: number;
36
+ "x-clamp"?: true;
37
+ }>, never>;
38
+ readonly working_hours: import("./schema.js").ContractObject<{
39
+ readonly timezone: import("./schema.js").StringProperty;
40
+ readonly days: import("./schema.js").ArrayProperty<{
41
+ type: "integer";
42
+ description?: string;
43
+ minimum?: number;
44
+ maximum?: number;
45
+ "x-clamp"?: true;
46
+ }>;
47
+ readonly start: import("./schema.js").StringProperty;
48
+ readonly end: import("./schema.js").StringProperty;
49
+ }, never>;
50
+ readonly warmup: import("./schema.js").ContractObject<{
51
+ readonly enabled: import("./schema.js").BooleanProperty;
52
+ readonly start_date: {
53
+ oneOf: [import("./schema.js").StringProperty, import("./schema.js").NullProperty];
54
+ description?: string;
55
+ };
56
+ }, never>;
57
+ readonly read_warmup: import("./schema.js").BooleanProperty;
58
+ readonly warmup_preset: {
59
+ type: "string";
60
+ enum: readonly ("conservative" | "fast" | "standard")[];
61
+ description?: string;
62
+ };
63
+ readonly randomize_daily_caps: import("./schema.js").BooleanProperty;
64
+ readonly weekend_full_volume: import("./schema.js").BooleanProperty;
65
+ }, never>;
66
+ export type SenderLimitsPatch = Infer<typeof SENDER_LIMITS_PATCH_CONTRACT>;
67
+ export declare const SENDER_LIMITS_SET_OPERATION: OperationSpec<typeof SENDER_LIMITS_PATCH_CONTRACT>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `PATCH /api/cli/senders/{id}/limits`: per-account LinkedIn caps, working
3
+ * hours, the warm-up ramp and the account's safety knobs. Mirrors exactly what
4
+ * the route accepts; the route's own validators (working-hours rules, warm-up
5
+ * date parsing) still run after the contract.
6
+ */
7
+ import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_KEYS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, } from "../linkedin-sequences.js";
8
+ import { arr, bool, enumOf, int, nullable, obj, str } from "./schema.js";
9
+ /** Short MCP-facing notes; the CLI carries its own longer help per flag. */
10
+ const LIMIT_NOTES = {
11
+ inmails_per_month: "Rolling 30 days.",
12
+ profile_lookups_per_day: "Invisible profile/company lookups.",
13
+ withdrawals_per_day: "Sent-invite withdrawals.",
14
+ posts_per_day: "Own-feed posts.",
15
+ total_actions_per_day: "All sends except posts.",
16
+ relations_reads_per_day: "Connection-list reads.",
17
+ messages_reads_per_day: "Message-history reads.",
18
+ sales_nav_search_results_per_day: "Result rows.",
19
+ api_reads_per_day: "Other API reads.",
20
+ total_reads_per_day: "All reads.",
21
+ interactive_min_spacing_seconds: "Human Unibox send spacing.",
22
+ interactive_spacing_jitter_seconds: "Jitter on that spacing.",
23
+ interactive_sends_per_hour: "Hourly human send cap.",
24
+ relations_ingest_reads_per_day: "Background connection/follower list reads.",
25
+ api_ingest_reads_per_day: "Background engagement and post-metric reads.",
26
+ messages_ingest_reads_per_day: "Background message-history backfill.",
27
+ network_backfill_reads_per_day: "Network capture, first walk.",
28
+ network_delta_reads_per_day: "Network capture, once walked.",
29
+ };
30
+ function limit(key) {
31
+ const max = LINKEDIN_SENDER_LIMIT_MAXIMUMS[key];
32
+ const note = LIMIT_NOTES[key];
33
+ return int({
34
+ minimum: 0,
35
+ maximum: max,
36
+ clamp: true,
37
+ description: `Max ${max} (default ${LINKEDIN_SENDER_LIMIT_DEFAULTS[key]}).${note ? ` ${note}` : ""}`,
38
+ });
39
+ }
40
+ export const SENDER_LIMITS_CONTRACT = obj(Object.fromEntries(LINKEDIN_SENDER_LIMIT_KEYS.map((key) => [key, limit(key)])), { description: "Per-account caps for sends and reads. Values above the maximum are clamped server-side." });
41
+ // Merged into the stored window server-side, so a partial edit keeps the rest.
42
+ export const SENDER_WORKING_HOURS_CONTRACT = obj({
43
+ timezone: str({ description: "IANA zone for hours and daily reset, e.g. Europe/Berlin." }),
44
+ days: arr(int({ minimum: 1, maximum: 7 }), { description: "ISO weekdays, 1=Mon." }),
45
+ start: str({ description: "HH:MM, e.g. 09:00." }),
46
+ end: str({ description: "HH:MM, after start." }),
47
+ }, { description: "When LinkedIn actions may run (default 07:00-22:00 daily); work outside waits. Omitted fields keep their value." });
48
+ export const SENDER_WARMUP_CONTRACT = obj({
49
+ enabled: bool({ description: "true = start/restart the ramp at day 0; false = disable warm-up (account becomes warm)." }),
50
+ start_date: nullable(str({
51
+ description: "ISO date (e.g. 2026-01-31) to anchor the ramp start. A past date credits prior warming and advances the ramp day; a future date holds at day 0 until it arrives. null disables warm-up.",
52
+ })),
53
+ }, {
54
+ description: "Warm-up ramp override for this account. Warm-up gradually raises invite + message caps to full over ~2 weeks and is auto-enabled at first connect (pre-existing accounts are treated as already warm). Use { enabled: true } to start/restart the ramp now, { enabled: false } to disable it (account uses its full configured caps), or { start_date } to anchor the ramp to a specific day.",
55
+ });
56
+ export const LINKEDIN_WARMUP_PRESETS = ["conservative", "standard", "fast"];
57
+ export const SENDER_LIMITS_PATCH_CONTRACT = obj({
58
+ limits: SENDER_LIMITS_CONTRACT,
59
+ working_hours: SENDER_WORKING_HOURS_CONTRACT,
60
+ warmup: SENDER_WARMUP_CONTRACT,
61
+ read_warmup: bool({ description: "Network-capture read ramp: false runs full read budget now; true restarts it from today." }),
62
+ warmup_preset: enumOf(LINKEDIN_WARMUP_PRESETS, { description: "Warm-up ramp: ~3 weeks, ~2 (default), ~1 for an aged account." }),
63
+ randomize_daily_caps: bool({ description: "Vary each day's caps a little so volume is not identical daily." }),
64
+ weekend_full_volume: bool({
65
+ description: "Send caps halve on Saturday and Sunday in the account's timezone. true keeps full volume; false restores the halving.",
66
+ }),
67
+ });
68
+ const inferMatchesRoute = true;
69
+ void inferMatchesRoute;
70
+ export const SENDER_LIMITS_SET_OPERATION = {
71
+ id: "senders.limits.set",
72
+ route: "/api/cli/senders/{id}/limits",
73
+ method: "PATCH",
74
+ contract: SENDER_LIMITS_PATCH_CONTRACT,
75
+ cli: {
76
+ path: ["senders", "limits", "set"],
77
+ summary: "Adjust per-account action limits and working hours. LinkedIn actions only run inside the account's working hours (default 07:00-22:00 every day, in the account's timezone); work due outside them is deferred to the next window, not failed. Set them with --timezone, --working-days, --working-hours-start and --working-hours-end; flags you omit keep their current value. A sequence's own schedule (sequences update --linkedin-timezone ...) can narrow the window for its LinkedIn steps, never widen it; email steps follow the sequence's email send window instead. Every limit flag shows its default and hard maximum; values above the maximum are clamped, and higher caps raise the risk of a LinkedIn restriction. Each action has its own cap (InMails per day and per rolling 30 days, profile views, profile lookups, endorsements, invitation withdrawals, posts), and every send except posts also counts toward --total-actions-per-day. Daily send caps run at half on Saturday and Sunday in the account's timezone unless --weekend-full-volume is set. A withdrawn invitation cannot be re-sent to the same person for 21 days. Own-feed posts and your own Unibox replies ignore working hours. A refused action names its reason (outside_working_hours defers it; linkedin_rate_limited names the limit and resets_at). Changing limits is free. <id> accepts a sender account id, connection id, or Unipile account id.",
78
+ argument: { name: "id", description: "Sender account id, connection id, or Unipile account id." },
79
+ },
80
+ mcp: {
81
+ name: "oxygen_senders_limits_set",
82
+ title: "Oxygen: Set LinkedIn Account Limits",
83
+ description: "Updates per-account daily limits for sends (invites/messages/InMails) AND reads (relations lists, message history, searches, profile views), working hours, and the warm-up ramp (enable/disable/anchor). Values are clamped to safe maximums to protect the LinkedIn account. LinkedIn actions run only inside working hours.",
84
+ pack: "deliverability",
85
+ },
86
+ mutates: true,
87
+ spends: false,
88
+ };
@@ -317,6 +317,17 @@ export type LinkedInSenderLimits = {
317
317
  network_backfill_reads_per_day: number;
318
318
  network_delta_reads_per_day: number;
319
319
  };
320
+ /**
321
+ * Every LinkedInSenderLimits key, in the order the CLI flags and the MCP
322
+ * `limits` schema list them (sends, reads, spacing, then the background ingest
323
+ * budgets). `SENDER_LIMITS_CONTRACT` is generated from this tuple, and a
324
+ * type-level test pins it to `keyof LinkedInSenderLimits`, so a new limit key
325
+ * cannot reach the defaults without reaching the CLI and MCP too.
326
+ */
327
+ export declare const LINKEDIN_SENDER_LIMIT_KEYS: readonly ["invites_per_day", "invites_per_week", "messages_per_day", "inmails_per_day", "inmails_per_month", "profile_views_per_day", "profile_lookups_per_day", "endorsements_per_day", "withdrawals_per_day", "posts_per_day", "follows_per_day", "likes_per_day", "comments_per_day", "total_actions_per_day", "relations_reads_per_day", "messages_reads_per_day", "searches_per_day", "sales_nav_search_results_per_day", "api_reads_per_day", "total_reads_per_day", "min_action_spacing_seconds", "action_spacing_jitter_seconds", "interactive_min_spacing_seconds", "interactive_spacing_jitter_seconds", "interactive_sends_per_hour", "relations_ingest_reads_per_day", "api_ingest_reads_per_day", "messages_ingest_reads_per_day", "network_backfill_reads_per_day", "network_delta_reads_per_day"];
328
+ export type LinkedInSenderLimitKey = (typeof LINKEDIN_SENDER_LIMIT_KEYS)[number];
329
+ type SameKeys<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
330
+ export type LinkedInSenderLimitKeysAreComplete = SameKeys<LinkedInSenderLimitKey, keyof LinkedInSenderLimits> extends true ? true : ["LINKEDIN_SENDER_LIMIT_KEYS is missing a LinkedInSenderLimits key"];
320
331
  /**
321
332
  * Safe defaults for LinkedIn action limits.
322
333
  *
@@ -196,6 +196,47 @@ export function truncateLinkedInInviteNote(note, limit) {
196
196
  const kept = cut === 0 ? head : head.slice(0, cut);
197
197
  return { note: kept.join("").trimEnd(), truncated: true, originalLength };
198
198
  }
199
+ /**
200
+ * Every LinkedInSenderLimits key, in the order the CLI flags and the MCP
201
+ * `limits` schema list them (sends, reads, spacing, then the background ingest
202
+ * budgets). `SENDER_LIMITS_CONTRACT` is generated from this tuple, and a
203
+ * type-level test pins it to `keyof LinkedInSenderLimits`, so a new limit key
204
+ * cannot reach the defaults without reaching the CLI and MCP too.
205
+ */
206
+ export const LINKEDIN_SENDER_LIMIT_KEYS = [
207
+ "invites_per_day",
208
+ "invites_per_week",
209
+ "messages_per_day",
210
+ "inmails_per_day",
211
+ "inmails_per_month",
212
+ "profile_views_per_day",
213
+ "profile_lookups_per_day",
214
+ "endorsements_per_day",
215
+ "withdrawals_per_day",
216
+ "posts_per_day",
217
+ "follows_per_day",
218
+ "likes_per_day",
219
+ "comments_per_day",
220
+ "total_actions_per_day",
221
+ "relations_reads_per_day",
222
+ "messages_reads_per_day",
223
+ "searches_per_day",
224
+ "sales_nav_search_results_per_day",
225
+ "api_reads_per_day",
226
+ "total_reads_per_day",
227
+ "min_action_spacing_seconds",
228
+ "action_spacing_jitter_seconds",
229
+ "interactive_min_spacing_seconds",
230
+ "interactive_spacing_jitter_seconds",
231
+ "interactive_sends_per_hour",
232
+ "relations_ingest_reads_per_day",
233
+ "api_ingest_reads_per_day",
234
+ "messages_ingest_reads_per_day",
235
+ "network_backfill_reads_per_day",
236
+ "network_delta_reads_per_day",
237
+ ];
238
+ const limitKeysAreComplete = true;
239
+ void limitKeysAreComplete;
199
240
  /**
200
241
  * Safe defaults for LinkedIn action limits.
201
242
  *
@@ -216,10 +216,12 @@ export const OXYGEN_BRIEFING_RULES = [
216
216
  probes: [/never block a concrete ask/i],
217
217
  },
218
218
  {
219
+ // 2026-09-28: the always-listed dispatch pair replaced the reconnect as the
220
+ // way a strict host reaches a tool its profile does not list.
219
221
  id: "toolset-pack-recovery",
220
- rule: "On 'No such tool available', reconnect with the returned `?toolset=<toolset_pack>`; never load a full manifest by default.",
222
+ rule: "Run a tool the profile does not list through `oxygen_capabilities_read` or `oxygen_capabilities_call`; never load a full manifest by default.",
221
223
  home: "mcp",
222
- probes: [/\?toolset=<toolset_pack>/],
224
+ probes: [/oxygen_capabilities_read`? for reads or `?oxygen_capabilities_call/],
223
225
  },
224
226
  {
225
227
  // Both halves of the full-profile rule are load-bearing and they were carried
@@ -38,7 +38,8 @@ export declare const PLAN_BANDS_AWAITING_TABLE_ROW_CAPACITY_TEST: readonly PlanB
38
38
  * (`storage.<band>.*` in `REPRICING_2026_09_SCHEDULE`): today's envelope until
39
39
  * the effective-date switch, the ratified ladder from it on.
40
40
  * - A band awaiting the rows-per-Table capacity test has its ladder workspace
41
- * totals and keeps today's rows per Table.
41
+ * totals and keeps today's rows per Table, except on the self-hosted dev
42
+ * deployment, where the test runs.
42
43
  * - Every other band is already at its ladder value.
43
44
  */
44
45
  export declare function resolveWorkspaceTableCapacity(band: PlanBand, options?: RepricingOptions): WorkspaceTableCapacityLimits;
@@ -49,6 +49,20 @@ export const PLAN_BAND_TABLE_CAPACITY_TARGETS = Object.freeze({
49
49
  * had gated both); shipping a band's rows per Table is removing it from this list.
50
50
  */
51
51
  export const PLAN_BANDS_AWAITING_TABLE_ROW_CAPACITY_TEST = Object.freeze(["999", "1999"]);
52
+ /**
53
+ * The capacity test itself runs on the self-hosted dev deployment, which
54
+ * therefore applies the awaiting bands' full ladder: a 10,000,000-row Table
55
+ * cannot be measured behind a 3,000,000-row limit. Every other deployment,
56
+ * production included, keeps the list above. The exact selector is compared
57
+ * (not `resolveDeployEnv`, which throws on a typo) so a malformed value keeps
58
+ * the production answer instead of failing a limits read.
59
+ */
60
+ function awaitsTableRowCapacityTest(band, options) {
61
+ if (!PLAN_BANDS_AWAITING_TABLE_ROW_CAPACITY_TEST.includes(band))
62
+ return false;
63
+ const env = options.env ?? process.env;
64
+ return env.OXYGEN_DEPLOY_ENV?.trim().toLowerCase() !== "selfhost-dev";
65
+ }
52
66
  const STORAGE_FIELDS = [
53
67
  ["tableRowLimit", "table_row_limit"],
54
68
  ["workspaceRowLimit", "workspace_row_limit"],
@@ -62,12 +76,13 @@ const STORAGE_FIELDS = [
62
76
  * (`storage.<band>.*` in `REPRICING_2026_09_SCHEDULE`): today's envelope until
63
77
  * the effective-date switch, the ratified ladder from it on.
64
78
  * - A band awaiting the rows-per-Table capacity test has its ladder workspace
65
- * totals and keeps today's rows per Table.
79
+ * totals and keeps today's rows per Table, except on the self-hosted dev
80
+ * deployment, where the test runs.
66
81
  * - Every other band is already at its ladder value.
67
82
  */
68
83
  export function resolveWorkspaceTableCapacity(band, options = {}) {
69
84
  const target = PLAN_BAND_TABLE_CAPACITY_TARGETS[band];
70
- if (PLAN_BANDS_AWAITING_TABLE_ROW_CAPACITY_TEST.includes(band)) {
85
+ if (awaitsTableRowCapacityTest(band, options)) {
71
86
  return Object.freeze({ ...target, tableRowLimit: WORKSPACE_TABLE_CAPACITY.tableRowLimit });
72
87
  }
73
88
  const resolved = { ...target };
@@ -80,7 +95,7 @@ export function resolveWorkspaceTableCapacity(band, options = {}) {
80
95
  return Object.freeze(resolved);
81
96
  }
82
97
  export function describeWorkspaceTableCapacityStatus(band, options = {}) {
83
- if (PLAN_BANDS_AWAITING_TABLE_ROW_CAPACITY_TEST.includes(band)) {
98
+ if (awaitsTableRowCapacityTest(band, options)) {
84
99
  return { status: "awaiting_capacity_test", effectiveAt: null, target: PLAN_BAND_TABLE_CAPACITY_TARGETS[band] };
85
100
  }
86
101
  const state = readRepricingSwitch(options);
@@ -1 +1 @@
1
- export declare const OXYGEN_BUILD_VERSION = "1.1010.905";
1
+ export declare const OXYGEN_BUILD_VERSION = "1.1010.950";
@@ -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.1010.905";
2
+ export const OXYGEN_BUILD_VERSION = "1.1010.950";
@@ -46,4 +46,7 @@ export const OXYGEN_VERSION = OXYGEN_BUILD_VERSION;
46
46
  // stop costing a manual step — the lag rule still applies unchanged. The raise
47
47
  // also retired both per-route floors, managed inboxes (1.326.2) and the guarded
48
48
  // support agent reply (1.747.0): below the global floor they could refuse no one.
49
+ // Since 2026-09-28 the release-preparation step advances this literal to the
50
+ // highest CLI version published on npm for at least 30 days. It never lowers
51
+ // the floor. The source change lands on dev before the next production release.
49
52
  export const OXYGEN_MINIMUM_CLI_VERSION = "1.906.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.1010.905",
3
+ "version": "1.1010.950",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",