@sellable/mcp 0.1.520 → 0.1.522

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.
@@ -5,6 +5,15 @@ export type PaidInmailRefreshAction = {
5
5
  actionKey?: string | null;
6
6
  action?: Record<string, unknown>;
7
7
  };
8
+ export type PaidInmailCreditRefreshOptions = {
9
+ actionType?: string | null;
10
+ campaignId?: string | null;
11
+ tableId?: string | null;
12
+ columnId?: string | null;
13
+ threshold?: number | null;
14
+ maxStalenessSeconds?: number | null;
15
+ paidInmailCreditsMaxStalenessSeconds?: number | null;
16
+ };
8
17
  export type YoloPrimitiveExecution = {
9
18
  enabled: true;
10
19
  status: "no_action" | "read_only_reread" | "executed_and_reread" | "refused" | "skipped_after_paid_refresh";
@@ -161,7 +170,7 @@ export declare function approveGeneratedMessagesBatch(action: Record<string, unk
161
170
  refusalReason?: undefined;
162
171
  }>;
163
172
  export declare function continueSignalDiscoverySource(action: Record<string, unknown>, workspaceId?: string): Promise<YoloPrimitiveAttempt>;
164
- export declare function refreshPaidInmailCreditsWithRetry(senderId: string, workspaceId: string): Promise<{
173
+ export declare function refreshPaidInmailCreditsWithRetry(senderId: string, workspaceId: string, options?: PaidInmailCreditRefreshOptions): Promise<{
165
174
  receipt: import("./senders.js").RefreshPaidInmailCreditsResponse;
166
175
  attempts: number;
167
176
  errors: string[];
@@ -365,6 +365,79 @@ export function refillPrepareRequestHash(params) {
365
365
  : "no-row-selector",
366
366
  ].join(":");
367
367
  }
368
+ function prepReceiptFromResult(result) {
369
+ const root = recordValue(result);
370
+ if (!root)
371
+ return null;
372
+ const status = recordValue(root.status);
373
+ const progress = recordValue(root.progress) ?? recordValue(status?.progress);
374
+ return (recordValue(root.prepReceipt) ??
375
+ recordValue(progress?.receipt) ??
376
+ recordValue(root.receipt));
377
+ }
378
+ function normalizePrepareMessagesResult(result) {
379
+ const receipt = prepReceiptFromResult(result);
380
+ const root = recordValue(result);
381
+ if (!receipt || !root)
382
+ return result;
383
+ const world = recordValue(receipt.world);
384
+ const batchStop = stringValue(receipt.batchStop);
385
+ const brokenByReason = recordValue(world?.brokenByReason) ?? {};
386
+ const approvalCandidates = numberValue(world?.approvalCandidates) ?? 0;
387
+ const hasMoreFrontierRows = world?.hasMoreFrontierRows === true;
388
+ const stuckActiveCells = Array.isArray(world?.stuckActiveCells)
389
+ ? world.stuckActiveCells
390
+ : [];
391
+ const approvedNotDispatched = Array.isArray(world?.approvedNotDispatched)
392
+ ? world.approvedNotDispatched
393
+ : [];
394
+ return {
395
+ ...root,
396
+ prepReceipt: receipt,
397
+ exhaustionEvidence: {
398
+ clean: batchStop === "frontier_exhausted" &&
399
+ hasMoreFrontierRows === false &&
400
+ stuckActiveCells.length === 0 &&
401
+ approvedNotDispatched.every((entry) => {
402
+ const row = recordValue(entry);
403
+ return row?.terminal === true || approvedNotDispatched.length === 0;
404
+ }),
405
+ batchStop,
406
+ hasMoreFrontierRows,
407
+ approvalCandidates,
408
+ brokenByReason,
409
+ stuckActiveCells,
410
+ approvedNotDispatched,
411
+ },
412
+ };
413
+ }
414
+ function waitReceiptFromAction(action) {
415
+ const toolInput = actionToolInput(action);
416
+ const wait = recordValue(toolInput.wait) ??
417
+ recordValue(action.wait) ??
418
+ recordValue(recordValue(toolInput.refillReceipt)?.wait);
419
+ if (!wait)
420
+ return null;
421
+ const deadlineAt = stringValue(wait.deadlineAt);
422
+ const onExpiry = stringValue(wait.onExpiry);
423
+ if (!deadlineAt || !onExpiry)
424
+ return null;
425
+ return { deadlineAt, onExpiry };
426
+ }
427
+ function waitResultForAction(action) {
428
+ const wait = waitReceiptFromAction(action);
429
+ if (!wait)
430
+ return { waited: true };
431
+ const deadlineMs = Date.parse(wait.deadlineAt);
432
+ if (Number.isFinite(deadlineMs) && deadlineMs <= Date.now()) {
433
+ return {
434
+ waited: false,
435
+ reason: "wait_deadline_expired",
436
+ wait,
437
+ };
438
+ }
439
+ return { waited: true, wait };
440
+ }
368
441
  export function boundedApprovalLimit(action) {
369
442
  const toolInput = actionToolInput(action);
370
443
  const rowSelector = recordValue(toolInput.rowSelector);
@@ -545,13 +618,14 @@ export async function continueSignalDiscoverySource(action, workspaceId) {
545
618
  },
546
619
  };
547
620
  }
548
- export async function refreshPaidInmailCreditsWithRetry(senderId, workspaceId) {
621
+ export async function refreshPaidInmailCreditsWithRetry(senderId, workspaceId, options = {}) {
549
622
  const errors = [];
550
623
  for (let attempt = 1; attempt <= PAID_INMAIL_REFRESH_MAX_ATTEMPTS; attempt += 1) {
551
624
  try {
552
625
  const receipt = await refreshPaidInmailCredits({
553
626
  senderId,
554
627
  workspaceId,
628
+ ...options,
555
629
  });
556
630
  return { receipt, attempts: attempt, errors };
557
631
  }
@@ -673,7 +747,7 @@ export async function executeOneYoloPrimitive(action, workspaceId) {
673
747
  case "wait_for_scheduler":
674
748
  case "wait_for_active_work":
675
749
  case "wait_for_source_import":
676
- return { status: "read_only_reread", result: { waited: true } };
750
+ return { status: "read_only_reread", result: waitResultForAction(action) };
677
751
  case "prepare_messages": {
678
752
  const campaignId = actionCampaignId(action);
679
753
  const tableId = actionTableId(action) ?? undefined;
@@ -712,7 +786,10 @@ export async function executeOneYoloPrimitive(action, workspaceId) {
712
786
  }),
713
787
  requestSource: "refill_sends",
714
788
  });
715
- return { status: "executed_and_reread", result };
789
+ return {
790
+ status: "executed_and_reread",
791
+ result: normalizePrepareMessagesResult(result),
792
+ };
716
793
  }
717
794
  case "copy_selected_source_rows": {
718
795
  const campaignOfferId = actionCampaignId(action);
@@ -2,6 +2,7 @@ import { actionIds, collectPaidInmailRefreshActions, executeOneYoloPrimitive, fi
2
2
  import { getRefillTargetPlan } from "./refill-target-plan.js";
3
3
  import { createWorkspaceContext, normalizeExplicitWorkspaceId, } from "./workspace-context.js";
4
4
  const DEFAULT_SCHEDULER_FORWARD_HOURS = 48;
5
+ const MCP_PAID_INMAIL_CREDITS_MAX_STALENESS_SECONDS = 4 * 60 * 60;
5
6
  function normalizeHorizonSendDays(value) {
6
7
  if (value === undefined || value === null)
7
8
  return null;
@@ -50,7 +51,7 @@ function normalizeTargetDate(value) {
50
51
  export const refillSendsToolDefinitions = [
51
52
  {
52
53
  name: "refill_sends",
53
- description: "Typed command entrypoint for Sellable refill sends. Accepts --yolo semantics and optional sender selectors, then returns the bounded execution contract for the skill-led refill workflow. In --yolo, it may refresh stale paid InMail credit cache facts once, or execute exactly one safe primitive from target.globalActionQueue[0] (bounded existing-row preparation, bounded same-source copy, bounded generated-message approval, or read-only wait) and reread. It does not run unbounded approval, lower paid InMail thresholds, switch source families, create campaigns, schedule sends, launch, archive, delete, or write scheduler rows.",
54
+ description: "Typed command entrypoint for Sellable refill sends. Accepts --yolo semantics and optional sender selectors, then returns the bounded execution contract for the skill-led refill workflow. In --yolo, it may refresh stale paid InMail credit cache facts once, or execute exactly one safe primitive from target.globalActionQueue[0] (bounded existing-row preparation, bounded generated-message approval, receipt-proven same-source copy, or read-only wait) and reread. Same-source copy/source fallback is only safe after receipt-proven exhaustion: hasMoreFrontierRows:false, zero approvalCandidates, no stuckActiveCells, and no non-terminal approvedNotDispatched work. Wait primitives are gates, not competing goals, and carry absolute wait.deadlineAt when present. It does not run unbounded approval, lower paid InMail thresholds, switch source families, create campaigns, schedule sends, launch, archive, delete, or write scheduler rows.",
54
55
  inputSchema: {
55
56
  type: "object",
56
57
  properties: {
@@ -209,6 +210,7 @@ export function refillSendsCommand(input = {}) {
209
210
  : `, horizonSendDays: ${horizonSendDays}`}, approvalMode: "${approvalMode}" }) before any import, prep, approval, start, or schedule-affecting action.`,
210
211
  "Render target.eligibleSenderLedger and target.senderRefillPlans before mutation. Preserve the coverage labels Need to prepare, Goal, Already sent, Scheduled, Ready and waiting to be scheduled, and Still need.",
211
212
  "Use target.globalActionQueue as the only cross-sender yolo queue: execute only target.globalActionQueue[0], one globally ranked primitive, then rerun get_refill_target_plan before choosing another action.",
213
+ "Read target.senderRefillPlans[].refillReceipt as the public ladder receipt: it carries the selected campaign/sender/lane summary, skippedRungs, existingRowFrontier, and any absolute wait.deadlineAt. Do not choose source/copy/fallback work until that receipt proves the earlier ready/prep/approval rungs are exhausted.",
212
214
  "If get_refill_target_plan returns status complete, report eligible sender ledger, target.senderRefillPlans, gross target, selected days, sent count, scheduled count, projected count, campaign ids, targetShapeRevision, and no-op proof without asking for approval.",
213
215
  "Refill target lanes are connection invites (send_invite), standalone paid InMails (send_inmail_closed), or unified Sales Nav cascades represented publicly as send_inmail_closed with campaign classification sales_nav_cascade. For a Sales Nav cascade, refill the selected campaign first; its sequence can route prospects to Open InMail, paid InMail while fresh credits are >= 5, or same-campaign connection fallback without asking for separate open/paid/connection campaigns. Do not count send_dm as horizon target capacity.",
214
216
  "If remainingReadyOrProjectedGap is 0 but remainingProjectedGap is positive, run only a persistent read-only scheduler wait/reread loop; do not ask for prep/import/approval and do not close out while scheduler pickup is the only remaining state.",
@@ -228,15 +230,17 @@ export function refillSendsCommand(input = {}) {
228
230
  "Treat current dashboard-active PAUSED campaign-backed sequence campaigns as start-eligible candidates: read refill state before deciding whether to prep, approve, start, or skip.",
229
231
  "Fresh reread get_campaign_refill_state immediately before any import, prep, approval, or horizon-fill mutation.",
230
232
  "Maintain a target-window saturation ledger per selected sender from get_refill_target_plan: selected days, gross capacity, actual sent counts, future scheduler-owned scheduled counts, projected counts, ready-to-schedule buffer, remaining projected gap, paid InMail threshold feasibility, targetShapeRevision, stateRevision, and next MCP primitive.",
233
+ "Source/fallback primitives require receipt-proven exhaustion: existingRowFrontier.hasMoreFrontierRows:false, approvalCandidates:0, no fresh active prep, no stuckActiveCells, and no non-terminal approvedNotDispatched rows. Treat anomalies, stuckActiveCells, and non-terminal approvedNotDispatched as diagnose-and-report gates, not exhaustion. Terminal approvedNotDispatched blockers may be reported and then the ladder can proceed.",
231
234
  "In --yolo, this tool automatically maintains a run-local refreshedPaidInmailSenderIds set: when the first target plan returns refresh_paid_inmail_credits candidates for selected paid-InMail lanes, it refreshes each exact sender at most once, reruns get_refill_target_plan, and returns the post-refresh targetPlan before any prep/source-copy/bounded-approval/read-only wait action is chosen.",
232
235
  "Freshness gate precedes scheduler wait: if any selected target.senderRefillPlans[].paidInmail.status is missing_credit_facts or stale_credit_facts, or the target plan contains refresh_paid_inmail_credits, do not enter wait_for_scheduler even when remainingReadyOrProjectedGap is 0; refresh exact sender credit facts once, reread get_refill_target_plan, then choose scheduler wait only if freshness is clean.",
236
+ "For wait_for_active_work and wait_for_scheduler, honor the receipt's absolute wait.deadlineAt when present. If the deadline is expired on this call, escalate to diagnostics with the receipt evidence instead of issuing another blind wait.",
233
237
  "Do not present paid-credit refresh as the next operator action after refill_sends returns autoPaidInmailRefresh and the post-refresh targetPlan.",
234
238
  "If paid InMail feasibility remains below threshold after that automatic refresh, report the exact sender/campaign/table/column threshold action or same-campaign connection fallback; --yolo must not lower paid InMail thresholds or create campaigns.",
235
239
  "In --yolo or after one Accept, continue through every safe selected sender/campaign action covered by the rendered target packet; sent/scheduled-count progress changes stateRevision and should continue while targetShapeRevision is stable.",
236
240
  "Do not complete a fill/schedule request until a final get_refill_target_plan or refill-state reread proves projected coverage (sent + scheduled) fills the scheduler-forward target window. Treat awaiting_scheduler_after_ready_buffer as loaded, awaiting scheduler when ready buffer covers the gap; do not import/prep more rows in that state, and keep polling unless Christian stops/statuses the run or a concrete non-scheduler blocker such as paid_inmail_below_threshold appears.",
237
241
  ],
238
242
  approvalContract: yolo
239
- ? "Auto-accept only the rendered bounded refill target packet after get_refill_target_plan and fresh reread. Execute one globally ranked primitive from target.globalActionQueue at a time, then rerun get_refill_target_plan before taking another action. Continue through every safe prep/source-copy/bounded-approval/read-only wait action inside that packet. Unbounded approval, start_campaign, source-family switches, threshold changes, and campaign creation require their own explicit gates. After each safe action, rerun get_refill_target_plan; keep going while targetShapeRevision is stable and projected coverage (sent + scheduled) progresses toward the target, even though stateRevision changes. Ready-to-schedule rows are buffer, not completion. If ready buffer covers the projected gap, use only persistent read-only scheduler wait/reread and keep the run open until projected coverage fills or Christian stops/statuses it. Stop immediately if targetShapeRevision changes because sender set, route, ids, caps, dates, blockers, action class, paid InMail threshold feasibility, or side-effect class drifts. --yolo does not authorize lowering paid InMail thresholds or creating connection campaigns."
243
+ ? "Auto-accept only the rendered bounded refill target packet after get_refill_target_plan and fresh reread. Execute one globally ranked primitive from target.globalActionQueue at a time, then rerun get_refill_target_plan before taking another action. Continue through every safe prep/source-copy/bounded-approval/read-only wait action inside that packet, but source-copy/fallback requires receipt-proven exhaustion of earlier ready/prep/approval rungs. Unbounded approval, start_campaign, source-family switches, threshold changes, and campaign creation require their own explicit gates. After each safe action, rerun get_refill_target_plan; keep going while targetShapeRevision is stable and projected coverage (sent + scheduled) progresses toward the target, even though stateRevision changes. Ready-to-schedule rows are buffer, not completion. If ready buffer covers the projected gap, use only persistent read-only scheduler wait/reread and keep the run open until projected coverage fills or Christian stops/statuses it. Stop immediately if targetShapeRevision changes because sender set, route, ids, caps, dates, blockers, action class, paid InMail threshold feasibility, side-effect class, or receipt exhaustion proof drifts. --yolo does not authorize lowering paid InMail thresholds or creating connection campaigns."
240
244
  : hasSenderSelectors
241
245
  ? "Before mutation, post the full bounded refill packet in normal chat as Markdown, including workspace, sender scope, campaign table, exact ids, caps/dates, gross target, sent count, scheduled count, projected count, ready buffer, remaining projected gap, paid InMail threshold feasibility, targetShapeRevision/stateRevision, side effects, forbidden actions, and stop condition. Then ask the final Accept/Decline structured approval question with a compact body that refers back to the posted packet instead of duplicating it. Only ask when get_refill_target_plan reports a positive remaining projected/ready gap. If target is complete by projected coverage, no-op without approval. If ready buffer covers the projected gap, run only persistent read-only scheduler wait/reread and keep the run open until projected coverage fills or Christian stops/statuses it. Threshold changes and campaign creation need separate explicit approval."
242
246
  : "Ask which eligible enrolled senders to refill first, then, before mutation, post the full bounded refill packet in normal chat as Markdown and ask the final Accept/Decline structured approval question with a compact body that refers back to the posted packet.",
@@ -249,7 +253,7 @@ export function refillSendsCommand(input = {}) {
249
253
  "direct scheduler writes",
250
254
  "sender reassignment",
251
255
  "campaigns outside the rendered eligible sender packet",
252
- "on-demand or unrelated active-campaign fallback unless the refill workflow proves the selected same-sender lane is blocked or exhausted",
256
+ "on-demand or unrelated active-campaign fallback unless the refill workflow shows receipt-proven exhaustion for the selected same-sender lane",
253
257
  ],
254
258
  hostExamples: {
255
259
  claude: [
@@ -326,6 +330,9 @@ function paidRefreshApprovalPacket(params) {
326
330
  const action = params.action.action ?? {};
327
331
  const ids = actionIds(action);
328
332
  const threshold = numberValue(action.oldThreshold) ?? numberValue(action.threshold);
333
+ const maxStalenessSeconds = Math.max(numberValue(action.maxStalenessSeconds) ??
334
+ numberValue(action.paidInmailCreditsMaxStalenessSeconds) ??
335
+ MCP_PAID_INMAIL_CREDITS_MAX_STALENESS_SECONDS, MCP_PAID_INMAIL_CREDITS_MAX_STALENESS_SECONDS);
329
336
  return {
330
337
  approvalSource: "explicit_yolo_flag",
331
338
  workspaceId: params.workspaceId ?? null,
@@ -336,12 +343,22 @@ function paidRefreshApprovalPacket(params) {
336
343
  tableId: stringValue(ids.tableId) ?? stringValue(action.tableId) ?? null,
337
344
  columnId: stringValue(ids.columnId) ?? stringValue(action.columnId) ?? null,
338
345
  threshold: threshold ?? null,
339
- maxStalenessSeconds: numberValue(action.maxStalenessSeconds) ??
340
- numberValue(action.paidInmailCreditsMaxStalenessSeconds) ??
341
- null,
346
+ maxStalenessSeconds,
342
347
  targetPlanFingerprint: targetPlanFingerprint(params.targetPlan),
343
348
  };
344
349
  }
350
+ function paidRefreshOptionsFromApprovalPacket(packet) {
351
+ return {
352
+ ...(packet.actionType ? { actionType: packet.actionType } : {}),
353
+ ...(packet.campaignId ? { campaignId: packet.campaignId } : {}),
354
+ ...(packet.tableId ? { tableId: packet.tableId } : {}),
355
+ ...(packet.columnId ? { columnId: packet.columnId } : {}),
356
+ ...(typeof packet.threshold === "number"
357
+ ? { threshold: packet.threshold }
358
+ : {}),
359
+ maxStalenessSeconds: packet.maxStalenessSeconds,
360
+ };
361
+ }
345
362
  export async function executeRefillSendsCommand(input = {}) {
346
363
  const yolo = input.yolo === true;
347
364
  const executionMode = input.executionMode ?? (yolo ? "yolo" : "manual");
@@ -404,7 +421,7 @@ export async function executeRefillSendsCommand(input = {}) {
404
421
  });
405
422
  continue;
406
423
  }
407
- const refreshResult = await refreshPaidInmailCreditsWithRetry(action.senderId, workspaceId);
424
+ const refreshResult = await refreshPaidInmailCreditsWithRetry(action.senderId, workspaceId, paidRefreshOptionsFromApprovalPacket(approvalPacket));
408
425
  if (refreshResult.receipt) {
409
426
  refreshedPaidInmailSenderIds.push(action.senderId);
410
427
  refreshReceipts.push({
@@ -36,6 +36,11 @@ const TERMINAL_NO_ACTION_BLOCKER_CODES = new Set([
36
36
  "paid_inmail_credit_missing",
37
37
  "paid_inmail_credit_stale",
38
38
  ]);
39
+ const MCP_PAID_INMAIL_CREDITS_MAX_STALENESS_SECONDS = 4 * 60 * 60;
40
+ const PAID_INMAIL_REFRESH_STATUSES = new Set([
41
+ "missing_credit_facts",
42
+ "stale_credit_facts",
43
+ ]);
39
44
  const SOURCE_PLAN_STATES = new Set([
40
45
  "not_needed",
41
46
  "use_existing_rows",
@@ -73,15 +78,37 @@ function actionTypesFrom(value) {
73
78
  function numberValue(value) {
74
79
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
75
80
  }
81
+ function optionalNumberValue(value) {
82
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
83
+ }
76
84
  function stringValue(value) {
77
85
  return typeof value === "string" ? value : undefined;
78
86
  }
79
87
  function booleanValue(value) {
80
88
  return typeof value === "boolean" ? value : undefined;
81
89
  }
90
+ function numericRecord(value) {
91
+ if (!isRecord(value))
92
+ return {};
93
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "number" && Number.isFinite(entry[1])));
94
+ }
82
95
  function campaignClassificationValue(value) {
83
96
  return value === "sales_nav_cascade" ? "sales_nav_cascade" : undefined;
84
97
  }
98
+ function normalizePaidInmailMaxStalenessSeconds(value) {
99
+ const numeric = optionalNumberValue(value);
100
+ return numeric !== null && numeric > 0
101
+ ? Math.max(Math.trunc(numeric), MCP_PAID_INMAIL_CREDITS_MAX_STALENESS_SECONDS)
102
+ : MCP_PAID_INMAIL_CREDITS_MAX_STALENESS_SECONDS;
103
+ }
104
+ function isFreshWithinMcpPaidInmailWindow(checkedAt, maxStalenessSeconds) {
105
+ if (!checkedAt)
106
+ return false;
107
+ const checkedAtMs = new Date(checkedAt).getTime();
108
+ if (!Number.isFinite(checkedAtMs))
109
+ return false;
110
+ return Date.now() - checkedAtMs <= maxStalenessSeconds * 1000;
111
+ }
85
112
  function stringArray(value) {
86
113
  if (!Array.isArray(value))
87
114
  return [];
@@ -133,7 +160,7 @@ function sanitizeCounts(counts, selectedKeys) {
133
160
  return Boolean(key && selectedKeys.has(key));
134
161
  });
135
162
  }
136
- function sanitizeActionCandidates(candidates, selectedKeys, status, remainingReadyOrProjectedGap, remainingProjectedGap) {
163
+ function sanitizeActionCandidates(candidates, selectedKeys, paidRefreshNeededSenderIds, status, remainingReadyOrProjectedGap, remainingProjectedGap) {
137
164
  const filtered = Array.isArray(candidates)
138
165
  ? candidates
139
166
  .filter((item) => {
@@ -144,6 +171,13 @@ function sanitizeActionCandidates(candidates, selectedKeys, status, remainingRea
144
171
  return false;
145
172
  }
146
173
  const actionType = item.actionType;
174
+ if (item.type === "refresh_paid_inmail_credits") {
175
+ const ids = isRecord(item.ids) ? item.ids : {};
176
+ const senderId = stringValue(item.senderId) ?? stringValue(ids.senderId);
177
+ return Boolean(senderId &&
178
+ paidRefreshNeededSenderIds.has(senderId) &&
179
+ selectedKeys.has(`${senderId}:send_inmail_closed`));
180
+ }
147
181
  if (actionType === undefined)
148
182
  return item.type === "wait_for_scheduler";
149
183
  const allowed = allowedActionType(actionType);
@@ -190,6 +224,8 @@ function sanitizeActionCandidate(candidate) {
190
224
  columnRole: stringValue(candidate.columnRole),
191
225
  rowSelector: sanitizeRowSelector(candidate.rowSelector),
192
226
  actionKey: stringValue(candidate.actionKey),
227
+ wait: sanitizeWait(candidate.wait),
228
+ refillReceipt: sanitizeRefillReceipt(candidate.refillReceipt),
193
229
  rereadAfter: candidate.rereadAfter === "get_refill_target_plan"
194
230
  ? "get_refill_target_plan"
195
231
  : undefined,
@@ -217,6 +253,98 @@ function sanitizeRowSelector(value) {
217
253
  : {}),
218
254
  };
219
255
  }
256
+ function sanitizeWait(value) {
257
+ if (!isRecord(value))
258
+ return undefined;
259
+ const deadlineAt = stringValue(value.deadlineAt);
260
+ const onExpiry = stringValue(value.onExpiry);
261
+ if (!deadlineAt && !onExpiry)
262
+ return undefined;
263
+ return { deadlineAt, onExpiry };
264
+ }
265
+ function sanitizeStuckActiveCells(value) {
266
+ if (!Array.isArray(value))
267
+ return [];
268
+ return value
269
+ .filter((cell) => isRecord(cell))
270
+ .map((cell) => ({
271
+ cellId: stringValue(cell.cellId),
272
+ rowId: stringValue(cell.rowId),
273
+ status: stringValue(cell.status),
274
+ ageMs: typeof cell.ageMs === "number" ? cell.ageMs : undefined,
275
+ }));
276
+ }
277
+ function sanitizeApprovedNotDispatched(value) {
278
+ if (!Array.isArray(value))
279
+ return [];
280
+ return value
281
+ .filter((row) => isRecord(row))
282
+ .map((row) => ({
283
+ rowId: stringValue(row.rowId),
284
+ attempts: typeof row.attempts === "number" ? row.attempts : undefined,
285
+ reason: stringValue(row.reason),
286
+ terminal: booleanValue(row.terminal),
287
+ }));
288
+ }
289
+ function sanitizeExistingRowFrontier(value) {
290
+ if (!isRecord(value))
291
+ return undefined;
292
+ return {
293
+ proof: stringValue(value.proof),
294
+ batchStop: stringValue(value.batchStop),
295
+ hasMoreFrontierRows: booleanValue(value.hasMoreFrontierRows),
296
+ stageCounts: numericRecord(value.stageCounts),
297
+ activeCellCount: typeof value.activeCellCount === "number"
298
+ ? value.activeCellCount
299
+ : undefined,
300
+ approvalCandidates: typeof value.approvalCandidates === "number"
301
+ ? value.approvalCandidates
302
+ : undefined,
303
+ brokenByReason: numericRecord(value.brokenByReason),
304
+ stuckActiveCells: sanitizeStuckActiveCells(value.stuckActiveCells),
305
+ approvedNotDispatched: sanitizeApprovedNotDispatched(value.approvedNotDispatched),
306
+ wait: sanitizeWait(value.wait),
307
+ };
308
+ }
309
+ function sanitizeSkippedRungs(value) {
310
+ if (!Array.isArray(value))
311
+ return [];
312
+ return value
313
+ .filter((rung) => isRecord(rung))
314
+ .map((rung) => ({
315
+ rung: stringValue(rung.rung),
316
+ reason: stringValue(rung.reason),
317
+ detail: stringValue(rung.detail),
318
+ }));
319
+ }
320
+ function sanitizeRefillReceipt(value) {
321
+ if (!isRecord(value))
322
+ return undefined;
323
+ return {
324
+ summary: stringValue(value.summary),
325
+ schedulableGap: typeof value.schedulableGap === "number"
326
+ ? value.schedulableGap
327
+ : undefined,
328
+ schedulerFillableGap: typeof value.schedulerFillableGap === "number"
329
+ ? value.schedulerFillableGap
330
+ : undefined,
331
+ selectedCampaignId: stringValue(value.selectedCampaignId),
332
+ selectedCampaignName: stringValue(value.selectedCampaignName),
333
+ selectedTableId: stringValue(value.selectedTableId),
334
+ senderId: stringValue(value.senderId),
335
+ senderName: stringValue(value.senderName),
336
+ actionType: allowedActionType(value.actionType),
337
+ dateWindow: isRecord(value.dateWindow)
338
+ ? { selectedDates: stringArray(value.dateWindow.selectedDates) }
339
+ : undefined,
340
+ readyBuffer: typeof value.readyBuffer === "number" ? value.readyBuffer : undefined,
341
+ approvalCandidates: typeof value.approvalCandidates === "number"
342
+ ? value.approvalCandidates
343
+ : undefined,
344
+ existingRowFrontier: sanitizeExistingRowFrontier(value.existingRowFrontier),
345
+ skippedRungs: sanitizeSkippedRungs(value.skippedRungs),
346
+ };
347
+ }
220
348
  function sanitizeRerunErroredCellsOperations(value) {
221
349
  if (!Array.isArray(value))
222
350
  return [];
@@ -285,6 +413,8 @@ function sanitizeToolInput(value) {
285
413
  : undefined,
286
414
  threshold: typeof value.threshold === "number" ? value.threshold : undefined,
287
415
  operations: sanitizeRerunErroredCellsOperations(value.operations),
416
+ wait: sanitizeWait(value.wait),
417
+ refillReceipt: sanitizeRefillReceipt(value.refillReceipt),
288
418
  };
289
419
  }
290
420
  function sanitizePacketIds(value) {
@@ -328,6 +458,7 @@ function sanitizeStructuredAction(action, selectedBySender, mode, rank) {
328
458
  toolInput: sanitizeToolInput(action.toolInput),
329
459
  inputSummary: stringValue(action.inputSummary) ?? "",
330
460
  reason: stringValue(action.reason) ?? "",
461
+ refillReceipt: sanitizeRefillReceipt(action.refillReceipt),
331
462
  prerequisites: stringArray(action.prerequisites),
332
463
  rereadAfter: action.rereadAfter === "get_refill_target_plan"
333
464
  ? "get_refill_target_plan"
@@ -430,20 +561,41 @@ function sanitizeSourcePlan(value) {
430
561
  function sanitizePaidInmail(value) {
431
562
  if (!isRecord(value))
432
563
  return null;
564
+ const originalMaxStalenessSeconds = optionalNumberValue(value.maxStalenessSeconds);
565
+ const maxStalenessSeconds = normalizePaidInmailMaxStalenessSeconds(value.maxStalenessSeconds);
566
+ const checkedAt = stringValue(value.checkedAt) ?? null;
567
+ const availableCredits = numberValue(value.availableCredits);
568
+ const threshold = numberValue(value.threshold);
569
+ let status = stringValue(value.status) ?? "missing_credit_facts";
570
+ let reason = stringValue(value.reason) ?? "";
571
+ let freshnessPolicy;
572
+ if (status === "stale_credit_facts" &&
573
+ isFreshWithinMcpPaidInmailWindow(checkedAt, maxStalenessSeconds)) {
574
+ status = availableCredits < threshold ? "below_threshold" : "ok";
575
+ reason =
576
+ status === "below_threshold"
577
+ ? "MCP normalized paid InMail freshness to 4h; cached credits are fresh enough but below threshold."
578
+ : "MCP normalized paid InMail freshness to 4h; cached credits are fresh enough.";
579
+ freshnessPolicy = {
580
+ normalizedBy: "mcp",
581
+ minMaxStalenessSeconds: MCP_PAID_INMAIL_CREDITS_MAX_STALENESS_SECONDS,
582
+ originalMaxStalenessSeconds,
583
+ };
584
+ }
433
585
  return {
434
- status: stringValue(value.status) ?? "missing_credit_facts",
435
- reason: stringValue(value.reason) ?? "",
436
- checkedAt: stringValue(value.checkedAt) ?? null,
586
+ status,
587
+ reason,
588
+ checkedAt,
437
589
  balance: typeof value.balance === "number" ? value.balance : null,
438
590
  sentSince: numberValue(value.sentSince),
439
- availableCredits: numberValue(value.availableCredits),
440
- threshold: numberValue(value.threshold),
591
+ availableCredits,
592
+ threshold,
441
593
  campaignId: stringValue(value.campaignId) ?? null,
442
594
  campaignName: stringValue(value.campaignName) ?? null,
443
595
  tableId: stringValue(value.tableId) ?? null,
444
596
  tableName: stringValue(value.tableName) ?? null,
445
597
  columnId: stringValue(value.columnId) ?? null,
446
- maxStalenessSeconds: numberValue(value.maxStalenessSeconds),
598
+ maxStalenessSeconds,
447
599
  campaignClassification: campaignClassificationValue(value.campaignClassification) ?? null,
448
600
  connectionFallbackAvailable: booleanValue(value.connectionFallbackAvailable) ?? false,
449
601
  connectionFallbackCampaignId: stringValue(value.connectionFallbackCampaignId) ?? null,
@@ -451,8 +603,19 @@ function sanitizePaidInmail(value) {
451
603
  suggestedThreshold: typeof value.suggestedThreshold === "number"
452
604
  ? value.suggestedThreshold
453
605
  : null,
606
+ ...(freshnessPolicy ? { freshnessPolicy } : {}),
454
607
  };
455
608
  }
609
+ function paidInmailNeedsRefresh(value) {
610
+ if (!isRecord(value))
611
+ return false;
612
+ const status = stringValue(value.status);
613
+ return Boolean(status && PAID_INMAIL_REFRESH_STATUSES.has(status));
614
+ }
615
+ function filterFreshPaidInmailRefreshAction(action, paidInmail) {
616
+ return (action.type !== "refresh_paid_inmail_credits" ||
617
+ paidInmailNeedsRefresh(paidInmail));
618
+ }
456
619
  function sanitizeStructuredSenderPlans(value, selectedBySender) {
457
620
  if (!Array.isArray(value))
458
621
  return [];
@@ -464,10 +627,12 @@ function sanitizeStructuredSenderPlans(value, selectedBySender) {
464
627
  return Boolean(selectedLane && selectedBySender.get(plan.senderId) === selectedLane);
465
628
  })
466
629
  .map((plan) => {
630
+ const paidInmail = sanitizePaidInmail(plan.paidInmail);
467
631
  const nextActions = Array.isArray(plan.nextActions)
468
632
  ? plan.nextActions
469
633
  .map((action, index) => sanitizeStructuredAction(action, selectedBySender, "next", index + 1))
470
634
  .filter((action) => Boolean(action))
635
+ .filter((action) => filterFreshPaidInmailRefreshAction(action, paidInmail))
471
636
  : [];
472
637
  const manualAlternates = Array.isArray(plan.manualAlternates)
473
638
  ? plan.manualAlternates
@@ -512,8 +677,9 @@ function sanitizeStructuredSenderPlans(value, selectedBySender) {
512
677
  : {},
513
678
  coverageDisplay: stringArray(plan.coverageDisplay),
514
679
  campaignRanking: sanitizeCampaignRanking(plan.campaignRanking),
515
- paidInmail: sanitizePaidInmail(plan.paidInmail),
680
+ paidInmail,
516
681
  sourcePlan: sanitizeSourcePlan(plan.sourcePlan),
682
+ refillReceipt: sanitizeRefillReceipt(plan.refillReceipt),
517
683
  nextActions,
518
684
  manualAlternates,
519
685
  ...(isRecord(plan.emptyState)
@@ -537,6 +703,17 @@ function buildSanitizedGlobalActionQueue(senderRefillPlans) {
537
703
  rank: index + 1,
538
704
  }));
539
705
  }
706
+ function paidRefreshNeededSenderIds(senderRefillPlans) {
707
+ const senderIds = new Set();
708
+ for (const plan of senderRefillPlans) {
709
+ if (!paidInmailNeedsRefresh(plan.paidInmail))
710
+ continue;
711
+ const senderId = stringValue(plan.senderId);
712
+ if (senderId)
713
+ senderIds.add(senderId);
714
+ }
715
+ return senderIds;
716
+ }
540
717
  function emptyUnsupportedResult(result) {
541
718
  if (!isRecord(result) || !isRecord(result.target))
542
719
  return result;
@@ -579,7 +756,7 @@ function emptyUnsupportedResult(result) {
579
756
  mcpSanitizedRefillLanes: true,
580
757
  };
581
758
  }
582
- function sanitizeBlockers(blockers, selectedKeys) {
759
+ function sanitizeBlockers(blockers, selectedKeys, paidRefreshNeededSenderIds) {
583
760
  if (!Array.isArray(blockers))
584
761
  return [];
585
762
  return blockers.filter((item) => {
@@ -591,6 +768,10 @@ function sanitizeBlockers(blockers, selectedKeys) {
591
768
  return true;
592
769
  const detailSenderId = detail.split(":")[0];
593
770
  if (code.startsWith("paid_inmail_")) {
771
+ if (code === "paid_inmail_credit_stale" &&
772
+ !paidRefreshNeededSenderIds.has(detailSenderId)) {
773
+ return false;
774
+ }
594
775
  return selectedKeys.has(`${detailSenderId}:send_inmail_closed`);
595
776
  }
596
777
  if (detail.includes(":send_dm") || detail.includes(":send_inmail_open")) {
@@ -679,9 +860,10 @@ function sanitizeRefillTargetPlanResult(result) {
679
860
  const remainingScheduledGap = senderPlans.reduce((sum, plan) => sum + numberValue(plan.remainingScheduledGap), 0);
680
861
  const remainingProjectedGap = senderPlans.reduce((sum, plan) => sum + numberValue(plan.remainingProjectedGap), 0);
681
862
  const remainingReadyOrProjectedGap = senderPlans.reduce((sum, plan) => sum + numberValue(plan.remainingReadyOrProjectedGap), 0);
682
- const blockers = sanitizeBlockers(result.blockers, selectedKeys);
683
863
  const eligibleSenderLedger = sanitizeEligibleSenderLedger(target.eligibleSenderLedger, selectedBySender);
684
864
  const senderRefillPlans = sanitizeStructuredSenderPlans(target.senderRefillPlans, selectedBySender);
865
+ const paidRefreshNeededSenderIdSet = paidRefreshNeededSenderIds(senderRefillPlans);
866
+ const blockers = sanitizeBlockers(result.blockers, selectedKeys, paidRefreshNeededSenderIdSet);
685
867
  const globalActionQueue = buildSanitizedGlobalActionQueue(senderRefillPlans);
686
868
  const hasTerminalNoActionBlocker = globalActionQueue.length === 0 &&
687
869
  blockers.some((blocker) => TERMINAL_NO_ACTION_BLOCKER_CODES.has(String(blocker.code)));
@@ -728,7 +910,7 @@ function sanitizeRefillTargetPlanResult(result) {
728
910
  readyCounts: sanitizeCounts(result.coverage.readyCounts, selectedKeys),
729
911
  }
730
912
  : result.coverage,
731
- actionCandidates: sanitizeActionCandidates(result.actionCandidates, selectedKeys, status, remainingReadyOrProjectedGap, remainingProjectedGap),
913
+ actionCandidates: sanitizeActionCandidates(result.actionCandidates, selectedKeys, paidRefreshNeededSenderIdSet, status, remainingReadyOrProjectedGap, remainingProjectedGap),
732
914
  blockers,
733
915
  mcpSanitizedRefillLanes: true,
734
916
  };
@@ -584,6 +584,97 @@ export declare const allTools: ({
584
584
  required: string[];
585
585
  additionalProperties: boolean;
586
586
  };
587
+ } | {
588
+ name: string;
589
+ description: string;
590
+ inputSchema: {
591
+ type: string;
592
+ properties: {
593
+ campaignId: {
594
+ type: string;
595
+ description: string;
596
+ };
597
+ tableId: {
598
+ type: string;
599
+ };
600
+ targetPreparedMessages: {
601
+ type: string;
602
+ minimum: number;
603
+ maximum: number;
604
+ };
605
+ maxRowsToCheck: {
606
+ type: string;
607
+ minimum: number;
608
+ maximum: number;
609
+ description: string;
610
+ };
611
+ batchSize: {
612
+ type: string;
613
+ minimum: number;
614
+ maximum: number;
615
+ description: string;
616
+ };
617
+ maxBatchRows: {
618
+ type: string;
619
+ minimum: number;
620
+ maximum: number;
621
+ description: string;
622
+ };
623
+ approvalMode: {
624
+ type: string;
625
+ enum: string[];
626
+ description: string;
627
+ };
628
+ rowSelector: {
629
+ type: string;
630
+ description: string;
631
+ properties: {
632
+ type: {
633
+ type: string;
634
+ enum: string[];
635
+ };
636
+ limit: {
637
+ type: string;
638
+ minimum: number;
639
+ maximum: number;
640
+ };
641
+ };
642
+ required: string[];
643
+ additionalProperties: boolean;
644
+ };
645
+ excludeRowIds: {
646
+ type: string;
647
+ items: {
648
+ type: string;
649
+ };
650
+ maxItems: number;
651
+ description: string;
652
+ };
653
+ autoContinue: {
654
+ type: string;
655
+ };
656
+ senderId: {
657
+ type: string;
658
+ description: string;
659
+ };
660
+ actionType: {
661
+ type: string;
662
+ enum: string[];
663
+ description: string;
664
+ };
665
+ disableLowPassRateStop: {
666
+ type: string;
667
+ description: string;
668
+ };
669
+ workspaceId: {
670
+ type: string;
671
+ description: string;
672
+ };
673
+ jobId?: undefined;
674
+ };
675
+ required: string[];
676
+ additionalProperties: boolean;
677
+ };
587
678
  } | {
588
679
  name: string;
589
680
  description: string;
@@ -2343,6 +2434,13 @@ export declare const allTools: ({
2343
2434
  type: string;
2344
2435
  description: string;
2345
2436
  };
2437
+ actionType?: undefined;
2438
+ campaignId?: undefined;
2439
+ tableId?: undefined;
2440
+ columnId?: undefined;
2441
+ threshold?: undefined;
2442
+ maxStalenessSeconds?: undefined;
2443
+ paidInmailCreditsMaxStalenessSeconds?: undefined;
2346
2444
  };
2347
2445
  required: string[];
2348
2446
  additionalProperties: boolean;
@@ -35,6 +35,13 @@ export type SenderDetailResponse = {
35
35
  export type RefreshPaidInmailCreditsInput = {
36
36
  senderId: string;
37
37
  workspaceId: string;
38
+ actionType?: string | null;
39
+ campaignId?: string | null;
40
+ tableId?: string | null;
41
+ columnId?: string | null;
42
+ threshold?: number | null;
43
+ maxStalenessSeconds?: number | null;
44
+ paidInmailCreditsMaxStalenessSeconds?: number | null;
38
45
  };
39
46
  export type PaidInmailCreditStatus = {
40
47
  available: number;
@@ -68,6 +75,13 @@ export declare const senderToolDefinitions: ({
68
75
  description: string;
69
76
  };
70
77
  senderId?: undefined;
78
+ actionType?: undefined;
79
+ campaignId?: undefined;
80
+ tableId?: undefined;
81
+ columnId?: undefined;
82
+ threshold?: undefined;
83
+ maxStalenessSeconds?: undefined;
84
+ paidInmailCreditsMaxStalenessSeconds?: undefined;
71
85
  };
72
86
  required: never[];
73
87
  additionalProperties: boolean;
@@ -86,6 +100,59 @@ export declare const senderToolDefinitions: ({
86
100
  type: string;
87
101
  description: string;
88
102
  };
103
+ actionType?: undefined;
104
+ campaignId?: undefined;
105
+ tableId?: undefined;
106
+ columnId?: undefined;
107
+ threshold?: undefined;
108
+ maxStalenessSeconds?: undefined;
109
+ paidInmailCreditsMaxStalenessSeconds?: undefined;
110
+ };
111
+ required: string[];
112
+ additionalProperties: boolean;
113
+ };
114
+ } | {
115
+ name: string;
116
+ description: string;
117
+ inputSchema: {
118
+ type: string;
119
+ properties: {
120
+ senderId: {
121
+ type: string;
122
+ description: string;
123
+ };
124
+ workspaceId: {
125
+ type: string;
126
+ description: string;
127
+ };
128
+ actionType: {
129
+ type: string;
130
+ description: string;
131
+ };
132
+ campaignId: {
133
+ type: string;
134
+ description: string;
135
+ };
136
+ tableId: {
137
+ type: string;
138
+ description: string;
139
+ };
140
+ columnId: {
141
+ type: string;
142
+ description: string;
143
+ };
144
+ threshold: {
145
+ type: string;
146
+ description: string;
147
+ };
148
+ maxStalenessSeconds: {
149
+ type: string;
150
+ description: string;
151
+ };
152
+ paidInmailCreditsMaxStalenessSeconds: {
153
+ type: string;
154
+ description: string;
155
+ };
89
156
  };
90
157
  required: string[];
91
158
  additionalProperties: boolean;
@@ -11,6 +11,19 @@ function pickString(...values) {
11
11
  }
12
12
  return null;
13
13
  }
14
+ const DEFAULT_PAID_INMAIL_CREDITS_MAX_STALENESS_SECONDS = 4 * 60 * 60;
15
+ function numberOrNull(value) {
16
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
17
+ }
18
+ function normalizePaidInmailCreditsMaxStalenessSeconds(...values) {
19
+ for (const value of values) {
20
+ const numeric = numberOrNull(value);
21
+ if (numeric === null || numeric <= 0)
22
+ continue;
23
+ return Math.max(Math.trunc(numeric), DEFAULT_PAID_INMAIL_CREDITS_MAX_STALENESS_SECONDS);
24
+ }
25
+ return DEFAULT_PAID_INMAIL_CREDITS_MAX_STALENESS_SECONDS;
26
+ }
14
27
  function computeDisplayName(sender) {
15
28
  const profile = sender?.unipileAccount?.profileData;
16
29
  const name = pickString(profile?.name);
@@ -148,6 +161,34 @@ export const senderToolDefinitions = [
148
161
  type: "string",
149
162
  description: "Explicit request-scoped workspace id for scheduled/yolo refill automation. Pass this instead of switching the shared active workspace.",
150
163
  },
164
+ actionType: {
165
+ type: "string",
166
+ description: "Optional action type context for the credit freshness check. Defaults to send_inmail_closed.",
167
+ },
168
+ campaignId: {
169
+ type: "string",
170
+ description: "Optional campaign context for the refresh receipt.",
171
+ },
172
+ tableId: {
173
+ type: "string",
174
+ description: "Optional workflow table context for the refresh receipt.",
175
+ },
176
+ columnId: {
177
+ type: "string",
178
+ description: "Optional workflow column context for the refresh receipt.",
179
+ },
180
+ threshold: {
181
+ type: "number",
182
+ description: "Optional paid InMail threshold used by the planner for this sender.",
183
+ },
184
+ maxStalenessSeconds: {
185
+ type: "number",
186
+ description: "Optional cache freshness window in seconds. Values below 4h are clamped to 4h.",
187
+ },
188
+ paidInmailCreditsMaxStalenessSeconds: {
189
+ type: "number",
190
+ description: "Optional legacy alias for maxStalenessSeconds. Values below 4h are clamped to 4h.",
191
+ },
151
192
  },
152
193
  required: ["senderId", "workspaceId"],
153
194
  additionalProperties: false,
@@ -213,7 +254,25 @@ export async function refreshPaidInmailCredits(input) {
213
254
  throw new Error("workspaceId is required.");
214
255
  }
215
256
  const requestOptions = workspaceRequestOptions(workspaceId);
216
- const body = { workspaceId };
257
+ const threshold = numberOrNull(input.threshold);
258
+ const body = {
259
+ workspaceId,
260
+ maxStalenessSeconds: normalizePaidInmailCreditsMaxStalenessSeconds(input.maxStalenessSeconds, input.paidInmailCreditsMaxStalenessSeconds),
261
+ };
262
+ const actionType = pickString(input.actionType);
263
+ const campaignId = pickString(input.campaignId);
264
+ const tableId = pickString(input.tableId);
265
+ const columnId = pickString(input.columnId);
266
+ if (actionType)
267
+ body.actionType = actionType;
268
+ if (campaignId)
269
+ body.campaignId = campaignId;
270
+ if (tableId)
271
+ body.tableId = tableId;
272
+ if (columnId)
273
+ body.columnId = columnId;
274
+ if (threshold !== null)
275
+ body.threshold = threshold;
217
276
  const result = await api.post(`/api/v3/mcp/senders/${encodeURIComponent(senderId)}/refresh-inmail-credits`, body, requestOptions);
218
277
  return {
219
278
  senderId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.520",
3
+ "version": "0.1.522",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -97,8 +97,11 @@ That command helper normalizes arguments and returns the execution contract. In
97
97
  non-yolo mode it does not mutate. In `--yolo`, it may execute exactly one safe
98
98
  bounded primitive from the fresh `target.globalActionQueue[0]`, then reread and
99
99
  return the new target plan; currently safe primitives are paid-credit refresh,
100
- existing-row message preparation, same-source row copy, and read-only wait
101
- rereads. It does not run unbounded approval, lower
100
+ existing-row message preparation, generated-message approval, receipt-proven
101
+ same-source row copy, and read-only wait rereads. Same-source copy/source
102
+ fallback is safe only after receipt-proven exhaustion:
103
+ `hasMoreFrontierRows:false`, zero `approvalCandidates`, no `stuckActiveCells`,
104
+ and no non-terminal `approvedNotDispatched` work. It does not run unbounded approval, lower
102
105
  paid-InMail thresholds, switch source families, create campaigns, launch, send,
103
106
  or write scheduler rows. Continue with the workflow below for route selection,
104
107
  state rereads, approval gating, source import, preparation, and bounded
@@ -198,7 +201,10 @@ Structured planner packet:
198
201
  - `target.senderRefillPlans[]` is the canonical sender-level packet; read and
199
202
  display it before mutation.
200
203
  - Each sender packet includes `campaignRanking.options`, `sourcePlan`,
201
- `nextActions`, and `manualAlternates`.
204
+ `refillReceipt`, `nextActions`, and `manualAlternates`.
205
+ - `refillReceipt` is the public ladder receipt. It carries the selected
206
+ campaign/sender/lane summary, skipped rungs, existing-row frontier proof, and
207
+ any absolute `wait.deadlineAt`.
202
208
  - Preserve these coverage labels exactly: `Need to prepare`, `Goal`,
203
209
  `Already sent`, `Scheduled`, `Ready and waiting to be scheduled`, and
204
210
  `Still need`.
@@ -217,6 +223,13 @@ from the selected source (`selectedLeadListId`, provider, and source
217
223
  fingerprint preserved), then use provider-aligned source-more. A new source or
218
224
  provider switch changes the reply-rate baseline and is a manual alternate, not a
219
225
  `--yolo` side effect.
226
+ Source/copy/fallback requires receipt-proven exhaustion of earlier rungs:
227
+ `existingRowFrontier.hasMoreFrontierRows:false`, zero `approvalCandidates`, no
228
+ fresh active prep, no `stuckActiveCells`, and no non-terminal
229
+ `approvedNotDispatched` rows. Treat anomalies, `stuckActiveCells`, and
230
+ non-terminal `approvedNotDispatched` as diagnose-and-report gates, not
231
+ exhaustion. Terminal `approvedNotDispatched` blockers may be reported, then the
232
+ ladder can proceed.
220
233
 
221
234
  Run-local paid-credit guard: in `--yolo`, the `refill_sends` MCP command
222
235
  automatically maintains a `refreshedPaidInmailSenderIds` set for the current
@@ -266,6 +279,10 @@ interval, until projected coverage fills the target, a concrete non-scheduler
266
279
  blocker appears, or Christian explicitly asks to stop or only receive a status
267
280
  report. Treat `awaiting_scheduler_after_ready_buffer` as an in-progress wait
268
281
  state, not a close-out condition.
282
+ Wait actions are gates, not competing goals. When `wait_for_active_work` or
283
+ `wait_for_scheduler` includes receipt `wait.deadlineAt`, honor that absolute
284
+ deadline; if it is expired on this call, escalate to diagnostics with the
285
+ receipt evidence instead of issuing another blind wait.
269
286
  If paid InMail credit facts are stale or missing and the first target plan
270
287
  contains `refresh_paid_inmail_credits`, do not present that as the operator's
271
288
  next action in `--yolo`. Do not present paid-credit refresh as the next operator
@@ -119,7 +119,10 @@ files or memory.
119
119
  - `target.senderRefillPlans[]` is the canonical sender-level packet; read and
120
120
  display it before mutation.
121
121
  - Each sender packet includes `campaignRanking.options`, `sourcePlan`,
122
- `nextActions`, and `manualAlternates`.
122
+ `refillReceipt`, `nextActions`, and `manualAlternates`.
123
+ - `refillReceipt` is the public ladder receipt. It carries the selected
124
+ campaign/sender/lane summary, skipped rungs, existing-row frontier proof,
125
+ and any absolute `wait.deadlineAt`.
123
126
  - Preserve these coverage labels exactly: `Need to prepare`, `Goal`,
124
127
  `Already sent`, `Scheduled`, `Ready and waiting to be scheduled`, and
125
128
  `Still need`.
@@ -138,6 +141,14 @@ files or memory.
138
141
  provider, and source fingerprint preserved), then use provider-aligned
139
142
  source-more. A new source or provider switch changes the reply-rate baseline
140
143
  and is a manual alternate, not a `--yolo` side effect.
144
+ Source/copy/fallback requires receipt-proven exhaustion of earlier rungs:
145
+ `existingRowFrontier.hasMoreFrontierRows:false`, zero
146
+ `approvalCandidates`, no fresh active prep, no `stuckActiveCells`, and no
147
+ non-terminal `approvedNotDispatched` rows. Treat anomalies,
148
+ `stuckActiveCells`, and non-terminal `approvedNotDispatched` as
149
+ diagnose-and-report gates, not exhaustion. Terminal
150
+ `approvedNotDispatched` blockers may be reported, then the ladder can
151
+ proceed.
141
152
  Run-local paid-credit guard: in `--yolo`, the `refill_sends` MCP command
142
153
  automatically maintains a `refreshedPaidInmailSenderIds` set for the current
143
154
  command call. If its first target plan has stale/missing paid-InMail credit
@@ -187,6 +198,10 @@ files or memory.
187
198
  stop or only receive a status report. Treat
188
199
  `awaiting_scheduler_after_ready_buffer` as an in-progress wait state, not a
189
200
  close-out condition.
201
+ Wait actions are gates, not competing goals. When `wait_for_active_work` or
202
+ `wait_for_scheduler` includes receipt `wait.deadlineAt`, honor that absolute
203
+ deadline; if it is expired on this call, escalate to diagnostics with the
204
+ receipt evidence instead of issuing another blind wait.
190
205
  If paid InMail credit facts are stale or missing and the first target plan
191
206
  contains `refresh_paid_inmail_credits`, do not present that as the operator's
192
207
  next action in `--yolo`. Do not present paid-credit refresh as the next