social-autoposter 1.6.71 → 1.6.73

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/mcp/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
  // social-autoposter MCP server (X/Twitter rail).
3
3
  //
4
4
  // Core tools:
5
- // draft_cycle - scan + draft, return all drafts as a numbered table for the
6
- // user to review in chat (posts nothing).
7
- // post_drafts - post the drafts the user chose by number from a batch.
5
+ // scan_candidates - scan + score X threads, return the top candidates (no draft, no post).
6
+ // submit_drafts - take the replies you drafted and queue them for review (posts nothing).
7
+ // post_drafts - post the drafts the user chose by number from a batch.
8
8
  // get_stats - read-only post + engagement stats.
9
9
  //
10
10
  // THIN wrapper. The pipeline brain (scan, score, drafting prompts, posting)
@@ -30,6 +30,11 @@ import http from "node:http";
30
30
  // (vite-plugin-singlefile) into dist/panel.html alongside this compiled file.
31
31
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
32
32
  const PANEL_URI = "ui://social-autoposter/panel.html";
33
+ // Stable id for the accumulating draft review queue. submit_drafts appends each
34
+ // run's drafts here (dedup by tweet URL) so the menu-bar cards PILE UP across a
35
+ // continuous autopilot instead of each run overwriting the last; post_drafts posts
36
+ // the approved subset and marks them posted (filtered out of the cards thereafter).
37
+ const REVIEW_QUEUE_ID = "review-queue";
33
38
  const TWITTER_AUTOPILOT_LABEL = "com.m13v.social-twitter-cycle";
34
39
  const TWITTER_AUTOPILOT_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${TWITTER_AUTOPILOT_LABEL}.plist`);
35
40
  // Daily self-updater. Enabled alongside autopilot so a hands-free (headless)
@@ -152,7 +157,9 @@ const server = new McpServer({
152
157
  "ONBOARDING IS A TERMINAL GOAL. When the user asks to set up, install, configure, or onboard " +
153
158
  "social-autoposter, keep taking the next safe action until the owned runtime is ready, a " +
154
159
  "project is fully configured with seeded search topics, X is connected with its real handle, " +
155
- "and `draft_cycle` has verified the pipeline without posting. Do not ask whether to inspect " +
160
+ "the draft path (`scan_candidates` -> draft -> `submit_drafts`) has verified the pipeline " +
161
+ "without posting, AND the recurring autopilot scheduled task has been created and run once " +
162
+ "(see SCHEDULE THE AUTOPILOT below). Do not ask whether to inspect " +
156
163
  "status, install or repair owned dependencies, choose an auto-detected browser profile, connect " +
157
164
  "X, scan the profile, research the product website, save conservative inferred fields, seed " +
158
165
  "topics, retry a recoverable failure, or run draft-only verification. Do those things. The " +
@@ -160,27 +167,42 @@ const server = new McpServer({
160
167
  "twitter.com session cookies into the managed browser: warn that macOS keychain prompts may " +
161
168
  "appear, then proceed without a separate yes/no turn. Ask at most one bundled question, only " +
162
169
  "when no product can be identified from config, context, the X profile, or public research, " +
163
- "or pause for an unavoidable interactive login or OS prompt. Never post drafts or enable " +
164
- "autopilot during setup unless the user explicitly requested that. When setup reaches done " +
165
- "runtime ready, a project configured with seeded topics, X connected, and `draft_cycle` " +
166
- "verified call the `dashboard` tool so the user sees the finished setup rendered, then give " +
170
+ "or pause for an unavoidable interactive login or OS prompt. Never auto-POST during setup, but " +
171
+ "DO schedule the draft-only autopilot as the final step (it only queues drafts for approval, it " +
172
+ "never posts on its own). When setup reaches done (" +
173
+ "runtime ready, a project configured with seeded topics, X connected, the draft path verified, " +
174
+ "and the autopilot scheduled task created and run once), call the `dashboard` tool so the user " +
175
+ "sees the finished setup rendered, then give " +
167
176
  "the completion summary.\n\n" +
177
+ "SCHEDULE THE AUTOPILOT (final onboarding step). Once the draft path is verified, start the " +
178
+ "autopilot by calling the HOST tool `create_scheduled_task` with: taskId " +
179
+ "'social-autoposter-autopilot'; cronExpression '* * * * *' (continuous, like the pipeline; the " +
180
+ "host runs one at a time and skips overlapping fires, so cycles run back-to-back); and a " +
181
+ "self-contained prompt telling the run to call scan_candidates, draft ONE on-brand reply " +
182
+ "(<=250 chars, same language as the thread, adds real value) for each candidate worth engaging, " +
183
+ "then call submit_drafts with those drafts, and NOT post (drafts pile up in the menu-bar " +
184
+ "approval cards for the user to approve). Then run ONE cycle yourself now (scan_candidates -> " +
185
+ "draft -> submit_drafts) to seed the cards and pre-approve the tools, so unattended fires do not " +
186
+ "stall on a permission prompt. If the host exposes no create_scheduled_task tool, tell the user " +
187
+ "the autopilot could not be scheduled here and to run a draft cycle manually instead.\n\n" +
168
188
  "BE PROACTIVE ABOUT MARKETING MOMENTS. Whenever the user ships, finishes, merges, or releases " +
169
189
  "something worth talking about in this session (a new feature, a launch, a long-awaited fix, a " +
170
- "milestone), don't wait to be asked: offer to tell the world by running `draft_cycle` to post " +
171
- "about it. A feature nobody hears about is wasted distribution. Use judgment, suggest it for " +
190
+ "milestone), don't wait to be asked: offer to tell the world by running a draft cycle " +
191
+ "(`scan_candidates` -> draft the replies -> `submit_drafts`) about it. A feature nobody hears " +
192
+ "about is wasted distribution. Use judgment, suggest it for " +
172
193
  "genuinely shareable moments, not routine refactors, chores, or internal cleanup.\n\n" +
173
194
  "STAY CURRENT. At the start of a session, and again right after you help the user ship/merge/" +
174
195
  "release, call the `runtime` tool (action:'version'). If `update_available` is true, tell the " +
175
196
  "user and offer to run `runtime` with action:'update'. The `project_config` tool's status also " +
176
197
  "surfaces `update_available` and an `update_hint`.\n\n" +
177
- "TYPICAL FLOW: `project_config` (configure OR edit the project, and connect X) -> `draft_cycle` " +
178
- "(scan + review a batch; the user approves / edits / skips every draft in a single form) -> " +
198
+ "TYPICAL FLOW: `project_config` (configure OR edit the project, and connect X) -> `scan_candidates` " +
199
+ "(get the top threads to reply to) -> draft the replies yourself -> `submit_drafts` (queue them " +
200
+ "for review; nothing posts until the user approves) -> `post_drafts` (post the approved ones) -> " +
179
201
  "`get_stats` (see performance). Run `project_config` first; the other tools refuse until a " +
180
202
  "project is fully configured. To change anything about a project later, call `project_config` " +
181
203
  "again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
182
204
  "RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
183
- "(`draft_cycle`, `post_drafts`, `get_stats`), end your turn by " +
205
+ "(`scan_candidates`, `submit_drafts`, `post_drafts`, `get_stats`), end your turn by " +
184
206
  "calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
185
207
  "`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.",
186
208
  });
@@ -254,28 +276,28 @@ function blockedReasonMessage(reason) {
254
276
  "couldn't run. (It DID find and rank threads, it just couldn't draft replies.) This " +
255
277
  "CLI uses its own login, separate from Claude Desktop. To fix it, open a terminal and run:\n\n" +
256
278
  " claude\n\n" +
257
- "then `/login` inside it (or run `claude setup-token`). Once it's logged in, run draft_cycle again.");
279
+ "then `/login` inside it (or run `claude setup-token`). Once it's logged in, run scan_candidates again.");
258
280
  case "monthly_limit":
259
281
  case "daily_limit":
260
282
  case "rate_limit_5h":
261
283
  return (`The drafting step hit an Anthropic usage limit (${reason}), so no replies were drafted. ` +
262
- "Wait for the limit to reset, then run draft_cycle again.");
284
+ "Wait for the limit to reset, then run scan_candidates again.");
263
285
  case "no_search_topics":
264
286
  return ("This project has no search topics yet, so there was nothing to scan. Topics live in the " +
265
287
  "DB (project_search_topics) and are seeded from your project's `search_topics` when you " +
266
288
  "configure it. Re-run the `project_config` tool for this project with a `search_topics` list " +
267
289
  "(comma-separated keywords/phrases your buyers tweet about); it seeds them automatically, then " +
268
- "run draft_cycle again.");
290
+ "run scan_candidates again.");
269
291
  case "topics_api_unreachable":
270
292
  return ("Couldn't reach the search-topics service to load this project's topics, so the cycle stopped " +
271
- "before scanning. This is usually a transient backend/network issue. Try draft_cycle again in a " +
293
+ "before scanning. This is usually a transient backend/network issue. Try scan_candidates again in a " +
272
294
  "moment; if it persists, check connectivity to the autoposter backend.");
273
295
  case "credit_balance":
274
296
  return ("The drafting step failed because the Anthropic account is out of credits. " +
275
- "Add credits, then run draft_cycle again.");
297
+ "Add credits, then run scan_candidates again.");
276
298
  default:
277
299
  return (`The drafting step failed (${reason}) and produced no drafts. ` +
278
- "Check skill/logs/twitter-cycle-*.log on this machine for details, then run draft_cycle again.");
300
+ "Check skill/logs/twitter-cycle-*.log on this machine for details, then run scan_candidates again.");
279
301
  }
280
302
  }
281
303
  // Turn a raw run-twitter-cycle.sh stdout line into a short, user-facing
@@ -465,8 +487,11 @@ async function produceDrafts(project, onProgress) {
465
487
  function renderDraftsTable(plan) {
466
488
  const candidates = plan.candidates || [];
467
489
  return candidates
468
- .map((c, i) => {
469
- const n = i + 1;
490
+ // Number by FULL-array index (matches post_drafts + the menu bar), then drop
491
+ // already-posted entries so the cards only show what's still pending.
492
+ .map((c, i) => ({ c, n: i + 1 }))
493
+ .filter((e) => e.c.posted !== true)
494
+ .map(({ c, n }) => {
470
495
  const author = c.thread_author ? `@${c.thread_author}` : "(unknown thread)";
471
496
  const style = c.engagement_style ?? "?";
472
497
  const reply = c.reply_text ?? "(empty)";
@@ -647,7 +672,7 @@ tool("project_config", {
647
672
  "Call with status:true (or no name) to list every configured project, its remaining fields, AND " +
648
673
  "whether X is connected. Use config, conversation context, profile_scan, and website research " +
649
674
  "before asking for fields. Ask only if no product can be identified or an interactive login is " +
650
- "unavoidable. The draft_cycle and get_stats tools refuse to run until a project is " +
675
+ "unavoidable. The scan_candidates and get_stats tools refuse to run until a project is " +
651
676
  "fully set up.",
652
677
  inputSchema: {
653
678
  status: z.boolean().optional(),
@@ -811,7 +836,7 @@ tool("project_config", {
811
836
  next_step: r.connected
812
837
  ? "X is connected. Next, run project_config action:'profile_scan' to read this account's bio + recent " +
813
838
  "posts + replies and draft the project's voice/icp/search_topics in the user's own register " +
814
- "before saving. Then run draft_cycle once the project is fully set up."
839
+ "before saving. Then run a draft cycle (scan_candidates -> draft -> submit_drafts) once the project is fully set up."
815
840
  : r.state === "needs_login"
816
841
  ? "The user must finish signing in to x.com in the Chrome window that just opened. Tell " +
817
842
  "them that single required action, then call project_config action:'connect_x', confirm:true again."
@@ -918,7 +943,7 @@ tool("project_config", {
918
943
  (x.connected ? "" : " X is not connected yet either — detect_x_sources, warn about keychain prompts, then run connect_x with confirm:true without a separate permission turn.")
919
944
  : projects.every((p) => p.ready)
920
945
  ? (x.connected
921
- ? "All configured projects are ready and X is connected. Run draft_cycle now to verify end to end without posting. After it verifies, call the `dashboard` tool so the user sees the finished setup."
946
+ ? "All configured projects are ready and X is connected. Run scan_candidates, draft a reply or two, and submit_drafts now to verify end to end without posting. Then SCHEDULE THE AUTOPILOT: call create_scheduled_task (taskId 'social-autoposter-autopilot', cron '* * * * *', prompt = scan_candidates -> draft -> submit_drafts, draft-only) and run one cycle now. Then call the `dashboard` tool so the user sees the finished setup."
922
947
  : "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
923
948
  "x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
924
949
  : "Some projects are missing required fields (see each project's missing_required). Derive them from config, context, profile_scan, and website research, then call project_config again. Ask only if a required field is genuinely unknowable." +
@@ -960,13 +985,13 @@ tool("project_config", {
960
985
  topic_count: topicCount,
961
986
  });
962
987
  seedNote = m
963
- ? ` Seeded ${m[1]} search topic(s) into the DB (new: ${m[2]}, updated: ${m[3]}), so draft_cycle has a topic universe to work with.`
964
- : " Seeded search topics into the DB so draft_cycle has a topic universe to work with.";
988
+ ? ` Seeded ${m[1]} search topic(s) into the DB (new: ${m[2]}, updated: ${m[3]}), so scan_candidates has a topic universe to work with.`
989
+ : " Seeded search topics into the DB so scan_candidates has a topic universe to work with.";
965
990
  }
966
991
  else {
967
992
  const tail = (seed.stderr || seed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
968
993
  blockOnboardingMilestone("topics_seeded", "topic_seed_failed", tail, { exit_code: seed.code });
969
- seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}. draft_cycle will tell you clearly if topics are missing.)`;
994
+ seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}. scan_candidates will tell you clearly if topics are missing.)`;
970
995
  }
971
996
  // Cold-start QUERY supply: fan the seeded topics out into >=30 real X
972
997
  // search queries (project_search_queries) so the deterministic Phase 1
@@ -1030,8 +1055,9 @@ tool("project_config", {
1030
1055
  note: (result.ready
1031
1056
  ? `Project '${result.project}' is fully configured.${seedNote} Next: if X is not connected, ` +
1032
1057
  `detect sources, warn about keychain prompts, and call project_config with ` +
1033
- `action:'connect_x', confirm:true immediately. Once X is connected, run draft_cycle to ` +
1034
- `verify without posting. Do not enable autopilot unless explicitly requested.`
1058
+ `action:'connect_x', confirm:true immediately. Once X is connected, run scan_candidates -> submit_drafts ` +
1059
+ `to verify without posting, then schedule the draft-only autopilot (create_scheduled_task, cron '* * * * *', ` +
1060
+ `prompt = scan_candidates -> draft -> submit_drafts) and run one cycle now.`
1035
1061
  : `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
1036
1062
  `First derive those fields from existing context, profile_scan, and website research, then ` +
1037
1063
  `call project_config again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`) +
@@ -1042,145 +1068,29 @@ tool("project_config", {
1042
1068
  return textContent(`Setup failed: ${e.message}`);
1043
1069
  }
1044
1070
  });
1045
- // ---- draft_cycle: scan + draft, then hand the batch to the user for review.
1046
- // Posting is a SEPARATE step (post_drafts) so the user picks by number in chat.
1047
- // This host doesn't support elicitation, so there is no in-tool form: the model
1048
- // relays the table and asks which to post / edit, then calls post_drafts.
1049
- tool("draft_cycle", {
1050
- title: "Draft an X reply cycle",
1051
- description: "Scan X and draft replies on this machine, then return ALL drafts as a numbered table " +
1052
- "for review. This tool POSTS NOTHING. Show the table to the user and ask which numbers " +
1053
- "to post and which to rewrite, then call `post_drafts` with their decision and the " +
1054
- "returned batch_id. The table MUST show, per draft: the thread being replied to " +
1055
- "(thread_text), the draft reply, and the link target (link_url) when present; never " +
1056
- "drop those columns. Flow: discover -> draft -> review in chat -> post_drafts. " +
1057
- "After returning the table, call the `dashboard` tool so the user sees the updated state.",
1058
- inputSchema: {
1059
- project: z
1060
- .string()
1061
- .optional()
1062
- .describe("Which configured project to draft for. Optional when only one project is set up; required when several are."),
1063
- },
1064
- }, async ({ project }, extra) => {
1065
- const r = resolveProject(project);
1066
- if (!r.ok)
1067
- return textContent(r.message);
1068
- const proj = r.project;
1069
- recordOnboardingAttempt("draft_verified");
1070
- // Live progress so the chat doesn't sit on a frozen spinner for minutes.
1071
- // Two channels, both best-effort (a sink failure must never fail the cycle):
1072
- // 1. notifications/message — a log line; the host records it (and some
1073
- // clients show it in a log view). Works with no client opt-in.
1074
- // 2. notifications/progress — drives the status text under the running
1075
- // tool. Only valid when the client supplied a progressToken on the
1076
- // request, so it's guarded on that.
1077
- const progressToken = extra?._meta?.progressToken;
1078
- const sendProgress = async (message, step) => {
1079
- try {
1080
- await extra.sendNotification({
1081
- method: "notifications/message",
1082
- params: { level: "info", logger: "draft_cycle", data: message },
1083
- });
1084
- }
1085
- catch {
1086
- /* ignore */
1087
- }
1088
- if (progressToken !== undefined) {
1089
- try {
1090
- await extra.sendNotification({
1091
- method: "notifications/progress",
1092
- params: { progressToken, progress: step, message },
1093
- });
1094
- }
1095
- catch {
1096
- /* ignore */
1097
- }
1098
- }
1099
- };
1100
- const drafted = await produceDrafts(proj, (message, step) => {
1101
- void sendProgress(message, step);
1102
- });
1103
- if (drafted.blocked || !drafted.batchId) {
1104
- blockOnboardingMilestone("draft_verified", "draft_cycle_blocked", drafted.blocked ?? "No drafts produced.", { outcome: "blocked" });
1105
- return textContent(drafted.blocked ?? "No drafts produced.");
1106
- }
1107
- const plan = readPlan(drafted.batchId);
1108
- if (!plan || !(plan.candidates && plan.candidates.length)) {
1109
- blockOnboardingMilestone("draft_verified", "draft_batch_empty", `No drafts in batch ${drafted.batchId}.`, { outcome: "empty_batch", draft_count: 0 });
1110
- return textContent(`No drafts in batch ${drafted.batchId}.`);
1111
- }
1112
- const count = plan.candidates.length;
1113
- completeOnboardingMilestone("draft_verified", {
1114
- outcome: "review_batch",
1115
- draft_count: count,
1116
- });
1117
- // Fire the menu-bar pop-up review (default). The chat-table review below is
1118
- // unchanged; both surfaces can approve, de-duped by the plan `posted` flag.
1119
- writeReviewRequest({
1120
- batch_id: drafted.batchId,
1121
- project: proj,
1122
- count,
1123
- plan_path: planPath(drafted.batchId),
1124
- created_at: new Date().toISOString(),
1125
- });
1126
- const table = renderDraftsTable(plan);
1127
- const message = `Drafted ${count} ${count === 1 ? "reply" : "replies"} for "${proj}" ` +
1128
- `(batch ${drafted.batchId}). NOTHING has been posted yet.\n\n` +
1129
- `${table}\n\n` +
1130
- `Show this list to the user and ask which to post and which to edit. When you render ` +
1131
- `the table, ALWAYS include, for every draft: the thread it replies to (in reply to / ` +
1132
- `thread_text), the draft text, and the link target (link_url) if present. Do NOT drop ` +
1133
- `the thread or link columns. Note the literal short link is appended at post time and ` +
1134
- `an A/B gate may omit it, so present link_url as the target, not a guaranteed final URL. ` +
1135
- `They can reply however is natural, e.g. "post 1, 3 and 5", "edit 2: <new wording>", ` +
1136
- `"post all", or "skip all". Editing a draft also posts it. Then call the post_drafts ` +
1137
- `tool with batch_id "${drafted.batchId}" and their decision (post: [numbers], ` +
1138
- `edits: [{n, text}], or post_all: true). Do not post anything the user didn't ask for.`;
1139
- return {
1140
- content: [{ type: "text", text: message }],
1141
- structuredContent: {
1142
- batch_id: drafted.batchId,
1143
- drafted: count,
1144
- status: "awaiting_decision",
1145
- onboarding: onboardingSnapshot(),
1146
- // Include the actual draft text here, not just a count. Some hosts
1147
- // (e.g. Claude Desktop) surface ONLY structuredContent to the model and
1148
- // drop the human-readable `content` table — which left the agent saying
1149
- // "drafted: 2" with no way to show the drafts. Carrying the drafts in
1150
- // structuredContent makes them available regardless of host behavior.
1151
- drafts: (plan.candidates || []).map((c, i) => ({
1152
- n: i + 1,
1153
- author: c.thread_author,
1154
- tweet_url: c.candidate_url,
1155
- // The original tweet being replied to — reviewer context. Hosts that
1156
- // surface ONLY structuredContent (Claude Desktop, Fazm Code tab) need
1157
- // this here or the relayed table loses the thread it's responding to.
1158
- thread_text: c.thread_text,
1159
- reply_text: c.reply_text,
1160
- // Target link only. The literal /r/<code> short link is minted at post
1161
- // time and an A/B gate may omit it; do not pre-mint here.
1162
- link_url: c.link_url,
1163
- style: c.engagement_style,
1164
- language: c.language,
1165
- })),
1166
- },
1167
- };
1168
- });
1071
+ // ---- draft_cycle: DEPRECATED 2026-06-20 (registration removed) -------------
1072
+ // Replaced by the scan_candidates -> (agent drafts) -> submit_drafts flow, so the
1073
+ // AI drafting now runs in the calling session (on the user's plan) instead of a
1074
+ // spawned `claude -p`. post_drafts (below) still posts the approved subset, and
1075
+ // submit_drafts completes the onboarding "draft_verified" milestone that this
1076
+ // tool used to. The underlying pipeline (run-twitter-cycle.sh) and the
1077
+ // produceDrafts() helper are kept as the source those tools reuse; only the
1078
+ // draft_cycle tool registration was removed here.
1169
1079
  // ---- post_drafts: post the user's chosen drafts from a batch ---------------
1170
1080
  // Second half of the manual loop. The user reviewed the table from draft_cycle
1171
1081
  // and said which numbers to post / edit; this posts exactly those. Editing a
1172
1082
  // draft implies posting it. Indices are 1-based, matching the table.
1173
1083
  tool("post_drafts", {
1174
1084
  title: "Post chosen drafts",
1175
- description: "Post the drafts the user approved from a draft_cycle batch. Pass the batch_id from " +
1176
- "draft_cycle and the user's decision by NUMBER (1-based, matching the table): `post` is " +
1085
+ description: "Post the drafts the user approved from a submit_drafts batch. Pass the batch_id from " +
1086
+ "submit_drafts and the user's decision by NUMBER (1-based, matching the table): `post` is " +
1177
1087
  "the list of draft numbers to post as drafted; `edits` rewrites a draft's text before " +
1178
1088
  "posting it (editing implies posting); `post_all` posts every draft. Only the chosen " +
1179
1089
  "drafts post; anything not listed is left unposted. Call this ONLY after the user has " +
1180
1090
  "told you which drafts they want. After posting, call the `dashboard` tool so the user " +
1181
1091
  "sees the updated state.",
1182
1092
  inputSchema: {
1183
- batch_id: z.string().describe("The batch_id returned by draft_cycle."),
1093
+ batch_id: z.string().describe("The batch_id returned by submit_drafts."),
1184
1094
  post: z
1185
1095
  .array(z.number().int().positive())
1186
1096
  .optional()
@@ -1194,7 +1104,7 @@ tool("post_drafts", {
1194
1104
  }, async ({ batch_id, post, edits, post_all }) => {
1195
1105
  const plan = readPlan(batch_id);
1196
1106
  if (!plan || !(plan.candidates && plan.candidates.length)) {
1197
- return textContent(`No drafts found for batch ${batch_id}. Run draft_cycle again to produce a fresh batch.`);
1107
+ return textContent(`No drafts found for batch ${batch_id}. Run scan_candidates then submit_drafts again to produce a fresh batch.`);
1198
1108
  }
1199
1109
  const candidates = plan.candidates;
1200
1110
  const total = candidates.length;
@@ -1831,11 +1741,10 @@ tool("submit_drafts", {
1831
1741
  const project = (candidates.map((c) => c.matched_project).find((p) => !!p) ||
1832
1742
  firstSc?.matched_project ||
1833
1743
  "default");
1834
- const plan = { candidates };
1835
- writePlan(args.batch_id, plan);
1836
- // Bake link targets into the plan (sub-second at TWITTER_PAGE_GEN_RATE=0), the
1837
- // same prep step DRAFT_ONLY does before handing off. Best-effort: posting
1838
- // falls back to the plain project URL per-candidate if this is skipped.
1744
+ // Stage the new drafts under the scan batch id and bake link targets into them
1745
+ // (sub-second at TWITTER_PAGE_GEN_RATE=0). Best-effort: posting falls back to the
1746
+ // plain project URL per-candidate if gen is skipped.
1747
+ writePlan(args.batch_id, { candidates });
1839
1748
  try {
1840
1749
  await runPython("scripts/twitter_gen_links.py", ["--plan", planPath(args.batch_id)], {
1841
1750
  timeoutMs: 120_000,
@@ -1845,21 +1754,45 @@ tool("submit_drafts", {
1845
1754
  catch {
1846
1755
  /* best effort — plan still posts with a plain-URL fallback */
1847
1756
  }
1848
- const finalPlan = readPlan(args.batch_id) ?? plan;
1849
- const count = (finalPlan.candidates || []).length;
1850
- // Surface to the menu-bar review cards (same contract draft_cycle uses).
1757
+ const staged = readPlan(args.batch_id)?.candidates ?? candidates;
1758
+ // Accumulate into ONE persistent review queue so a continuous autopilot's drafts
1759
+ // PILE UP in the menu-bar cards instead of each run overwriting the last. New
1760
+ // drafts are appended; a thread already in the queue (by URL) is skipped (one
1761
+ // draft per thread). Posted entries are KEPT in place so the 1-based card
1762
+ // numbering stays stable across runs — the menu bar, the chat table, and
1763
+ // post_drafts all index the full array and filter on the `posted` flag.
1764
+ const queue = [
1765
+ ...(readPlan(REVIEW_QUEUE_ID)?.candidates ?? []),
1766
+ ];
1767
+ const seen = new Set(queue.map((c) => c.candidate_url).filter((u) => !!u));
1768
+ let added = 0;
1769
+ for (const nc of staged) {
1770
+ if (nc.candidate_url && seen.has(nc.candidate_url))
1771
+ continue;
1772
+ queue.push(nc);
1773
+ if (nc.candidate_url)
1774
+ seen.add(nc.candidate_url);
1775
+ added++;
1776
+ }
1777
+ writePlan(REVIEW_QUEUE_ID, { candidates: queue });
1778
+ const pending = queue.filter((c) => c.posted !== true);
1779
+ // Drafts queued = the pipeline verified end-to-end without posting. This is the
1780
+ // onboarding "draft_verified" terminal goal (formerly completed by draft_cycle).
1781
+ if (added > 0)
1782
+ completeOnboardingMilestone("draft_verified", { outcome: "review_batch", draft_count: added });
1783
+ // Point the menu-bar review cards at the accumulated queue.
1851
1784
  writeReviewRequest({
1852
- batch_id: args.batch_id,
1785
+ batch_id: REVIEW_QUEUE_ID,
1853
1786
  project,
1854
- count,
1855
- plan_path: planPath(args.batch_id),
1787
+ count: pending.length,
1788
+ plan_path: planPath(REVIEW_QUEUE_ID),
1856
1789
  created_at: new Date().toISOString(),
1857
1790
  });
1858
- return textContent(`${count} draft(s) queued for review (batch ${args.batch_id}). They're now in the menu-bar ` +
1859
- `approval cards and the table below; nothing posts until approved.\n\n` +
1860
- renderDraftsTable(finalPlan) +
1861
- `\n\nTo post the approved ones: the user approves in the menu bar, or call post_drafts with the ` +
1862
- `numbers to post.`);
1791
+ return textContent(`Queued ${added} new draft(s); ${pending.length} now awaiting approval in the menu-bar cards ` +
1792
+ `(review queue "${REVIEW_QUEUE_ID}"). Nothing posts until approved.\n\n` +
1793
+ renderDraftsTable({ candidates: queue }) +
1794
+ `\n\nTo post: the user approves in the menu bar, or call post_drafts with batch_id ` +
1795
+ `"${REVIEW_QUEUE_ID}" and the numbers to post.`);
1863
1796
  });
1864
1797
  appTool("dashboard", {
1865
1798
  title: "Social Autoposter dashboard",
@@ -1867,7 +1800,7 @@ appTool("dashboard", {
1867
1800
  "connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, connect X, " +
1868
1801
  "and refresh. Use when the user asks to see the dashboard, panel, " +
1869
1802
  "status, or controls. ALSO call this at the end of any state-changing or results-producing " +
1870
- "action (draft_cycle, post_drafts, get_stats) so the user sees the " +
1803
+ "action (scan_candidates, submit_drafts, post_drafts, get_stats) so the user sees the " +
1871
1804
  "updated dashboard. Hosts without UI support get the same data as text.",
1872
1805
  inputSchema: {},
1873
1806
  // fallback_url is set only when the host can't render the ui:// resource and
@@ -13,7 +13,7 @@ import { repoDir, runPython } from "./repo.js";
13
13
  import { VERSION } from "./version.js";
14
14
  // Sentry DSN is a client-side identifier (safe to embed, same posture as Fazm's
15
15
  // hardcoded Swift DSN). Overridable via env for dev. Empty -> Sentry disabled.
16
- const EMBEDDED_DSN = "";
16
+ const EMBEDDED_DSN = "https://4d44ac907262c6545cf8681703528d04@o4507617161314304.ingest.us.sentry.io/4511598804336640";
17
17
  const SENTRY_DSN = process.env.SAPS_SENTRY_DSN || EMBEDDED_DSN;
18
18
  let sentryReady = false;
19
19
  export function initSentry() {
@@ -2,10 +2,12 @@
2
2
  //
3
3
  // The "real" version is the top-level `social-autoposter` npm package version
4
4
  // (e.g. 1.6.x) — that is what actually bundles this MCP's prebuilt dist/. The
5
- // MCP's own package.json (0.0.1) and manifest (0.0.1) are decoupled artifacts
6
- // that nobody bumps, so they are NOT a reliable signal. This module resolves the
7
- // true version from the most authoritative source available at runtime, and can
8
- // check npm for a newer published release so we can deliver updates on demand.
5
+ // MCP's own package.json and manifest are stamped to the same version at release
6
+ // time (scripts/release-mcpb.sh step 3b), but historically they were frozen at
7
+ // 0.0.1, so this module still resolves the true version from the most
8
+ // authoritative source available at runtime (and tolerates a stale co-located
9
+ // package.json on an old bundle). It can also check npm for a newer published
10
+ // release so we can deliver updates on demand.
9
11
  import fs from "node:fs";
10
12
  import path from "node:path";
11
13
  import { fileURLToPath } from "node:url";
@@ -26,7 +28,8 @@ function readJsonVersion(p) {
26
28
  // from the npm package version at every init/update. Authoritative on a
27
29
  // real user install, where the top-level package.json is NOT copied.
28
30
  // 2. <repo>/package.json — git checkout / dev machine: the meaningful 1.6.x.
29
- // 3. mcp/package.json — co-located last resort (currently 0.0.1).
31
+ // 3. mcp/package.json — co-located last resort (release-stamped to match,
32
+ // but may be stale on an older bundle).
30
33
  export function resolveVersion() {
31
34
  return (readJsonVersion(path.join(__dirname, "version.json")) ||
32
35
  readJsonVersion(path.join(repoDir(), "package.json")) ||
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.71",
3
- "installedAt": "2026-06-20T16:29:20.789Z"
2
+ "version": "1.6.72",
3
+ "installedAt": "2026-06-20T17:17:17.565Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "Social Autoposter",
5
- "version": "0.0.1",
5
+ "version": "1.6.72",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the social-autoposter pipeline.",
7
7
  "long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Fully quit and restart Claude (quit and reopen, not just close the window).\n2. Choose **Set up social-autoposter** from the prompt menu or type **Set me up on social-autoposter end to end**. The agent installs the owned runtime, connects X, discovers and configures your product, and verifies with a draft-only cycle.",
8
8
  "author": {
@@ -15,7 +15,9 @@
15
15
  "entry_point": "dist/index.js",
16
16
  "mcp_config": {
17
17
  "command": "node",
18
- "args": ["${__dirname}/dist/index.js"],
18
+ "args": [
19
+ "${__dirname}/dist/index.js"
20
+ ],
19
21
  "env": {
20
22
  "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
21
23
  }
@@ -43,7 +45,13 @@
43
45
  "description": "Render the visual dashboard (MCP Apps UI): project setup, X connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, connect X, and refresh. Also rendered automatically after state-changing actions."
44
46
  }
45
47
  ],
46
- "keywords": ["twitter", "x", "social", "autoposter", "marketing"],
48
+ "keywords": [
49
+ "twitter",
50
+ "x",
51
+ "social",
52
+ "autoposter",
53
+ "marketing"
54
+ ],
47
55
  "license": "UNLICENSED",
48
56
  "repository": {
49
57
  "type": "git",
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "0.0.1",
3
+ "version": "1.6.72",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.71",
3
+ "version": "1.6.73",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -87,7 +87,6 @@
87
87
  "!scripts/export_kent_handoff.py",
88
88
  "!scripts/extract_user_messages_today.py",
89
89
  "!scripts/fazm_seo_health.py",
90
- "!scripts/fetch_twitter_t1.py",
91
90
  "!scripts/fix_mdx_light_mode.py",
92
91
  "!scripts/fix_style_lengths_20260601.py",
93
92
  "!scripts/fix_svg_paragraph_wrap.py",
@@ -75,6 +75,31 @@ fs.writeFileSync(p, JSON.stringify({version:'$VERSION',installedAt:new Date().to
75
75
  console.log(' '+fs.readFileSync(p,'utf8').trim());
76
76
  "
77
77
 
78
+ # ---- 3b. Stamp manifest.json + mcp/package.json + lockfile version ----------
79
+ # Claude Desktop's extension "Details" panel reads version from manifest.json,
80
+ # so it must track the release version too (not the frozen 0.0.1 placeholder).
81
+ # mcp/package.json and its lockfile are stamped in lockstep so the three stay
82
+ # consistent (npm errors if package.json and package-lock.json disagree).
83
+ say "Stamping mcp/manifest.json + mcp/package.json + mcp/package-lock.json -> $VERSION"
84
+ node -e "
85
+ const fs=require('fs');
86
+ const V='$VERSION';
87
+ for (const p of ['$MCP_DIR/manifest.json','$MCP_DIR/package.json']) {
88
+ const j=JSON.parse(fs.readFileSync(p,'utf8'));
89
+ j.version=V;
90
+ fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
91
+ console.log(' '+p.replace('$MCP_DIR/','mcp/')+' -> '+j.version);
92
+ }
93
+ const lp='$MCP_DIR/package-lock.json';
94
+ if (fs.existsSync(lp)) {
95
+ const l=JSON.parse(fs.readFileSync(lp,'utf8'));
96
+ l.version=V;
97
+ if (l.packages && l.packages['']) l.packages[''].version=V;
98
+ fs.writeFileSync(lp, JSON.stringify(l,null,2)+'\n');
99
+ console.log(' mcp/package-lock.json -> '+l.version);
100
+ }
101
+ "
102
+
78
103
  # ---- 4. Pack the .mcpb ------------------------------------------------------
79
104
  say "Packing $BUNDLE"
80
105
  rm -f "$BUNDLE"
@@ -99,6 +124,10 @@ BUNDLE_VER=$(unzip -p "$BUNDLE" dist/version.json 2>/dev/null | node -p "JSON.pa
99
124
  [[ "$BUNDLE_VER" == "$VERSION" ]] || die "bundle version.json=$BUNDLE_VER != $VERSION"
100
125
  echo " version.json: $BUNDLE_VER ok"
101
126
 
127
+ MANIFEST_VER=$(unzip -p "$BUNDLE" manifest.json 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo "?")
128
+ [[ "$MANIFEST_VER" == "$VERSION" ]] || die "bundle manifest.json=$MANIFEST_VER != $VERSION (Desktop Details panel would show the wrong version)"
129
+ echo " manifest.json: $MANIFEST_VER ok"
130
+
102
131
  for f in "dist/index.js" "dist/runtime.js" "manifest.json"; do
103
132
  # grep -c reads all input (no SIGPIPE); anchor on the time column + 3-space
104
133
  # gutter so node_modules/.../dist/index.js does not false-match the top-level.
@@ -15,7 +15,7 @@ import os
15
15
 
16
16
  # Client-side DSN: safe to embed, same posture as the Node telemetry and Fazm's
17
17
  # hardcoded Swift DSN. Overridable via env for dev. Empty -> Sentry disabled.
18
- _EMBEDDED_DSN = ""
18
+ _EMBEDDED_DSN = "https://4d44ac907262c6545cf8681703528d04@o4507617161314304.ingest.us.sentry.io/4511598804336640"
19
19
 
20
20
  _initialized = False
21
21
 
package/skill/lock.sh CHANGED
@@ -617,10 +617,9 @@ defer_if_foreign_browser_mcp_active() {
617
617
 
618
618
  # Explicit early release. Use this when a long-running script only needs the
619
619
  # browser for part of its run (e.g. run-twitter-cycle.sh holds the lock for
620
- # Phase 1 scrape, releases during the 5-min T1 sleep + Phase 2a HTTP poll, then
621
- # re-acquires before Phase 2b posting). Without this, sibling pipelines waiting
622
- # on the same profile lock block for the full cycle even when the holder is
623
- # only sleeping.
620
+ # Phase 1 scrape, releases between Phase 1 and Phase 2b posting, then re-acquires
621
+ # before Phase 2b). Without this, sibling pipelines waiting on the same profile
622
+ # lock block for the full cycle even when the holder is not using the browser.
624
623
  release_lock() {
625
624
  local name="$1"
626
625
  local lock_dir="/tmp/social-autoposter-${name}.lock"
@@ -12,18 +12,14 @@
12
12
  # - scrape tweets via twitter-harness, enrich via fxtwitter -> T0 snapshot
13
13
  # - store all candidates with batch_id and search_topic
14
14
  #
15
- # Sleep 300s.
15
+ # No ripen wait (variant D won the A/B/C/D test 2026-05-31): the cycle goes
16
+ # straight from discovery to drafting. There is NO 5-min sleep and NO fxtwitter
17
+ # T1 re-poll anywhere in the Twitter pipeline. delta_score stays at its T0
18
+ # value and is no longer a gate. Do not re-introduce a ripen/sleep step here.
16
19
  #
17
- # Phase 2 (t=5m):
18
- # - re-fetch the same candidates via fxtwitter -> T1 snapshot + delta_score
19
- # - SQL gate: floor lowered to delta_score >= 0 so zero-momentum but on-theme
20
- # product-discussion tweets (asking for a tool, venting a pain point) can
21
- # compete; ranking still favors growth via hybrid sort
22
- # - Hybrid sort: ORDER BY (delta_score + product_intent_boost) where
23
- # product_intent_boost is +5 when tweet text matches an intent-signal regex
24
- # (wish/need/looking for/recommend/alternative/frustrated/etc); raw growth
25
- # remains the dominant signal, but a slow-burn "anyone know a tool for..."
26
- # tweet now ranks alongside fast-growing news/drama
20
+ # Phase 2 (immediately after Phase 1):
21
+ # - sort candidates by virality_score DESC (composite predictor stamped at
22
+ # discovery by score_twitter_candidates.py); no delta floor, no T1 re-poll
27
23
  # - Claude reads top 25 (raised from 15 so the long tail reaches the model),
28
24
  # drops unsuitable, posts every candidate it judges genuinely on-brand
29
25
  # (no per-cycle post cap, no per-project quota)
@@ -412,7 +408,7 @@ python3 "$REPO_DIR/scripts/twitter_batch_phase.py" start "$BATCH_ID" --phase pha
412
408
  # read from the owner's twitter_batches row:
413
409
  # phase0 -> 5 min (just the salvage SQL)
414
410
  # phase1 -> 20 min (Claude scan + scrape)
415
- # phase2a -> 20 min (5 min sleep + HTTP T1 poll)
411
+ # phase2a -> 20 min (browser-lock handoff window; no ripen wait since 2026-05-31)
416
412
  # phase2b-prep -> 45 min (Claude reads threads + drafts; bumped 2026-05-15
417
413
  # 15 -> 30 after 17:15 cycle was wrongly salvaged
418
414
  # while queued behind 17:30's 42-min lock-hold;
@@ -1454,16 +1450,16 @@ if [ "$BATCH_COUNT" = "0" ]; then
1454
1450
  exit 0
1455
1451
  fi
1456
1452
 
1457
- # Stamp phase2a before releasing the lock so the budget covers the entire
1458
- # 5-min wait + HTTP poll window (phase2a budget = 20 min).
1453
+ # Stamp phase2a before releasing the lock so the salvage budget covers the
1454
+ # browser-lock handoff window (phase2a budget = 20 min).
1459
1455
  python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase phase2a 2>&1 | tee -a "$LOG_FILE" || true
1460
1456
 
1461
- # Release the twitter-browser lock during the 5-min T1 wait + HTTP-only Phase 2a.
1457
+ # Release the twitter-browser lock between Phase 1 scrape and Phase 2b posting.
1462
1458
  # Other pipelines (engage-twitter, dm-outreach-twitter, link-edit-twitter,
1463
1459
  # stats.sh) can run their browser steps in this window instead of waiting for us
1464
1460
  # to finish. We re-acquire just before Phase 2b posts, blocking up to the
1465
1461
  # acquire_lock timeout if another pipeline is mid-run.
1466
- log "Releasing twitter-browser lock for the T1 wait window (5min sleep + HTTP fxtwitter poll)..."
1462
+ log "Releasing twitter-browser lock between Phase 1 scrape and Phase 2b posting..."
1467
1463
  release_lock "twitter-browser" 2>>"$LOG_FILE"
1468
1464
  # (2026-06-16) NO `rm -f twitter-browser-lock.json` here. The blind rm was
1469
1465
  # ownership-unaware and ran AFTER release_lock, so under a pipeline handoff it