@sellable/mcp 0.1.531 → 0.1.533

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.
package/dist/auth.d.ts CHANGED
@@ -38,6 +38,7 @@ export type SkillState = {
38
38
  * - "sellable-dev.json" - local development
39
39
  */
40
40
  export declare function setConfigFile(fileName: string): void;
41
+ export declare function getResolvedConfigsDir(): string | null;
41
42
  export declare function getConfigPath(): string;
42
43
  export declare function getConfig(): SellableConfig;
43
44
  export declare function updateActiveWorkspace(params: {
package/dist/auth.js CHANGED
@@ -19,9 +19,9 @@ export function setConfigFile(fileName) {
19
19
  }
20
20
  function getConfigPathCandidates() {
21
21
  const candidates = [];
22
- const explicitConfigPath = process.env.SELLABLE_CONFIG_PATH?.trim();
22
+ const explicitConfigPath = getExplicitConfigPath();
23
23
  if (explicitConfigPath) {
24
- candidates.push(path.resolve(explicitConfigPath));
24
+ candidates.push(explicitConfigPath);
25
25
  }
26
26
  if (configFileName === "sellable.json") {
27
27
  candidates.push(path.join(os.homedir(), ".sellable", "config.json"));
@@ -34,12 +34,31 @@ function getConfigPathCandidates() {
34
34
  candidates.push(path.join(os.homedir(), ".claude", configFileName));
35
35
  return Array.from(new Set(candidates));
36
36
  }
37
+ function getExplicitConfigPath() {
38
+ const explicitConfigPath = process.env.SELLABLE_CONFIG_PATH?.trim();
39
+ return explicitConfigPath ? path.resolve(explicitConfigPath) : null;
40
+ }
41
+ export function getResolvedConfigsDir() {
42
+ const explicitConfigsDir = process.env.SELLABLE_CONFIGS_DIR?.trim();
43
+ if (explicitConfigsDir) {
44
+ return path.resolve(explicitConfigsDir);
45
+ }
46
+ const explicitConfigPath = getExplicitConfigPath();
47
+ if (explicitConfigPath) {
48
+ return path.join(path.dirname(explicitConfigPath), "configs");
49
+ }
50
+ return null;
51
+ }
37
52
  function renderConfigPathOrder(candidates) {
38
53
  return candidates
39
54
  .map((candidate, idx) => `${idx + 1}. ${candidate}`)
40
55
  .join("\n");
41
56
  }
42
57
  export function getConfigPath() {
58
+ const explicitConfigPath = getExplicitConfigPath();
59
+ if (explicitConfigPath) {
60
+ return explicitConfigPath;
61
+ }
43
62
  const candidates = getConfigPathCandidates();
44
63
  for (const candidate of candidates) {
45
64
  if (fs.existsSync(candidate)) {
@@ -49,9 +68,9 @@ export function getConfigPath() {
49
68
  return candidates[0];
50
69
  }
51
70
  function getConfigWritePath() {
52
- const explicitConfigPath = process.env.SELLABLE_CONFIG_PATH?.trim();
71
+ const explicitConfigPath = getExplicitConfigPath();
53
72
  if (explicitConfigPath) {
54
- return path.resolve(explicitConfigPath);
73
+ return explicitConfigPath;
55
74
  }
56
75
  if (configFileName === "sellable.json") {
57
76
  return path.join(os.homedir(), ".sellable", "config.json");
package/dist/index-dev.js CHANGED
File without changes
package/dist/index.js CHANGED
File without changes
@@ -19,6 +19,34 @@ const REFILL_DONE_REASONS = new Set([
19
19
  "not_an_evergreen_workspace",
20
20
  "no_refillable_campaigns",
21
21
  ]);
22
+ const SCHEDULER_RUN_ENVELOPE_STATUSES = new Set([
23
+ "ran",
24
+ "attached",
25
+ "backoff",
26
+ "window_closed_noop",
27
+ "failed",
28
+ ]);
29
+ const SCHEDULER_RUN_RECEIPT_STATUSES = new Set([
30
+ "ran",
31
+ "window_closed_noop",
32
+ "failed",
33
+ ]);
34
+ const SCHEDULER_RUN_SKIP_REASONS = [
35
+ "window_closed",
36
+ "daily_limit",
37
+ "cooldown",
38
+ "sender_gate",
39
+ "credit_threshold",
40
+ "billing_blocked",
41
+ "duplicate_lead",
42
+ "no_senders",
43
+ "other",
44
+ ];
45
+ const SCHEDULER_RUN_HARD_BLOCK_REASONS = new Set([
46
+ "billing_blocked",
47
+ "credit_threshold",
48
+ "cooldown",
49
+ ]);
22
50
  function isRecord(value) {
23
51
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
24
52
  }
@@ -40,6 +68,89 @@ function arrayValue(value) {
40
68
  function hasBlocker(value, blocker) {
41
69
  return isRecord(value) && value.blocker === blocker;
42
70
  }
71
+ function schedulerRunSkipReasons(value) {
72
+ const raw = recordValue(value) ?? {};
73
+ const normalized = {};
74
+ for (const reason of SCHEDULER_RUN_SKIP_REASONS) {
75
+ normalized[reason] = numberValue(raw[reason]) ?? 0;
76
+ }
77
+ return normalized;
78
+ }
79
+ function dominantSchedulerRunSkipReason(skipReasons) {
80
+ let dominant = null;
81
+ let dominantCount = 0;
82
+ for (const reason of SCHEDULER_RUN_SKIP_REASONS) {
83
+ const count = skipReasons[reason] ?? 0;
84
+ if (count > dominantCount) {
85
+ dominant = reason;
86
+ dominantCount = count;
87
+ }
88
+ }
89
+ return dominantCount > 0 ? dominant : null;
90
+ }
91
+ function sanitizeSchedulerRunReceipt(raw) {
92
+ const envelope = recordValue(raw);
93
+ if (!envelope)
94
+ return null;
95
+ const status = stringValue(envelope.status);
96
+ if (!status || !SCHEDULER_RUN_ENVELOPE_STATUSES.has(status))
97
+ return null;
98
+ const retryAfterMs = envelope.retryAfterMs == null ? null : numberValue(envelope.retryAfterMs);
99
+ if (envelope.retryAfterMs != null && retryAfterMs == null)
100
+ return null;
101
+ const receiptInput = envelope.receipt;
102
+ let receipt = null;
103
+ let dominantSkipReason = null;
104
+ if (receiptInput != null) {
105
+ const rawReceipt = recordValue(receiptInput);
106
+ if (!rawReceipt)
107
+ return null;
108
+ const receiptStatus = stringValue(rawReceipt.status);
109
+ if (!receiptStatus || !SCHEDULER_RUN_RECEIPT_STATUSES.has(receiptStatus)) {
110
+ return null;
111
+ }
112
+ const cellsConsidered = numberValue(rawReceipt.cellsConsidered);
113
+ const cellsScheduled = numberValue(rawReceipt.cellsScheduled);
114
+ const cellsSkipped = numberValue(rawReceipt.cellsSkipped);
115
+ const cellsDeferred = numberValue(rawReceipt.cellsDeferred);
116
+ const tablesFilteredForNoCapacity = numberValue(rawReceipt.tablesFilteredForNoCapacity);
117
+ if (cellsConsidered == null ||
118
+ cellsScheduled == null ||
119
+ cellsSkipped == null ||
120
+ cellsDeferred == null ||
121
+ tablesFilteredForNoCapacity == null) {
122
+ return null;
123
+ }
124
+ const skipReasons = schedulerRunSkipReasons(rawReceipt.skipReasons);
125
+ dominantSkipReason = dominantSchedulerRunSkipReason(skipReasons);
126
+ receipt = {
127
+ status: receiptStatus,
128
+ cellsConsidered,
129
+ cellsScheduled,
130
+ cellsSkipped,
131
+ cellsDeferred,
132
+ tablesFilteredForNoCapacity,
133
+ skipReasons,
134
+ };
135
+ }
136
+ return {
137
+ status,
138
+ retryAfterMs,
139
+ receipt,
140
+ dominantSkipReason,
141
+ hardBlocked: dominantSkipReason != null &&
142
+ SCHEDULER_RUN_HARD_BLOCK_REASONS.has(dominantSkipReason),
143
+ };
144
+ }
145
+ function schedulerRunReceiptIsFreshZeroScheduled(schedulerRunReceipt) {
146
+ const status = stringValue(schedulerRunReceipt?.status);
147
+ const fresh = status === "ran" || status === "window_closed_noop";
148
+ if (!fresh)
149
+ return false;
150
+ const receipt = recordValue(schedulerRunReceipt?.receipt);
151
+ return (status === "window_closed_noop" ||
152
+ numberValue(receipt?.cellsScheduled) === 0);
153
+ }
43
154
  function isLeaseLost(value) {
44
155
  return hasBlocker(value, "lease_lost");
45
156
  }
@@ -1013,22 +1124,34 @@ async function verifySchedulerWait(input, deps, ctx, action, budgets) {
1013
1124
  const enteredAt = stringValue(progress.schedulerWaitEnteredAt) ??
1014
1125
  (deps.now?.() ?? new Date()).toISOString();
1015
1126
  const jitFired = booleanValue(progress.schedulerJitFired) ?? false;
1127
+ let schedulerRunReceipt = recordValue(progress.schedulerRunReceipt) ?? null;
1016
1128
  if (!jitFired) {
1017
1129
  const senderId = actionSenderId(action);
1018
1130
  if (senderId) {
1019
1131
  await deps.executors.refreshPaidInmailCreditsWithRetry(senderId, input.workspaceId);
1020
1132
  }
1021
1133
  if (deps.requestSchedulerRun) {
1022
- await deps.requestSchedulerRun(input.workspaceId);
1134
+ try {
1135
+ schedulerRunReceipt = sanitizeSchedulerRunReceipt(await deps.requestSchedulerRun(input.workspaceId));
1136
+ }
1137
+ catch {
1138
+ schedulerRunReceipt = null;
1139
+ }
1023
1140
  }
1024
1141
  ctx.runState = mergeRunState(ctx.runState, {
1025
1142
  progress: mergeProgress(ctx, {
1026
1143
  schedulerWaitEnteredAt: enteredAt,
1027
1144
  schedulerJitFired: true,
1145
+ ...(schedulerRunReceipt ? { schedulerRunReceipt } : {}),
1028
1146
  }),
1029
1147
  });
1030
1148
  }
1031
- for (let poll = 0; poll < budgets.maxSchedulerReadbacks; poll += 1) {
1149
+ const zeroScheduledFresh = schedulerRunReceiptIsFreshZeroScheduled(schedulerRunReceipt);
1150
+ // EDGE-2: the on-demand run only executes placement; cron can still process
1151
+ // due/timed-out cells later. We still do one readback, then avoid burning the
1152
+ // full wait budget when the fresh receipt says nothing was placeable now.
1153
+ const readbackBudget = zeroScheduledFresh ? 1 : budgets.maxSchedulerReadbacks;
1154
+ for (let poll = 0; poll < readbackBudget; poll += 1) {
1032
1155
  const fresh = await deps.readPlan({
1033
1156
  workspaceId: input.workspaceId,
1034
1157
  intent: input.intent,
package/dist/server.js CHANGED
@@ -45,6 +45,7 @@ import { allTools } from "./tools/registry.js";
45
45
  import { getRows, getTableRows, getTableRowsMinimal } from "./tools/rows.js";
46
46
  import { addRubricItem, checkRubric, deleteRubricItem, draftRubrics, saveRubrics, selectNecessaryRubrics, updateRubricItem, waitForRubricResults, } from "./tools/rubrics.js";
47
47
  import { getSchedulerFillCapacity } from "./tools/scheduler-fill-capacity.js";
48
+ import { runSchedulerSweep } from "./tools/scheduler-run.js";
48
49
  import { getSenderRoutingTool, setSenderRoutingTool, } from "./tools/sender-routing.js";
49
50
  import { getSender, listSenders, refreshPaidInmailCredits, } from "./tools/senders.js";
50
51
  import { attachRecommendedSequence, attachSequence, createWorkflowTable, } from "./tools/sequencer.js";
@@ -235,6 +236,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
235
236
  case "get_scheduler_fill_capacity":
236
237
  result = await getSchedulerFillCapacity(args);
237
238
  break;
239
+ case "run_scheduler_sweep":
240
+ result = await runSchedulerSweep(args);
241
+ break;
238
242
  case "refill_sends":
239
243
  result = await executeRefillSendsCommand(args);
240
244
  break;
@@ -2,10 +2,13 @@ import { type SellableUpdateStatus } from "../update-check.js";
2
2
  export type AuthStatus = {
3
3
  ok: boolean;
4
4
  configPath: string;
5
+ configExists: boolean;
6
+ configsDir: string | null;
5
7
  activeEnvName: string | null;
6
8
  apiUrl: string | null;
7
9
  activeWorkspaceId: string | null;
8
10
  activeWorkspaceName: string | null;
11
+ tokenPresent: boolean;
9
12
  tokenPrefix: string | null;
10
13
  workspacesCount: number | null;
11
14
  checkedAt: string;
@@ -1,5 +1,6 @@
1
+ import * as fs from "fs";
1
2
  import { getApi, SellableApiError } from "../api.js";
2
- import { getConfig, getConfigPath } from "../auth.js";
3
+ import { getConfig, getConfigPath, getResolvedConfigsDir } from "../auth.js";
3
4
  import { checkForUpdates } from "../update-check.js";
4
5
  function maskToken(token) {
5
6
  if (token.length <= 10)
@@ -33,15 +34,20 @@ export const authToolDefinitions = [
33
34
  ];
34
35
  export async function getAuthStatus() {
35
36
  const configPath = getConfigPath();
37
+ const configsDir = getResolvedConfigsDir();
36
38
  const checkedAt = new Date().toISOString();
37
39
  const update = await getUpdateStatus();
40
+ let tokenPresent = false;
38
41
  const base = {
39
42
  ok: false,
40
43
  configPath,
44
+ configExists: fs.existsSync(configPath),
45
+ configsDir,
41
46
  activeEnvName: null,
42
47
  apiUrl: null,
43
48
  activeWorkspaceId: null,
44
49
  activeWorkspaceName: null,
50
+ tokenPresent: false,
45
51
  tokenPrefix: null,
46
52
  workspacesCount: null,
47
53
  _userNotice: appendUpdateNotice(null, update),
@@ -50,6 +56,7 @@ export async function getAuthStatus() {
50
56
  };
51
57
  try {
52
58
  const config = getConfig();
59
+ tokenPresent = Boolean(config.token);
53
60
  base.activeEnvName = config.activeEnvName || null;
54
61
  const api = getApi();
55
62
  const { workspaces } = await api.get("/api/v3/workspaces");
@@ -61,6 +68,7 @@ export async function getAuthStatus() {
61
68
  return {
62
69
  ...base,
63
70
  apiUrl: config.apiUrl || null,
71
+ tokenPresent,
64
72
  tokenPrefix: maskToken(config.token),
65
73
  workspacesCount: workspaces.length,
66
74
  error: {
@@ -77,6 +85,7 @@ export async function getAuthStatus() {
77
85
  return {
78
86
  ...base,
79
87
  apiUrl: config.apiUrl || null,
88
+ tokenPresent,
80
89
  tokenPrefix: maskToken(config.token),
81
90
  workspacesCount: workspaces.length,
82
91
  error: {
@@ -97,10 +106,13 @@ export async function getAuthStatus() {
97
106
  return {
98
107
  ok: true,
99
108
  configPath,
109
+ configExists: fs.existsSync(configPath),
110
+ configsDir,
100
111
  activeEnvName: envLabel,
101
112
  apiUrl: config.apiUrl || null,
102
113
  activeWorkspaceId,
103
114
  activeWorkspaceName: workspaceName,
115
+ tokenPresent,
104
116
  tokenPrefix: maskToken(config.token),
105
117
  workspacesCount: workspaces.length,
106
118
  _userNotice: appendUpdateNotice(notice, update),
@@ -124,7 +136,7 @@ export async function getAuthStatus() {
124
136
  " 3. Click it, come back here, and we'll keep going\\n\\n" +
125
137
  "What email should I use?` " +
126
138
  "2) Wait for the user to type their email in normal chat (do NOT use AskUserQuestion / request_user_input). " +
127
- "3) Call `mcp__sellable__start_cli_login({ email })` with that email. " +
139
+ "3) Call `mcp__sellable__start_cli_login({ email })` with that email in Claude Code/Codex, or `mcp_sellable_start_cli_login({ email })` in Hermes. " +
128
140
  "4) On `ok: true`, say verbatim (substituting the email exactly as typed):\\n" +
129
141
  "`Magic link sent to {email}.\\n\\n" +
130
142
  "─────────────────────────────────────────────\\n" +
@@ -135,14 +147,15 @@ export async function getAuthStatus() {
135
147
  " 3. Come back here when you're done\\n\\n" +
136
148
  "I'll be waiting right here.\\n\\n" +
137
149
  " (If your team already uses Sellable, ask an admin to invite you into their shared workspace instead — that gets you straight in.)` " +
138
- "5) Call `mcp__sellable__wait_for_cli_login({ sessionId })` using the sessionId returned by start_cli_login. " +
139
- "6) If the result is `error.type === 'tool_timeout_guard'`, IMMEDIATELY re-call wait_for_cli_login with the SAME sessionId — do not narrate, do not call start_cli_login again. Loop until you get a different result. " +
140
- "7) On `ok: true`, the user is signed in and `~/.sellable/config.json` has been written. Branch on `isReturningUser` and use `activeWorkspaceName` when present, otherwise `activeWorkspaceId`, as `{workspaceLabel}`: " +
150
+ "5) Call `mcp__sellable__wait_for_cli_login({ sessionId })` in Claude Code/Codex, or `mcp_sellable_wait_for_cli_login({ sessionId })` in Hermes, using the sessionId returned by start_cli_login. " +
151
+ "6) If the result is `error.type === 'tool_timeout_guard'`, IMMEDIATELY re-call wait_for_cli_login with the SAME sessionId — do not narrate, do not call start_cli_login again. In Hermes, re-call `mcp_sellable_wait_for_cli_login({ sessionId })`. Loop until you get a different result. " +
152
+ `7) On \`ok: true\`, the user is signed in and the resolved Sellable config file has been written at ${configPath}. Branch on \`isReturningUser\` and use \`activeWorkspaceName\` when present, otherwise \`activeWorkspaceId\`, as \`{workspaceLabel}\`: ` +
141
153
  "if true, say `You're in {workspaceLabel}.\\n\\nExcited to help you launch your LinkedIn outbound campaign. We're at setup: first I'll use your LinkedIn profile to understand the company, then I'll draft the campaign brief, help choose where to find buyers, review messages, and wait for final launch approval.\\n\\nWhat's your LinkedIn profile URL or handle?`; " +
142
154
  "if false, say `You're set up in {workspaceLabel}.\\n\\nExcited to help you launch your LinkedIn outbound campaign. We're at setup: first I'll use your LinkedIn profile to understand the company, then I'll draft the campaign brief, help choose where to find buyers, review messages, and wait for final launch approval.\\n\\nWhat's your LinkedIn profile URL or handle?`";
143
155
  if (error instanceof SellableApiError && error.isAuthError) {
144
156
  return {
145
157
  ...base,
158
+ tokenPresent,
146
159
  error: {
147
160
  type: "auth",
148
161
  status: error.status,
@@ -162,6 +175,7 @@ export async function getAuthStatus() {
162
175
  : `Fix the configuration in ${configPath}, then retry get_auth_status.`;
163
176
  return {
164
177
  ...base,
178
+ tokenPresent,
165
179
  error: {
166
180
  type: isConfigError ? "config" : "api",
167
181
  message,
@@ -307,7 +307,7 @@ export async function bootstrapCreateCampaign(input = {}) {
307
307
  ? resumeDetected
308
308
  ? `Bootstrap complete.${workspaceNotice}${modelNotice} Resume from campaign state and navigation diagnostics first; treat local draft artifacts as debug-only evidence. Then load ${createCampaignSubskill?.name ?? "create-campaign"} instructions with get_subskill_prompt({ subskillName: "${createCampaignSubskill?.name ?? "create-campaign"}" }); if the response has hasMore=true, continue with nextOffset until hasMore=false.`
309
309
  : flowVersion === "v2"
310
- ? `Bootstrap complete.${workspaceNotice}${modelNotice} Load the compact create-campaign-v2 entry prompt once with get_subskill_prompt({ subskillName: "create-campaign-v2" }); load flow/reference assets lazily only when that stage needs them. Preserve the pre-intake sequence: confirm auth/workspace status, ask only for the LinkedIn profile URL or handle, normalize handles to a full profile URL, require that profile identity before continuing, run lightweight profile/company lookup, then ask the target, offer, credibility, and prospect-source setup questions. Do not call list_senders or sender discovery during setup; sender availability belongs only to Settings after message approval. Then write the campaign brief, call create_campaign once to mint the watchable shell, surface the returned watch link once before brief approval, and hand off to lead finding without repeating the link.`
310
+ ? `Bootstrap complete.${workspaceNotice}${modelNotice} Load the compact create-campaign-v2 entry prompt once with get_subskill_prompt({ subskillName: "create-campaign-v2" }); Hermes users should start this flow with /sellable-create-campaign. Load flow/reference assets lazily only when that stage needs them. Preserve the pre-intake sequence: confirm auth/workspace status, ask only for the LinkedIn profile URL or handle, normalize handles to a full profile URL, require that profile identity before continuing, run lightweight profile/company lookup, then ask the target, offer, credibility, and prospect-source setup questions. Do not call list_senders or sender discovery during setup; sender availability belongs only to Settings after message approval. Then write the campaign brief, call create_campaign once to mint the watchable shell, surface the returned watch link once before brief approval, and hand off to lead finding without repeating the link.`
311
311
  : `Bootstrap complete.${workspaceNotice}${modelNotice} Load ${createCampaignSubskill?.name ?? "create-campaign"} instructions with get_subskill_prompt({ subskillName: "${createCampaignSubskill?.name ?? "create-campaign"}" }); if the response has hasMore=true, continue with nextOffset until hasMore=false. Follow that flow before calling create_campaign.`
312
312
  : "Bootstrap incomplete. Resolve blockingErrors and rerun bootstrap_create_campaign before provider/search/import tools.";
313
313
  // Strip prompt body from createCampaignSubskill — it's loaded via the host
@@ -9,9 +9,9 @@ declare const LEAD_SOURCE_PROVIDERS: {
9
9
  };
10
10
  type LeadSourceProvider = (typeof LEAD_SOURCE_PROVIDERS)[keyof typeof LEAD_SOURCE_PROVIDERS];
11
11
  export declare function buildWatchUrl(config: Pick<ReturnType<typeof getConfig>, "apiUrl" | "token" | "activeWorkspaceId" | "workspaceId">, path: string): string;
12
- export type CampaignBuilderWatchMode = "claude" | "codex";
12
+ export type CampaignBuilderWatchMode = "claude" | "codex" | "hermes";
13
13
  export declare function getCampaignBuilderWatchModeParam(): CampaignBuilderWatchMode;
14
- export declare function getCampaignBuilderWatchModeDriverLabel(mode?: CampaignBuilderWatchMode): "Claude Code" | "Codex";
14
+ export declare function getCampaignBuilderWatchModeDriverLabel(mode?: CampaignBuilderWatchMode): "Claude Code" | "Codex" | "Hermes";
15
15
  export declare function buildCampaignWatchHandoffMarkdown(watchUrl: string, mode?: CampaignBuilderWatchMode): string;
16
16
  export interface Campaign {
17
17
  id: string;
@@ -124,23 +124,35 @@ export function buildWatchUrl(config, path) {
124
124
  }
125
125
  return url.toString();
126
126
  }
127
+ const CAMPAIGN_BUILDER_AGENT_WATCH_MODES = new Set([
128
+ "claude",
129
+ "codex",
130
+ "hermes",
131
+ ]);
127
132
  export function getCampaignBuilderWatchModeParam() {
128
133
  const explicit = process.env.SELLABLE_WATCH_MODE_DRIVER?.trim().toLowerCase();
129
- if (explicit === "claude" || explicit === "codex")
134
+ if (CAMPAIGN_BUILDER_AGENT_WATCH_MODES.has(explicit)) {
130
135
  return explicit;
136
+ }
131
137
  return process.env.CODEX_HOME ? "codex" : "claude";
132
138
  }
133
139
  function getCampaignBuilderWatchModeFromUrl(watchUrl) {
134
140
  try {
135
141
  const mode = new URL(watchUrl).searchParams.get("mode");
136
- return mode === "claude" || mode === "codex" ? mode : null;
142
+ return CAMPAIGN_BUILDER_AGENT_WATCH_MODES.has(mode)
143
+ ? mode
144
+ : null;
137
145
  }
138
146
  catch {
139
147
  return null;
140
148
  }
141
149
  }
142
150
  export function getCampaignBuilderWatchModeDriverLabel(mode = getCampaignBuilderWatchModeParam()) {
143
- return mode === "codex" ? "Codex" : "Claude Code";
151
+ if (mode === "codex")
152
+ return "Codex";
153
+ if (mode === "hermes")
154
+ return "Hermes";
155
+ return "Claude Code";
144
156
  }
145
157
  export function buildCampaignWatchHandoffMarkdown(watchUrl, mode = getCampaignBuilderWatchModeFromUrl(watchUrl) ?? getCampaignBuilderWatchModeParam()) {
146
158
  const driverLabel = getCampaignBuilderWatchModeDriverLabel(mode);
@@ -161,7 +173,7 @@ function isValidBriefHandoffWatchUrl(watchUrl, campaignId) {
161
173
  const url = new URL(watchUrl);
162
174
  const mode = url.searchParams.get("mode");
163
175
  return (url.pathname === `/campaign-builder/${campaignId}` &&
164
- (mode === "claude" || mode === "codex") &&
176
+ CAMPAIGN_BUILDER_AGENT_WATCH_MODES.has(mode) &&
165
177
  Boolean(url.searchParams.get("workspaceId")) &&
166
178
  Boolean(url.searchParams.get("token")));
167
179
  }
@@ -173,7 +185,7 @@ function assertBriefHandoffWatchUrl(watchUrl, campaignId) {
173
185
  if (isValidBriefHandoffWatchUrl(watchUrl, campaignId))
174
186
  return;
175
187
  throw new Error("create_campaign produced an invalid watchUrl for the brief approval handoff. " +
176
- "Recover a fresh direct /campaign-builder/{campaignId}?mode={claude|codex}&workspaceId=...&token=... URL " +
188
+ "Recover a fresh direct /campaign-builder/{campaignId}?mode={claude|codex|hermes}&workspaceId=...&token=... URL " +
177
189
  "with create_campaign({ campaignId }) or get_campaign before asking for approval.");
178
190
  }
179
191
  export const campaignToolDefinitions = [
@@ -1,4 +1,4 @@
1
- export type CampaignModelHost = "claude" | "codex" | "unknown";
1
+ export type CampaignModelHost = "claude" | "codex" | "hermes" | "unknown";
2
2
  export type CampaignModelQualityInput = {
3
3
  host?: string | null;
4
4
  model?: string | null;
@@ -33,6 +33,7 @@ export type CampaignModelQualityConfig = {
33
33
  hosts: {
34
34
  claude: CampaignModelQualityHostConfig;
35
35
  codex: CampaignModelQualityHostConfig;
36
+ hermes: CampaignModelQualityHostConfig;
36
37
  };
37
38
  warningCopy: {
38
39
  ok: string;
@@ -30,6 +30,20 @@ const DEFAULT_MODEL_QUALITY_CONFIG = {
30
30
  ],
31
31
  recommendedReasoningEffort: "xhigh",
32
32
  },
33
+ hermes: {
34
+ label: "Hermes",
35
+ minimumModel: "GPT 5.5",
36
+ familyKeywords: ["gpt"],
37
+ minimumVersion: "5.5",
38
+ minimumReasoningEffort: "xhigh",
39
+ acceptedReasoningEfforts: [
40
+ "extra high",
41
+ "extra-high",
42
+ "xhigh",
43
+ "extra_high",
44
+ ],
45
+ recommendedReasoningEffort: "xhigh",
46
+ },
33
47
  },
34
48
  warningCopy: {
35
49
  ok: "Active host model metadata meets the configured campaign floor: {currentSettings}.",
@@ -41,6 +55,8 @@ const TRUSTED_METADATA_SOURCE_KEYWORDS = [
41
55
  "codex_turn_metadata",
42
56
  "claude_runtime_metadata",
43
57
  "claude_session_context",
58
+ "hermes_runtime_metadata",
59
+ "hermes_session_context",
44
60
  "active_turn_metadata",
45
61
  "user_confirmed",
46
62
  ];
@@ -49,6 +65,9 @@ const normalize = (value) => String(value ?? "")
49
65
  .toLowerCase();
50
66
  const normalizeHost = (host) => {
51
67
  const normalized = normalize(host);
68
+ if (normalized.includes("hermes")) {
69
+ return "hermes";
70
+ }
52
71
  if (normalized.includes("claude") ||
53
72
  normalized.includes("opus") ||
54
73
  normalized.includes("sonnet") ||
@@ -114,6 +133,7 @@ function findHostConfig(host, model, config) {
114
133
  ? [
115
134
  ["claude", config.hosts.claude],
116
135
  ["codex", config.hosts.codex],
136
+ ["hermes", config.hosts.hermes],
117
137
  ]
118
138
  : [[host, config.hosts[host]]];
119
139
  return candidates.find(([, hostConfig]) => modelMeetsMinimum(model, hostConfig, {
@@ -130,7 +150,7 @@ export function evaluateCampaignModelQuality(input = {}) {
130
150
  const model = input.model?.trim() || null;
131
151
  const reasoningEffort = input.reasoningEffort?.trim() || null;
132
152
  const metadataSource = input.metadataSource?.trim() || null;
133
- const recommendationHost = host === "claude" ? "claude" : "codex";
153
+ const recommendationHost = host === "claude" || host === "hermes" ? host : "codex";
134
154
  const recommendedHostConfig = config.hosts[recommendationHost];
135
155
  const minimumSummary = getCampaignModelMinimumSummary(config);
136
156
  const currentSettings = [
@@ -0,0 +1,28 @@
1
+ type RefillSendsEvergreenInput = {
2
+ workspaceId?: string;
3
+ };
4
+ export declare const refillSendsEvergreenToolDefinitions: {
5
+ name: string;
6
+ description: string;
7
+ inputSchema: {
8
+ type: string;
9
+ properties: {
10
+ workspaceId: {
11
+ type: string;
12
+ description: string;
13
+ };
14
+ };
15
+ required: string[];
16
+ additionalProperties: boolean;
17
+ };
18
+ }[];
19
+ export declare function refillSendsEvergreenCommand(input: RefillSendsEvergreenInput): {
20
+ readOnly: boolean;
21
+ workspaceId: string | null;
22
+ firstOperationalSteps: string[];
23
+ approvalContract: string;
24
+ forbiddenActions: string[];
25
+ fillWindow: string;
26
+ hostExamples: string[];
27
+ };
28
+ export {};
@@ -0,0 +1,47 @@
1
+ export const refillSendsEvergreenToolDefinitions = [
2
+ {
3
+ name: "refill_sends_evergreen",
4
+ description: "Read-only Phase 85 evergreen refill command contract. It performs no mutations and only tells the operator to call get_evergreen_refill_plan for a dry-run packet and journal.",
5
+ inputSchema: {
6
+ type: "object",
7
+ properties: {
8
+ workspaceId: {
9
+ type: "string",
10
+ description: "Explicit request-scoped workspace id.",
11
+ },
12
+ },
13
+ required: ["workspaceId"],
14
+ additionalProperties: false,
15
+ },
16
+ },
17
+ ];
18
+ export function refillSendsEvergreenCommand(input) {
19
+ return {
20
+ readOnly: true,
21
+ workspaceId: input.workspaceId ?? null,
22
+ firstOperationalSteps: [
23
+ "Call get_evergreen_refill_plan with the explicit workspaceId.",
24
+ "Read the returned packet, globalActionQueue, per-sender plans, and itinerary before taking any action.",
25
+ "Review the dry-run journal file path returned by get_evergreen_refill_plan.",
26
+ "Phase 85 is PLAN-ONLY; execution arrives in Phase 86.",
27
+ ],
28
+ approvalContract: "Nothing is approved or executable in Phase 85. The evergreen command is read-only; Phase 86 introduces execution approval.",
29
+ forbiddenActions: [
30
+ "Do not schedule sends.",
31
+ "Do not send messages.",
32
+ "Do not approve messages.",
33
+ "Do not prepare messages.",
34
+ "Do not start or launch campaigns.",
35
+ "Do not create campaigns.",
36
+ "Do not switch providers or source families.",
37
+ "Do not lower paid InMail thresholds.",
38
+ "Do not refresh paid InMail credits.",
39
+ "Do not write scheduler fields.",
40
+ ],
41
+ fillWindow: "Use only the target window and caps returned by get_evergreen_refill_plan.",
42
+ hostExamples: [
43
+ "refill_sends_evergreen({ workspaceId })",
44
+ "get_evergreen_refill_plan({ workspaceId })",
45
+ ],
46
+ };
47
+ }
@@ -5,6 +5,7 @@ import { runRefillV2Loop } from "../refill-run-loop.js";
5
5
  import { getPrepareCampaignMessagesStatus } from "./campaign-message-preparation.js";
6
6
  import { getRefillPlanV2 } from "./evergreen-refill-plan.js";
7
7
  import { executeOneYoloPrimitive, executeStartCampaignPrimitive, prepareRowSelectorValue, refillPrepareRequestHash, refreshPaidInmailCreditsWithRetry, } from "./refill-executors.js";
8
+ import { runSchedulerSweep } from "./scheduler-run.js";
8
9
  const FORBIDDEN_ACTIONS = [
9
10
  "Do not schedule sends.",
10
11
  "Do not send messages.",
@@ -152,6 +153,7 @@ export async function refillSendsV2Command(input) {
152
153
  localState: {
153
154
  writeRefillWorkspaceState,
154
155
  },
156
+ requestSchedulerRun: (workspaceId) => runSchedulerSweep({ workspaceId, action: "run" }),
155
157
  });
156
158
  return {
157
159
  ...(await maybeAddLostFenceGuidance(result, { ...input, workspaceId })),
@@ -7370,6 +7370,25 @@ export declare const allTools: ({
7370
7370
  };
7371
7371
  required: string[];
7372
7372
  };
7373
+ } | {
7374
+ name: string;
7375
+ description: string;
7376
+ inputSchema: {
7377
+ type: string;
7378
+ properties: {
7379
+ workspaceId: {
7380
+ type: string;
7381
+ description: string;
7382
+ };
7383
+ action: {
7384
+ type: string;
7385
+ enum: string[];
7386
+ description: string;
7387
+ };
7388
+ };
7389
+ required: string[];
7390
+ additionalProperties: boolean;
7391
+ };
7373
7392
  } | {
7374
7393
  name: string;
7375
7394
  description: string;
@@ -40,6 +40,7 @@ import { refillTargetPlanToolDefinitions } from "./refill-target-plan.js";
40
40
  import { rowToolDefinitions } from "./rows.js";
41
41
  import { rubricToolDefinitions } from "./rubrics.js";
42
42
  import { schedulerFillCapacityToolDefinitions } from "./scheduler-fill-capacity.js";
43
+ import { schedulerRunToolDefinitions } from "./scheduler-run.js";
43
44
  import { senderRoutingToolDefinitions } from "./sender-routing.js";
44
45
  import { senderToolDefinitions } from "./senders.js";
45
46
  import { sequencerToolDefinitions } from "./sequencer.js";
@@ -57,6 +58,7 @@ export const allTools = [
57
58
  ...refillPlanV2ToolDefinitions,
58
59
  ...refillTargetPlanToolDefinitions,
59
60
  ...schedulerFillCapacityToolDefinitions,
61
+ ...schedulerRunToolDefinitions,
60
62
  ...refillSendsToolDefinitions,
61
63
  ...refillSendsV2ToolDefinitions,
62
64
  ...setupEvergreenCampaignsToolDefinitions,
@@ -0,0 +1,27 @@
1
+ type SchedulerRunAction = "run" | "status";
2
+ type RunSchedulerSweepInput = {
3
+ workspaceId: string;
4
+ action?: SchedulerRunAction;
5
+ };
6
+ export declare const schedulerRunToolDefinitions: {
7
+ name: string;
8
+ description: string;
9
+ inputSchema: {
10
+ type: string;
11
+ properties: {
12
+ workspaceId: {
13
+ type: string;
14
+ description: string;
15
+ };
16
+ action: {
17
+ type: string;
18
+ enum: string[];
19
+ description: string;
20
+ };
21
+ };
22
+ required: string[];
23
+ additionalProperties: boolean;
24
+ };
25
+ }[];
26
+ export declare function runSchedulerSweep(input: RunSchedulerSweepInput): Promise<unknown>;
27
+ export {};
@@ -0,0 +1,45 @@
1
+ import { getApi } from "../api.js";
2
+ import { normalizeExplicitWorkspaceId, workspaceRequestOptions, } from "./workspace-context.js";
3
+ async function postSchedulerRun(body, workspaceId) {
4
+ const api = getApi();
5
+ const requestOptions = workspaceRequestOptions(workspaceId);
6
+ return requestOptions
7
+ ? api.post("/api/v3/mcp/scheduler-run", body, requestOptions)
8
+ : api.post("/api/v3/mcp/scheduler-run", body);
9
+ }
10
+ export const schedulerRunToolDefinitions = [
11
+ {
12
+ name: "run_scheduler_sweep",
13
+ description: 'Trigger the product scheduler placement sweep for one explicit workspace now, or read the last on-demand scheduler run status with action "status". A run returns a synchronous envelope with status ran, attached, backoff, window_closed_noop, or failed; retryAfterMs for backoff replays; and a receipt with cellsConsidered, cellsScheduled, cellsSkipped, cellsDeferred, deterministic skipReasons, and tablesFilteredForNoCapacity. It places cells within existing scheduler gates only: it can move placement earlier, but it cannot bypass sending windows, daily limits, cooldowns, sender gates, billing, or credit thresholds, and it never sends messages directly. Repeated calls inside the backoff window replay the last receipt verbatim. Status is read-only and scoped to the last on-demand run only; cron sweeps are not recorded here.',
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ workspaceId: {
18
+ type: "string",
19
+ description: "Explicit request-scoped workspace id for scheduled/yolo refill automation. Pass this instead of switching the shared active workspace.",
20
+ },
21
+ action: {
22
+ type: "string",
23
+ enum: ["run", "status"],
24
+ description: 'Use "run" to trigger a placement sweep now. Use "status" for a read-only view of the last on-demand run. Defaults to "run".',
25
+ },
26
+ },
27
+ required: ["workspaceId"],
28
+ additionalProperties: false,
29
+ },
30
+ },
31
+ ];
32
+ export async function runSchedulerSweep(input) {
33
+ const workspaceId = normalizeExplicitWorkspaceId(input.workspaceId);
34
+ if (!workspaceId) {
35
+ throw new Error("workspaceId is required for run_scheduler_sweep.");
36
+ }
37
+ const action = input.action ?? "run";
38
+ if (action !== "run" && action !== "status") {
39
+ throw new Error('action must be "run" or "status" for run_scheduler_sweep.');
40
+ }
41
+ return postSchedulerRun({
42
+ workspaceId,
43
+ action,
44
+ }, workspaceId);
45
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.531",
3
+ "version": "0.1.533",
4
4
  "type": "module",
5
- "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
5
+ "description": "Sellable MCP server for Claude Code, Codex, and Hermes campaign workflows",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "mcp": "dist/index.js",
@@ -20,6 +20,7 @@
20
20
  "mcp",
21
21
  "claude-code",
22
22
  "codex",
23
+ "hermes",
23
24
  "sellable",
24
25
  "linkedin",
25
26
  "outreach"
@@ -6,6 +6,7 @@ allowed-tools:
6
6
  - mcp__sellable__refill_sends
7
7
  - mcp__sellable__get_refill_target_plan
8
8
  - mcp__sellable__get_scheduler_fill_capacity
9
+ - mcp__sellable__run_scheduler_sweep
9
10
  - mcp__sellable__refresh_paid_inmail_credits
10
11
  - mcp__sellable__get_subskill_asset
11
12
  - mcp__sellable__get_auth_status
@@ -114,10 +115,11 @@ or install-time workspace mapping. Pass `workspaceId` on every scheduled or
114
115
  `--yolo` refill tool call, including setup/read calls such as
115
116
  `refill_sends`, `get_refill_target_plan`, `list_senders`,
116
117
  `get_sender_routing`, `resolve_campaign_fill_route`,
117
- `get_campaign_refill_state`, `get_scheduler_fill_capacity`, and any later
118
- refill mutation covered by the packet. Missing `workspaceId` in scheduled or
119
- `--yolo` mode is a blocker; stop with `WORKSPACE_REQUIRED` instead of running
120
- against an implicit or guessed workspace.
118
+ `get_campaign_refill_state`, `get_scheduler_fill_capacity`,
119
+ `run_scheduler_sweep`, and any later refill mutation covered by the packet.
120
+ Missing `workspaceId` in scheduled or `--yolo` mode is a blocker; stop with
121
+ `WORKSPACE_REQUIRED` instead of running against an implicit or guessed
122
+ workspace.
121
123
 
122
124
  Do not solve scheduled or `--yolo` workspace uncertainty by changing the shared
123
125
  active workspace. Manual interactive workspace switching remains a separate
@@ -269,6 +271,10 @@ need raw proof, call the read-only `get_scheduler_fill_capacity` query for the
269
271
  same sender/action/date; it tells the MCP how many cells the product scheduler
270
272
  will try to place and does not import, approve, schedule, refresh credits, or
271
273
  mutate.
274
+ When the refill loop has ready rows and needs scheduler pickup now, use
275
+ `run_scheduler_sweep` with the same explicit `workspaceId`; it can place cells
276
+ within existing scheduler gates and returns the receipt, but it never sends or
277
+ bypasses limits.
272
278
  If the target plan is complete by projected coverage, report that the selected
273
279
  target is already filled and no-op without asking for approval. If the ready
274
280
  buffer covers the projected gap, paid InMail credit facts are fresh for every
@@ -6,6 +6,7 @@ allowed-tools:
6
6
  - mcp__sellable__get_subskill_asset
7
7
  - mcp__sellable__get_refill_target_plan
8
8
  - mcp__sellable__get_scheduler_fill_capacity
9
+ - mcp__sellable__run_scheduler_sweep
9
10
  - mcp__sellable__refresh_paid_inmail_credits
10
11
  - mcp__sellable__list_senders
11
12
  - mcp__sellable__get_sender_routing
@@ -67,11 +68,12 @@ request-scoped `workspaceId`. Pass that same `workspaceId` on every refill tool
67
68
  call in this workflow: `get_refill_target_plan`, `list_senders`,
68
69
  `get_sender_routing`, `resolve_campaign_fill_route`,
69
70
  `get_campaign_refill_state`, `get_scheduler_fill_capacity`,
70
- `refresh_paid_inmail_credits`, source import/readiness calls, preparation calls,
71
- approval calls, and campaign start calls. Missing `workspaceId` in scheduled or
72
- `--yolo` mode is a blocker; return or report `WORKSPACE_REQUIRED` instead of
73
- falling back to shared config state. Manual interactive workspace switching is
74
- diagnostic setup only and is not an automation control path.
71
+ `run_scheduler_sweep`, `refresh_paid_inmail_credits`, source import/readiness
72
+ calls, preparation calls, approval calls, and campaign start calls. Missing
73
+ `workspaceId` in scheduled or `--yolo` mode is a blocker; return or report
74
+ `WORKSPACE_REQUIRED` instead of falling back to shared config state. Manual
75
+ interactive workspace switching is diagnostic setup only and is not an
76
+ automation control path.
75
77
 
76
78
  Goal-mode continuation: a skill cannot create or invoke `/goal` by itself. When
77
79
  this workflow is already running inside an active Codex goal, keep that goal
@@ -186,6 +188,10 @@ files or memory.
186
188
  how many cells the product scheduler will try to place for that sender; it
187
189
  does not create rows, import, approve, schedule, refresh paid-InMail credits,
188
190
  or mutate thresholds.
191
+ When ready rows exist and the wait is for scheduler pickup, call
192
+ `run_scheduler_sweep` with the same explicit `workspaceId` to request the
193
+ product scheduler placement pass now and read its receipt. This may place
194
+ cells within existing gates, never sends messages, and never bypasses limits.
189
195
  If `status:"complete"`, report the target, selected dates, sent count,
190
196
  scheduled count, projected count, campaign ids, and no-op proof without
191
197
  asking for approval or mutating.
@@ -0,0 +1,9 @@
1
+ {
2
+ "parallelMode": "wide",
3
+ "agentCount": 6,
4
+ "maxToolCallsPerAgent": 2,
5
+ "senderMaxAgents": 2,
6
+ "senderMaxToolCallsPerAgent": 3,
7
+ "progressMode": true,
8
+ "debugMode": true
9
+ }