social-autoposter 1.6.110 → 1.6.112

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,8 +2,9 @@
2
2
  // social-autoposter MCP server (X/Twitter rail).
3
3
  //
4
4
  // Core tools:
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).
5
+ // run_draft_cycle - fire ONE real pipeline cycle (scan -> draft via the queue + worker
6
+ // task), merging the resulting drafts into the menu-bar approval cards
7
+ // (posts nothing).
7
8
  // post_drafts - post the drafts the user chose by number from a batch.
8
9
  // get_stats - read-only post + engagement stats.
9
10
  //
@@ -17,7 +18,7 @@ import { screencast, bringBrowserToFront } from "./screencast.js";
17
18
  import os from "node:os";
18
19
  import path from "node:path";
19
20
  import fs from "node:fs";
20
- import { repoDir, runPython, run, readPlan, writePlan, planPath, scanResultPath, } from "./repo.js";
21
+ import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
21
22
  import { applySetup, resolveProject, listManagedProjectStatus, ensureShortLinksDefault, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, } from "./setup.js";
22
23
  import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
23
24
  import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, ensureRuntimeProvisioned, } from "./runtime.js";
@@ -32,15 +33,11 @@ import http from "node:http";
32
33
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
33
34
  const PANEL_URI = "ui://social-autoposter/panel.html";
34
35
  const PRODUCT_LINK_URI = "ui://social-autoposter/product-link.html";
35
- // Stable id for the accumulating draft review queue. submit_drafts appends each
36
- // run's drafts here (dedup by tweet URL) so the menu-bar cards PILE UP across a
36
+ // Stable id for the accumulating draft review queue. Each draft cycle appends its
37
+ // drafts here (dedup by tweet URL) so the menu-bar cards PILE UP across a
37
38
  // continuous autopilot instead of each run overwriting the last; post_drafts posts
38
39
  // the approved subset and marks them posted (filtered out of the cards thereafter).
39
40
  const REVIEW_QUEUE_ID = "review-queue";
40
- // The Desktop scheduled task onboarding creates for the autopilot. Its presence on
41
- // disk is the single "autopilot is set up" signal the dashboard + menu bar share
42
- // (the legacy launchd autopilot is retired).
43
- const AUTOPILOT_TASK_ID = "social-autoposter-autopilot";
44
41
  // ---- Queue-backed drafting (2026-06-23) -----------------------------------
45
42
  // Customer .mcpb boxes have no `claude` CLI, so the deterministic pipeline can't
46
43
  // run its `claude -p` steps directly. Instead a launchd job kicks the REAL
@@ -49,12 +46,21 @@ const AUTOPILOT_TASK_ID = "social-autoposter-autopilot";
49
46
  // and blocks. Two Claude Desktop scheduled tasks — one per job type — drain that
50
47
  // queue, run the pipeline's own prompt as a Claude turn, and write the result
51
48
  // back, unblocking the cycle. This reuses the entire pipeline (styles, voice,
52
- // top-performers, em-dash rules) instead of the old scan_candidates host-draft
53
- // reimplementation. See scripts/claude_job.py + run_claude.sh's provider seam.
49
+ // top-performers, em-dash rules). See scripts/claude_job.py + run_claude.sh's
50
+ // provider seam.
54
51
  const PHASE1_TASK_ID = "saps-phase1-query"; // drains "twitter-query" jobs
55
52
  const PHASE2B_TASK_ID = "saps-phase2b-draft"; // drains "twitter-prep" jobs
56
53
  const TWITTER_AUTOPILOT_LABEL = "com.m13v.social-twitter-cycle";
57
54
  const TWITTER_AUTOPILOT_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${TWITTER_AUTOPILOT_LABEL}.plist`);
55
+ // Self-healing reaper for leaked Claude agent-mode worker sessions. The queue
56
+ // autopilot fires two scheduled tasks every ~1 min; each fire spawns a ~200 MB
57
+ // `claude` agent-mode session that finishes its one queue turn but never exits
58
+ // (Desktop keeps the stream-json session warm), so they pile up — 226 procs /
59
+ // 22.5 GB on the test box in ~1h, load 75, near-OOM. We can't change Desktop's
60
+ // teardown, so a 60s launchd job kills the leaked sessions (see the script for
61
+ // the uuid-grouping safety that spares real interactive sessions).
62
+ const REAPER_LABEL = "com.m13v.social-claude-reaper";
63
+ const REAPER_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${REAPER_LABEL}.plist`);
58
64
  // Daily self-updater. Enabled alongside autopilot so a hands-free (headless)
59
65
  // install keeps itself current — the interactive `runtime` tool (action:'update')
60
66
  // only helps when
@@ -205,8 +211,8 @@ const server = new McpServer({
205
211
  "SCHEDULE + VERIFY THE AUTOPILOT (final onboarding step). There is ONE drafting pipeline: a launchd " +
206
212
  "job this server installs runs the real cycle in draft-only mode, its Phase-2b Claude turn is " +
207
213
  "serviced by a scheduled task that drains a local job queue, and the result is merged into the " +
208
- "menu-bar approval cards. There is NO scan_candidates/submit_drafts host-draft path anymore do " +
209
- "NOT use those to draft or to verify. Steps, in order: " +
214
+ "menu-bar approval cards. Drafting is the queue + worker path; `run_draft_cycle` is how you " +
215
+ "fire a real cycle on demand. Steps, in order: " +
210
216
  "(1) call `queue_setup` — it pre-writes the worker task prompts and returns two task specs " +
211
217
  "(saps-phase1-query, saps-phase2b-draft); " +
212
218
  "(2) for EACH spec, call the HOST tool `create_scheduled_task` with its taskId, cronExpression, and " +
@@ -235,7 +241,7 @@ const server = new McpServer({
235
241
  "project is fully configured. To change anything about a project later, call `project_config` " +
236
242
  "again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
237
243
  "RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
238
- "(`scan_candidates`, `submit_drafts`, `post_drafts`, `get_stats`), end your turn by " +
244
+ "(`run_draft_cycle`, `post_drafts`, `get_stats`), end your turn by " +
239
245
  "calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
240
246
  "`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.",
241
247
  });
@@ -318,28 +324,28 @@ function blockedReasonMessage(reason) {
318
324
  "couldn't run. (It DID find and rank threads, it just couldn't draft replies.) This " +
319
325
  "CLI uses its own login, separate from Claude Desktop. To fix it, open a terminal and run:\n\n" +
320
326
  " claude\n\n" +
321
- "then `/login` inside it (or run `claude setup-token`). Once it's logged in, run scan_candidates again.");
327
+ "then `/login` inside it (or run `claude setup-token`). Once it's logged in, run `run_draft_cycle` again.");
322
328
  case "monthly_limit":
323
329
  case "daily_limit":
324
330
  case "rate_limit_5h":
325
331
  return (`The drafting step hit an Anthropic usage limit (${reason}), so no replies were drafted. ` +
326
- "Wait for the limit to reset, then run scan_candidates again.");
332
+ "Wait for the limit to reset, then run `run_draft_cycle` again.");
327
333
  case "no_search_topics":
328
334
  return ("This project has no search topics yet, so there was nothing to scan. Topics live in the " +
329
335
  "DB (project_search_topics) and are seeded from your project's `search_topics` when you " +
330
336
  "configure it. Re-run the `project_config` tool for this project with a `search_topics` list " +
331
337
  "(comma-separated keywords/phrases your buyers tweet about); it seeds them automatically, then " +
332
- "run scan_candidates again.");
338
+ "run `run_draft_cycle` again.");
333
339
  case "topics_api_unreachable":
334
340
  return ("Couldn't reach the search-topics service to load this project's topics, so the cycle stopped " +
335
- "before scanning. This is usually a transient backend/network issue. Try scan_candidates again in a " +
341
+ "before scanning. This is usually a transient backend/network issue. Try `run_draft_cycle` again in a " +
336
342
  "moment; if it persists, check connectivity to the autoposter backend.");
337
343
  case "credit_balance":
338
344
  return ("The drafting step failed because the Anthropic account is out of credits. " +
339
- "Add credits, then run scan_candidates again.");
345
+ "Add credits, then run `run_draft_cycle` again.");
340
346
  default:
341
347
  return (`The drafting step failed (${reason}) and produced no drafts. ` +
342
- "Check skill/logs/twitter-cycle-*.log on this machine for details, then run scan_candidates again.");
348
+ "Check skill/logs/twitter-cycle-*.log on this machine for details, then run `run_draft_cycle` again.");
343
349
  }
344
350
  }
345
351
  // Turn a raw run-twitter-cycle.sh stdout line into a short, user-facing
@@ -692,16 +698,15 @@ async function postApproved(batchId, plan) {
692
698
  "or set accounts.twitter.handle in config.json.",
693
699
  };
694
700
  }
695
- // Mark posting active so scan_candidates DEFERS launching any scan for the
701
+ // Mark posting active so the draft-cycle scan DEFERS launching any scan for the
696
702
  // duration of this batch (+ grace). This is the source-level mutual exclusion
697
703
  // that actually fixes the hijack: the autopilot never launches a scan to race
698
704
  // the post for the browser. Reset is guaranteed by scheduleShellLockRelease()
699
705
  // in the finally below, so an early/failed post can't wedge scanning.
700
706
  postingActive = true;
701
707
  startPostingFlagHeartbeat(); // cross-instance: a sibling MCP's scan defers too
702
- // Posting is a priority over scanning: abort any in-flight plugin scan so the
708
+ // Posting is a priority over scanning: abort any in-flight pipeline scan so the
703
709
  // approved post takes the browser immediately instead of waiting on the lock.
704
- // Plugin pipeline only — never affects the plist autopilot.
705
710
  preemptScanForPost();
706
711
  // Hold the /tmp shell browser lock (the one the scanner respects) for the WHOLE
707
712
  // batch so the every-minute autopilot scan queues behind the post instead of
@@ -761,7 +766,7 @@ async function postApproved(batchId, plan) {
761
766
  // BYO-Chrome), else default to the local harness on port 9555.
762
767
  TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
763
768
  },
764
- // Stream the poster's output live (like scan_candidates does) so HANDLED
769
+ // Stream the poster's output live so HANDLED
765
770
  // failures — e.g. every reply refused with no_account_configured, which
766
771
  // returns a reason instead of throwing — surface in main.log + telemetry
767
772
  // in real time. Without this the poster's stdout was buffered in-process
@@ -997,7 +1002,7 @@ tool("project_config", {
997
1002
  "Call with status:true (or no name) to list every configured project, its remaining fields, AND " +
998
1003
  "whether X is connected. Use config, conversation context, profile_scan, and website research " +
999
1004
  "before asking for fields. Ask only if no product can be identified or an interactive login is " +
1000
- "unavoidable. The scan_candidates and get_stats tools refuse to run until a project is " +
1005
+ "unavoidable. The run_draft_cycle and get_stats tools refuse to run until a project is " +
1001
1006
  "fully set up.",
1002
1007
  inputSchema: {
1003
1008
  status: z.boolean().optional(),
@@ -1276,7 +1281,7 @@ tool("project_config", {
1276
1281
  (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.")
1277
1282
  : projects.every((p) => p.ready)
1278
1283
  ? (x.connected
1279
- ? "All configured projects are ready and X is connected. SCHEDULE + VERIFY THE AUTOPILOT: (1) call queue_setup and create each returned task with create_scheduled_task (prompt verbatim; 'already exists' is fine); (2) call run_draft_cycle to fire one real cycle; (3) poll the `dashboard` tool for ~3 min until the pending-draft count rises — that card came through the real pipeline. Do NOT use scan_candidates/submit_drafts to draft, do NOT create the deprecated 'social-autoposter-autopilot' task, do NOT pause to ask the user to review drafts. Then call `dashboard` so the user sees the finished setup."
1284
+ ? "All configured projects are ready and X is connected. SCHEDULE + VERIFY THE AUTOPILOT: (1) call queue_setup and create each returned task with create_scheduled_task (prompt verbatim; 'already exists' is fine); (2) call run_draft_cycle to fire one real cycle; (3) poll the `dashboard` tool for ~3 min until the pending-draft count rises — that card came through the real pipeline. Do NOT pause to ask the user to review drafts. Then call `dashboard` so the user sees the finished setup."
1280
1285
  : "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
1281
1286
  "x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
1282
1287
  : "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." +
@@ -1318,13 +1323,13 @@ tool("project_config", {
1318
1323
  topic_count: topicCount,
1319
1324
  });
1320
1325
  seedNote = m
1321
- ? ` 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.`
1322
- : " Seeded search topics into the DB so scan_candidates has a topic universe to work with.";
1326
+ ? ` Seeded ${m[1]} search topic(s) into the DB (new: ${m[2]}, updated: ${m[3]}), so the draft cycle has a topic universe to work with.`
1327
+ : " Seeded search topics into the DB so the draft cycle has a topic universe to work with.";
1323
1328
  }
1324
1329
  else {
1325
1330
  const tail = (seed.stderr || seed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
1326
1331
  blockOnboardingMilestone("topics_seeded", "topic_seed_failed", tail, { exit_code: seed.code });
1327
- seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}. scan_candidates will tell you clearly if topics are missing.)`;
1332
+ seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}. run_draft_cycle will tell you clearly if topics are missing.)`;
1328
1333
  }
1329
1334
  // Cold-start QUERY supply: fan the seeded topics out into >=30 real X
1330
1335
  // search queries (project_search_queries) so the deterministic Phase 1
@@ -1419,29 +1424,21 @@ tool("project_config", {
1419
1424
  return textContent(`Setup failed: ${e.message}`);
1420
1425
  }
1421
1426
  });
1422
- // ---- draft_cycle: DEPRECATED 2026-06-20 (registration removed) -------------
1423
- // Replaced by the scan_candidates -> (agent drafts) -> submit_drafts flow, so the
1424
- // AI drafting now runs in the calling session (on the user's plan) instead of a
1425
- // spawned `claude -p`. post_drafts (below) still posts the approved subset, and
1426
- // submit_drafts completes the onboarding "draft_verified" milestone that this
1427
- // tool used to. The underlying pipeline (run-twitter-cycle.sh) and the
1428
- // produceDrafts() helper are kept as the source those tools reuse; only the
1429
- // draft_cycle tool registration was removed here.
1430
1427
  // ---- post_drafts: post the user's chosen drafts from a batch ---------------
1431
- // Second half of the manual loop. The user reviewed the table from draft_cycle
1432
- // and said which numbers to post / edit; this posts exactly those. Editing a
1433
- // draft implies posting it. Indices are 1-based, matching the table.
1428
+ // Second half of the manual loop. The user reviewed the menu-bar cards a draft
1429
+ // cycle produced and said which numbers to post / edit; this posts exactly those.
1430
+ // Editing a draft implies posting it. Indices are 1-based, matching the table.
1434
1431
  tool("post_drafts", {
1435
1432
  title: "Post chosen drafts",
1436
- description: "Post the drafts the user approved from a submit_drafts batch. Pass the batch_id from " +
1437
- "submit_drafts and the user's decision by NUMBER (1-based, matching the table): `post` is " +
1433
+ description: "Post the drafts the user approved from a draft cycle. Pass the batch_id from the " +
1434
+ "approval cards and the user's decision by NUMBER (1-based, matching the table): `post` is " +
1438
1435
  "the list of draft numbers to post as drafted; `edits` rewrites a draft's text before " +
1439
1436
  "posting it (editing implies posting); `post_all` posts every draft. Only the chosen " +
1440
1437
  "drafts post; anything not listed is left unposted. Call this ONLY after the user has " +
1441
1438
  "told you which drafts they want. After posting, call the `dashboard` tool so the user " +
1442
1439
  "sees the updated state.",
1443
1440
  inputSchema: {
1444
- batch_id: z.string().describe("The batch_id returned by submit_drafts."),
1441
+ batch_id: z.string().describe("The batch_id of the draft batch (from the approval cards)."),
1445
1442
  post: z
1446
1443
  .array(z.number().int().positive())
1447
1444
  .optional()
@@ -1460,7 +1457,7 @@ tool("post_drafts", {
1460
1457
  }, async ({ batch_id, post, edits, post_all, reject }) => {
1461
1458
  const plan = readPlan(batch_id);
1462
1459
  if (!plan || !(plan.candidates && plan.candidates.length)) {
1463
- return textContent(`No drafts found for batch ${batch_id}. Run scan_candidates then submit_drafts again to produce a fresh batch.`);
1460
+ return textContent(`No drafts found for batch ${batch_id}. Run a draft cycle (run_draft_cycle) again to produce a fresh batch.`);
1464
1461
  }
1465
1462
  const candidates = plan.candidates;
1466
1463
  const total = candidates.length;
@@ -1805,7 +1802,7 @@ tool("queue_setup", {
1805
1802
  // the 5-min timer). The cycle drafts via the queue + worker task and the wrapper
1806
1803
  // merges the result into the review-queue cards. Use this for onboarding
1807
1804
  // verification ("does the real pipeline produce a card?") and any on-demand
1808
- // "draft now" NOT scan_candidates/submit_drafts, which are gone.
1805
+ // "draft now". This is the single draft path.
1809
1806
  tool("run_draft_cycle", {
1810
1807
  title: "Run one draft cycle now",
1811
1808
  description: "Fires ONE real draft cycle immediately (the same pipeline the autopilot runs): it scans, drafts " +
@@ -1838,19 +1835,21 @@ tool("run_draft_cycle", {
1838
1835
  // ---- panel: MCP Apps control surface --------------------------------------
1839
1836
  // A self-contained HTML view rendered by hosts that support MCP Apps (Claude
1840
1837
  // desktop/web, etc.). It duplicates NO pipeline logic: each button calls one of
1841
- // the tools above (draft_cycle / project_config / get_stats) through the host
1838
+ // the tools above (run_draft_cycle / project_config / get_stats) through the host
1842
1839
  // and re-reads status. The tool itself returns the first-paint snapshot so the
1843
1840
  // view has data the instant it loads.
1844
1841
  // Is either launchd job (cycle / daily updater) currently loaded?
1845
- // "Autopilot" is now the Claude Desktop scheduled task `social-autoposter-autopilot`
1846
- // (created during onboarding via create_scheduled_task), NOT the legacy launchd job.
1847
- // We can't read the host's enabled/paused flag, but the task's presence on disk is the
1842
+ // "Autopilot" is now the pair of Claude Desktop queue-worker scheduled tasks
1843
+ // (saps-phase1-query + saps-phase2b-draft, created during onboarding via
1844
+ // create_scheduled_task) that drain the draft queue, NOT the legacy launchd job.
1845
+ // We can't read the host's enabled/paused flag, but the tasks' presence on disk is the
1848
1846
  // single signal the dashboard AND the menu bar key off of, so they stay aligned.
1849
1847
  async function autopilotLoaded() {
1850
1848
  let autopilot_on = false;
1851
1849
  try {
1852
- const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
1853
- autopilot_on = fs.existsSync(path.join(cfg, "scheduled-tasks", AUTOPILOT_TASK_ID, "SKILL.md"));
1850
+ // Autopilot is "on" once BOTH queue-worker tasks that service the draft
1851
+ // pipeline's queued `claude -p` calls have their SKILL.md on disk.
1852
+ autopilot_on = QUEUE_WORKERS.every((spec) => fs.existsSync(scheduledTaskSkillPath(spec.taskId)));
1854
1853
  }
1855
1854
  catch {
1856
1855
  /* leave false */
@@ -1865,176 +1864,11 @@ async function autopilotLoaded() {
1865
1864
  }
1866
1865
  return { autopilot_on, auto_update_on };
1867
1866
  }
1868
- // ---- Autopilot prompt: keep the scheduled task's SKILL.md current ------------
1869
- // The scheduled task's prompt (SKILL.md) is owned by the host and does NOT update
1870
- // when the plugin updates. So a behavior change (the no-improvise / voice-inline /
1871
- // stop-cleanly rules) would never reach an already-scheduled task. We close that
1872
- // gap by REWRITING the task's SKILL.md on server boot when its embedded prompt
1873
- // version is older than what this build ships. The host reads the file fresh on
1874
- // each run, so the next fire picks up the new prompt — no host tool, no user
1875
- // action. Bump AUTOPILOT_PROMPT_VERSION whenever the prompt below changes.
1876
- const AUTOPILOT_PROMPT_VERSION = 2;
1877
- const AUTOPILOT_PROMPT_MARKER = "saps_autopilot_prompt_version";
1878
- function autopilotSkillPath() {
1879
- const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
1880
- return path.join(cfg, "scheduled-tasks", AUTOPILOT_TASK_ID, "SKILL.md");
1881
- }
1882
- // The canonical draft-only autopilot prompt: uses ONLY scan_candidates +
1883
- // submit_drafts, drafts from the INLINE project_voices, improvises nothing, and
1884
- // stops cleanly if its tools aren't available.
1885
- function autopilotSkillMd(project) {
1886
- const body = [
1887
- `You are running the Social Autoposter draft-only autopilot for the project "${project}". ` +
1888
- `Run ONE cycle, then stop. DRAFT-ONLY: you must NEVER post to X/Twitter — you only queue ` +
1889
- `drafts for the user's approval. Use ONLY two tools — scan_candidates and submit_drafts — ` +
1890
- `and IMPROVISE NOTHING ELSE. Specifically: NEVER run Bash, Read, Write, Edit, python, or any ` +
1891
- `shell/file tool, for ANY reason — not to debug, inspect a tool result, verify an outcome, or ` +
1892
- `investigate something surprising. This run is unattended: any tool that is not pre-approved ` +
1893
- `STALLS the whole pipeline waiting on a human who is not there, so reaching for one is never ` +
1894
- `worth it. If something looks off, report it in one line and stop.`,
1895
- ``,
1896
- `Steps:`,
1897
- `1. Call scan_candidates with project "${project}". It long-polls: if it returns a "Scan in ` +
1898
- `progress" status, call scan_candidates again (same args) and keep re-calling until it ` +
1899
- `returns candidates. Never sleep or use background waits. If it returns no candidates, stop ` +
1900
- `cleanly — nothing to do this cycle.`,
1901
- `2. The result includes a project_voices field — the brand voice + guardrails for each ` +
1902
- `candidate's project. Draft from project_voices[<matched_project>] (voice, description, ` +
1903
- `differentiator, content_guardrails). NEVER read config files, call project_config, or run ` +
1904
- `Bash/Read to find the voice — everything you need is already in the scan result.`,
1905
- `3. For each candidate you judge genuinely worth engaging, write ONE on-brand reply (<=250 ` +
1906
- `chars, match the thread's language, genuinely helpful — not spammy, no hard selling). Skip ` +
1907
- `the rest.`,
1908
- `4. Call submit_drafts with the batch_id from scan_candidates and a drafts array of ` +
1909
- `{candidate_id, reply_text}. This queues them for the menu-bar approval UI. Do NOT call ` +
1910
- `post_drafts — posting is the user's decision.`,
1911
- `5. You are now DONE: report in ONE short line how many drafts you queued, then stop. ` +
1912
- `"Queued 0 new draft(s)" is a NORMAL, expected result — it means the threads were already ` +
1913
- `queued in an earlier cycle (dedup), NOT a failure. Treat ANY count, including zero, as ` +
1914
- `success. NEVER re-read, parse, or inspect the submit_drafts result; NEVER open files under ` +
1915
- `tool-results/ or anywhere else; NEVER run Bash/Read/python to confirm what happened. A ` +
1916
- `zero or surprising count is not a reason to investigate — just report it and stop.`,
1917
- ``,
1918
- `HARD GUARD: if scan_candidates is NOT available (ToolSearch returns no matching tool — can ` +
1919
- `happen when a run spawns before the extension's MCP server has reconnected), STOP immediately ` +
1920
- `and report "S4L tools unavailable, skipping this cycle." Do NOT search the connector registry, ` +
1921
- `do NOT call list_connectors, do NOT improvise any other tool.`,
1922
- ].join("\n");
1923
- return (`---\n` +
1924
- `name: ${AUTOPILOT_TASK_ID}\n` +
1925
- `description: Social Autoposter draft-only autopilot for project ${project} — scans X, drafts ` +
1926
- `on-brand replies (voice inline), queues them for approval. Never posts.\n` +
1927
- `---\n\n` +
1928
- body +
1929
- `\n\n<!-- ${AUTOPILOT_PROMPT_MARKER}: ${AUTOPILOT_PROMPT_VERSION} -->\n`);
1930
- }
1931
- // Read the project name back from an existing autopilot SKILL.md so a rewrite
1932
- // keeps the same project. Null if it can't be determined (then leave it alone).
1933
- function detectAutopilotProject(skillMd) {
1934
- const m1 = /project[s]?\s+"([^"]+)"/i.exec(skillMd);
1935
- if (m1)
1936
- return m1[1];
1937
- const m2 = /for project\s+([A-Za-z0-9_-]+)/i.exec(skillMd);
1938
- if (m2)
1939
- return m2[1];
1940
- return null;
1941
- }
1942
- // Refresh the scheduled task's SKILL.md when this build ships a newer prompt than
1943
- // what's on disk. Best-effort, synchronous, never throws. Only touches an EXISTING
1944
- // task (onboarding creates the first one), only when stale, and only when the
1945
- // project reads back, so we never write a wrong/blank project.
1946
- function ensureAutopilotPromptCurrent() {
1947
- try {
1948
- const skillPath = autopilotSkillPath();
1949
- if (!fs.existsSync(skillPath))
1950
- return; // no scheduled task yet
1951
- const cur = fs.readFileSync(skillPath, "utf-8");
1952
- const m = new RegExp(`${AUTOPILOT_PROMPT_MARKER}:\\s*(\\d+)`).exec(cur);
1953
- const curVer = m ? parseInt(m[1], 10) : 0;
1954
- if (curVer >= AUTOPILOT_PROMPT_VERSION)
1955
- return; // already current
1956
- const project = detectAutopilotProject(cur);
1957
- if (!project) {
1958
- console.error(`[autopilot] prompt is stale (v${curVer}) but project unreadable from SKILL.md; leaving it`);
1959
- return;
1960
- }
1961
- fs.writeFileSync(skillPath, autopilotSkillMd(project), "utf-8");
1962
- console.error(`[autopilot] refreshed scheduled-task prompt -> v${AUTOPILOT_PROMPT_VERSION} (was v${curVer}), project=${project}`);
1963
- }
1964
- catch (e) {
1965
- console.error(`[autopilot] ensureAutopilotPromptCurrent error: ${e?.message || e}`);
1966
- }
1967
- }
1968
- // ---- Pre-approve the autopilot's own tools in ~/.claude/settings.json --------
1969
- // A Desktop scheduled task created via create_scheduled_task defaults to "Ask"
1970
- // permission mode, and per the Desktop docs an un-pre-approved tool STALLS the
1971
- // run until a human approves it. Allow rules from ~/.claude/settings.json DO
1972
- // apply to scheduled-task sessions, so we ship pre-approval for THIS server's
1973
- // tools (the only ones the autopilot is allowed to use) into that file on boot.
1974
- // This makes the legitimate scan -> submit path never stall, independent of
1975
- // whether anyone clicked "Always allow" during onboarding. ALLOW-only by design:
1976
- // it pre-approves our tools; it does NOT (and on this lane cannot) restrict any
1977
- // others — that is the prompt's job. Merge-in-place: read, add only missing
1978
- // entries, write back; never overwrite a user's settings on a parse error.
1979
- //
1980
- // The tool id is mcp__<server>__<tool>. A .mcpb install registers this server
1981
- // under the manifest name ("social-autoposter"); we also list the protocol name
1982
- // ("S4L") so pre-approval matches however the host loaded it. Extra entries that
1983
- // don't match the live namespace are harmless no-ops.
1984
- const AUTOPILOT_ALLOWED_TOOLS = [
1985
- "mcp__social-autoposter__scan_candidates",
1986
- "mcp__social-autoposter__submit_drafts",
1987
- "mcp__S4L__scan_candidates",
1988
- "mcp__S4L__submit_drafts",
1989
- ];
1990
- function ensureAutopilotToolsAllowed() {
1991
- try {
1992
- // Only touch settings.json for installs that actually have the autopilot
1993
- // scheduled — mirrors the prompt-refresh gate, so we never edit a user's
1994
- // global settings on a machine that doesn't use the scheduled task.
1995
- if (!fs.existsSync(autopilotSkillPath()))
1996
- return;
1997
- const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
1998
- const settingsPath = path.join(cfg, "settings.json");
1999
- let settings = {};
2000
- if (fs.existsSync(settingsPath)) {
2001
- try {
2002
- settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")) || {};
2003
- }
2004
- catch (e) {
2005
- // Malformed user settings: do NOT clobber — log and bail.
2006
- console.error(`[autopilot] settings.json unparseable; skipping tool pre-approval: ${e?.message || e}`);
2007
- return;
2008
- }
2009
- }
2010
- if (typeof settings !== "object" || Array.isArray(settings))
2011
- return;
2012
- const perms = (settings.permissions ??= {});
2013
- if (typeof perms !== "object" || Array.isArray(perms))
2014
- return;
2015
- const allow = Array.isArray(perms.allow) ? perms.allow : (perms.allow = []);
2016
- let added = 0;
2017
- for (const t of AUTOPILOT_ALLOWED_TOOLS) {
2018
- if (!allow.includes(t)) {
2019
- allow.push(t);
2020
- added++;
2021
- }
2022
- }
2023
- if (added === 0)
2024
- return;
2025
- fs.mkdirSync(cfg, { recursive: true });
2026
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2027
- console.error(`[autopilot] pre-approved ${added} autopilot tool(s) in ${settingsPath} (allow-only)`);
2028
- }
2029
- catch (e) {
2030
- console.error(`[autopilot] ensureAutopilotToolsAllowed error: ${e?.message || e}`);
2031
- }
2032
- }
2033
1867
  // ===========================================================================
2034
1868
  // Queue-worker scheduled tasks + launchd kicker (2026-06-23)
2035
1869
  //
2036
- // Replaces the scan_candidates -> host-draft -> submit_drafts autopilot. The
2037
- // REAL pipeline runs in DRAFT_ONLY mode under launchd; its `claude -p` calls go
1870
+ // The single drafting path. The REAL pipeline runs in DRAFT_ONLY mode under
1871
+ // launchd; its `claude -p` calls go
2038
1872
  // through scripts/claude_job.py's file queue (run_claude.sh provider seam); two
2039
1873
  // scheduled tasks drain that queue. Each task is single-purpose (one job type),
2040
1874
  // fires every minute, claims ONE job, runs the pipeline's own prompt as its
@@ -2118,7 +1952,7 @@ function queueWorkerSkillMd(spec) {
2118
1952
  `\n\n<!-- ${QUEUE_WORKER_PROMPT_MARKER}: ${QUEUE_WORKER_PROMPT_VERSION} -->\n`);
2119
1953
  }
2120
1954
  // Refresh each worker task's SKILL.md when this build ships a newer prompt than
2121
- // what's on disk. Mirrors ensureAutopilotPromptCurrent: best-effort, only touches
1955
+ // what's on disk. Best-effort, only touches
2122
1956
  // an EXISTING task (onboarding creates them), only when stale. Also rewrites when
2123
1957
  // the baked-in paths (python/repo) would have changed, since a stale absolute
2124
1958
  // path would break the Bash commands; we detect that by always rewriting on a
@@ -2308,6 +2142,60 @@ async function ensureQueueKickerInstalled() {
2308
2142
  return { ok: false, detail: e?.message || String(e) };
2309
2143
  }
2310
2144
  }
2145
+ // ---- launchd reaper: kill leaked agent-mode claude worker sessions ----------
2146
+ // Independent guardrail (NOT gated on a project being ready): the leak happens
2147
+ // whenever the scheduled-task workers fire, and a no-leak run is a cheap no-op.
2148
+ // Runs the stdlib-only reaper under SYSTEM python (always present, zero deps) so
2149
+ // it works even before the owned runtime provisions. Content-aware install so an
2150
+ // already-installed box picks up a changed interval/path on the next Claude boot.
2151
+ const REAPER_INTERVAL_SECS = 60; // match the ~1/min worker spawn cadence
2152
+ async function ensureClaudeReaperInstalled() {
2153
+ try {
2154
+ if (process.platform !== "darwin")
2155
+ return { ok: false, detail: "not macOS" };
2156
+ const logDir = path.join(repoDir(), "skill", "logs");
2157
+ try {
2158
+ fs.mkdirSync(logDir, { recursive: true });
2159
+ }
2160
+ catch {
2161
+ /* best-effort */
2162
+ }
2163
+ const xml = plistXml({
2164
+ label: REAPER_LABEL,
2165
+ programArgs: ["/usr/bin/python3", path.join(repoDir(), "scripts", "reap_stale_claude_sessions.py")],
2166
+ intervalSecs: REAPER_INTERVAL_SECS,
2167
+ runAtLoad: true, // clean up an existing backlog the instant Claude launches
2168
+ stdoutLog: path.join(logDir, "launchd-claude-reaper-stdout.log"),
2169
+ stderrLog: path.join(logDir, "launchd-claude-reaper-stderr.log"),
2170
+ });
2171
+ const uid = process.getuid ? process.getuid() : 0;
2172
+ let cur = null;
2173
+ try {
2174
+ cur = fs.readFileSync(REAPER_PLIST, "utf-8");
2175
+ }
2176
+ catch {
2177
+ cur = null;
2178
+ }
2179
+ let detail;
2180
+ if (cur === xml) {
2181
+ const res = await loadPlist(REAPER_LABEL, REAPER_PLIST, uid);
2182
+ detail = `current (load rc=${res.code})`;
2183
+ }
2184
+ else {
2185
+ if (cur !== null) {
2186
+ await unloadPlist(REAPER_LABEL, REAPER_PLIST, uid);
2187
+ }
2188
+ fs.mkdirSync(path.dirname(REAPER_PLIST), { recursive: true });
2189
+ fs.writeFileSync(REAPER_PLIST, xml, "utf-8");
2190
+ const res = await loadPlist(REAPER_LABEL, REAPER_PLIST, uid);
2191
+ detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
2192
+ }
2193
+ return { ok: true, detail };
2194
+ }
2195
+ catch (e) {
2196
+ return { ok: false, detail: e?.message || String(e) };
2197
+ }
2198
+ }
2311
2199
  // Assemble everything the panel needs in one shot (projects + X + autopilot +
2312
2200
  // version). Resilient: any probe that throws degrades to a safe default rather
2313
2201
  // than failing the whole snapshot.
@@ -2499,7 +2387,7 @@ function sapsStateDir() {
2499
2387
  // of the in-process `postingActive` flag. The autopilot scan and the post
2500
2388
  // sometimes run in the SAME MCP (the in-process flag covers that) and sometimes
2501
2389
  // in TWO SEPARATE MCP instances (different agent sessions each spawn their own).
2502
- // A file every instance's scan_candidates reads makes the mutual exclusion hold
2390
+ // A file every instance's draft-cycle scan reads makes the mutual exclusion hold
2503
2391
  // regardless of which topology Claude Desktop happens to use. Heartbeat'd with a
2504
2392
  // short TTL so a crashed poster's flag self-clears and never wedges scanning.
2505
2393
  const POSTING_FLAG_TTL_MS = 45_000;
@@ -2542,7 +2430,7 @@ function stopPostingFlagHeartbeat() {
2542
2430
  }
2543
2431
  }
2544
2432
  // True when ANY MCP instance has a FRESH posting flag on disk. Absent or expired
2545
- // == not posting. This is what makes a sibling instance's scan_candidates defer.
2433
+ // == not posting. This is what makes a sibling instance's draft-cycle scan defer.
2546
2434
  function isPostingFlagFresh() {
2547
2435
  try {
2548
2436
  const j = JSON.parse(fs.readFileSync(postingFlagPath(), "utf-8"));
@@ -2607,112 +2495,6 @@ async function openInBrowser(url) {
2607
2495
  console.error("[social-autoposter-mcp] openInBrowser failed:", e?.message || e);
2608
2496
  }
2609
2497
  }
2610
- async function runScanCandidates(project, onProgress) {
2611
- // SCAN_ONLY=1: run scan -> score -> top-N select, then STOP before drafting.
2612
- // No DRAFT_ONLY, no `claude -p` drafting. TWITTER_PHASE1_LLM_DRAFT=0 forces the
2613
- // deterministic query bank so the whole scan is claude-free (the agent does ALL
2614
- // the AI). Off-screen by default (no BH_WINDOW_* / overlay): a scheduled run has
2615
- // no human watching the Chrome.
2616
- const env = {
2617
- SCAN_ONLY: "1",
2618
- TWITTER_PHASE1_LLM_DRAFT: "0",
2619
- SAPS_REPO_DIR: repoDir(),
2620
- PATH: pipelinePath(),
2621
- };
2622
- if (project)
2623
- env.SAPS_FORCE_PROJECT = project;
2624
- const chrome = resolveChrome();
2625
- if (chrome)
2626
- env.BH_CHROME_BIN = chrome;
2627
- let step = 0;
2628
- let lastMsg = "";
2629
- // Specific phase label for the menu bar for the whole multi-minute scan, so the
2630
- // long-poll doesn't leave it on a generic "working" (or flicker to idle between
2631
- // re-calls). Cleared by the handler when the scan resolves.
2632
- writeActivity("scanning", project ? `scanning X for ${project}` : "scanning X");
2633
- // Single-flight: kill any pre-existing run-twitter-cycle.sh (zombies that
2634
- // survived a prior preempt, or stale waiters parked behind a post) before
2635
- // launching, so only ONE scan ever exists. The plist gets this from
2636
- // run-twitter-cycle-singleton.sh; the MCP's direct launch must enforce it here.
2637
- sigkillAllScans();
2638
- const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
2639
- env,
2640
- timeoutMs: 900_000,
2641
- onSpawn: (c) => {
2642
- // Track the PLUGIN's own scan process so an approved post can abort exactly
2643
- // this scan (posting preempts scanning). This is the plugin pipeline only —
2644
- // the plist autopilot's scan is a separate launchd process the MCP server
2645
- // never spawns and has no handle to, so it can never be touched here.
2646
- scanChild = c;
2647
- },
2648
- onLine: (line) => {
2649
- const t = line.replace(/\s+$/, "");
2650
- if (t.trim())
2651
- console.error(`[scan_candidates] ${t}`);
2652
- if (!onProgress)
2653
- return;
2654
- const msg = cycleProgressMessage(t);
2655
- if (msg && msg !== lastMsg) {
2656
- lastMsg = msg;
2657
- onProgress(msg, ++step);
2658
- }
2659
- },
2660
- });
2661
- scanChild = null; // scan finished on its own; nothing to preempt.
2662
- const marker = /SCAN_ONLY_RESULT=(\/\S+\.json)/.exec(res.stdout + "\n" + res.stderr);
2663
- if (marker && marker[1]) {
2664
- try {
2665
- const data = JSON.parse(fs.readFileSync(marker[1], "utf-8"));
2666
- return {
2667
- batchId: data.batch_id ?? null,
2668
- candidates: (data.candidates ?? []),
2669
- };
2670
- }
2671
- catch (e) {
2672
- return {
2673
- batchId: null,
2674
- candidates: [],
2675
- blocked: `Scan finished but its result file was unreadable: ${e?.message || e}`,
2676
- };
2677
- }
2678
- }
2679
- // If query-gen (when enabled) hits a real failure, the cycle still emits
2680
- // DRAFT_ONLY_BLOCKED=<reason>. Surface it rather than "no candidates".
2681
- const blockedMarker = /DRAFT_ONLY_BLOCKED=([a-z0-9_]+)/.exec(res.stdout + "\n" + res.stderr);
2682
- if (blockedMarker && blockedMarker[1]) {
2683
- return { batchId: null, candidates: [], blocked: blockedReasonMessage(blockedMarker[1]) };
2684
- }
2685
- // The pipeline's single-flight guard prints too_many_inflight and exits 0 when
2686
- // ANOTHER scan already holds the twitter-cycle slot (commonly the legacy
2687
- // claude -p autopilot, which we deliberately keep running). That is CONTENTION,
2688
- // not an empty batch — flag it so the caller retries instead of concluding
2689
- // "no candidates" (a clean empty scan returns a batch_id with count:0).
2690
- if (/too_many_inflight/.test(res.stdout + res.stderr)) {
2691
- return {
2692
- batchId: null,
2693
- candidates: [],
2694
- busy: true,
2695
- blocked: "Another scan already holds the pipeline slot (likely the legacy autopilot). Contention, " +
2696
- "not an empty result — retry scan_candidates shortly.",
2697
- };
2698
- }
2699
- return {
2700
- batchId: null,
2701
- candidates: [],
2702
- blocked: `This scan produced no candidates (exit ${res.code}). Usually a cold-start ` +
2703
- `project with no seeded search topics, or nothing fresh on-theme right now. Tail:\n` +
2704
- res.stderr.split("\n").slice(-12).join("\n"),
2705
- };
2706
- }
2707
- // At most one scan runs at a time (mirrors the pipeline's own max=1 lock). Kept
2708
- // across calls so a later poll attaches to the SAME running scan instead of
2709
- // starting a new one.
2710
- let currentScanJob = null;
2711
- // The PLUGIN's currently-spawned scan subprocess (captured via run()'s onSpawn),
2712
- // so an approved post can abort exactly this scan and take the browser
2713
- // immediately. Only ever references a scan the MCP server itself launched — never
2714
- // the plist autopilot's launchd scan.
2715
- let scanChild = null;
2716
2498
  // ---- Cross-process browser-lock bridge (the REAL posting-priority fix) ------
2717
2499
  // The SCANNER (run-twitter-cycle.sh) serializes browser access on a mkdir-based
2718
2500
  // DIRECTORY lock at /tmp/social-autoposter-twitter-browser.lock (skill/lock.sh).
@@ -2725,10 +2507,10 @@ let scanChild = null;
2725
2507
  // churned 118 queries for ~10min (proven live on the remote box 2026-06-23:
2726
2508
  // /tmp lock pid=scanner AND json lock python:poster role=post, simultaneously).
2727
2509
  //
2728
- // preemptScanForPost's old body only killed scanChild a process-LOCAL handle to
2729
- // a scan THIS instance launched. The scan that actually holds the browser is
2730
- // almost always a SIBLING instance's, which we have no ChildProcess for. So we
2731
- // bridge to the lock the scanner truly respects: read its /tmp pid file, and if a
2510
+ // The scan that actually holds the browser is a run-twitter-cycle.sh process
2511
+ // usually a SIBLING (the every-minute launchd cycle), which we have no
2512
+ // ChildProcess for. So we bridge to the lock the scanner truly respects: read
2513
+ // its /tmp pid file, and if a
2732
2514
  // live run-twitter-cycle.sh holds it, signal it cross-process. Then the post
2733
2515
  // HOLDS that same /tmp lock for the whole batch so the every-minute autopilot
2734
2516
  // scan queues behind us (its acquire_lock waits on our live pid) instead of
@@ -2840,7 +2622,7 @@ function sigkillAllScans() {
2840
2622
  const SHELL_LOCK_GRACE_MS = Number(process.env.SAPS_POST_LOCK_GRACE_MS) || 60_000;
2841
2623
  let shellLockReleaseTimer = null;
2842
2624
  // True from the start of a post batch until SHELL_LOCK_GRACE_MS after the last
2843
- // card. scan_candidates checks this and DEFERS launching a scan while it's set —
2625
+ // card. The draft-cycle scan checks this and DEFERS launching a scan while it's set —
2844
2626
  // the real fix: posting and scanning are mutually exclusive at the SOURCE (both
2845
2627
  // are children of THIS one MCP), so we never even launch a scan that would race
2846
2628
  // the post for the browser lock. Having the post fight scans for the lock (the
@@ -2940,324 +2722,20 @@ function releaseShellBrowserLock() {
2940
2722
  }
2941
2723
  }
2942
2724
  // Posting takes priority over scanning. When the user approves a post, abort any
2943
- // in-flight scan so the browser frees up at once. Two layers: (1) kill scanChild
2944
- // if THIS instance launched the scan, and (2) cross-process kill whoever holds
2945
- // the /tmp shell lock if it's a scan (covers sibling MCP instances we have no
2946
- // handle to). Best-effort; never throws; never touches a locked pipeline script.
2725
+ // in-flight scan so the browser frees up at once. The scan that actually holds the
2726
+ // shared Chrome is a live run-twitter-cycle.sh (the every-minute launchd cycle);
2727
+ // kill it cross-process via the /tmp shell lock it truly respects. Best-effort;
2728
+ // never throws; never touches a locked pipeline script.
2947
2729
  function preemptScanForPost() {
2948
- try {
2949
- if (scanChild && scanChild.pid && scanChild.exitCode === null) {
2950
- console.error(`[post] preempting in-flight plugin scan (pid ${scanChild.pid}) so the approved post takes the browser — SIGKILL tree`);
2951
- sigkillScanTree(scanChild.pid); // SIGKILL — scan bash traps SIGTERM
2952
- }
2953
- }
2954
- catch {
2955
- /* best effort */
2956
- }
2957
- // Drop the long-poll job so a queued poller stops waiting on the aborted scan;
2958
- // the next scan_candidates call starts fresh.
2959
- currentScanJob = null;
2960
- scanChild = null;
2961
- // Cross-process: the scan that actually holds the shared Chrome is usually a
2962
- // sibling MCP instance's, not ours. Kill it via the lock it truly respects.
2963
2730
  preemptScanHoldingBrowser();
2964
2731
  }
2965
- // Per-call long-poll window. Must stay under the ~60s MCP client request timeout
2966
- // so every scan_candidates call returns a real response before the client gives
2967
- // up. Override with SAPS_SCAN_POLL_WAIT_MS for non-standard clients.
2968
- const SCAN_POLL_WAIT_MS = Number(process.env.SAPS_SCAN_POLL_WAIT_MS) || 45_000;
2969
- // After a contention hit (too_many_inflight), wait this long before launching a
2970
- // fresh scan, so polls don't hammer the pipeline with back-to-back no-op scans
2971
- // while the other holder finishes. Polls during the cooldown just report "busy".
2972
- const SCAN_BUSY_COOLDOWN_MS = Number(process.env.SAPS_SCAN_BUSY_COOLDOWN_MS) || 20_000;
2973
- let scanBusyUntil = 0;
2974
- function scanBusy() {
2975
- return textContent("Another scan is already running on this machine (likely the legacy autopilot holding the " +
2976
- "twitter-cycle slot). This is CONTENTION, not an empty result. Call scan_candidates again in " +
2977
- "~20s to retry — do NOT conclude there are no candidates, and do NOT sleep; just re-call.");
2978
- }
2979
- // A post batch is in progress: scanning is deferred so posting has exclusive use
2980
- // of the one shared browser (mutual exclusion at the source). Not an error.
2981
- function scanDeferredForPost() {
2982
- return textContent("A post is in progress on this machine, so scanning is deferred — only one task drives X at a " +
2983
- "time and posting has priority. This is EXPECTED, not an error or an empty result. Call " +
2984
- "scan_candidates again shortly; it runs as soon as the current post batch finishes. Do NOT " +
2985
- "sleep or conclude there are no candidates; just re-call.");
2986
- }
2987
- // Resolve to {done:true,value} the moment `p` settles, or {done:false} after
2988
- // `ms` — whichever is first. The job promise keeps running either way, so a
2989
- // later poll re-attaches to it.
2990
- function waitUpTo(p, ms) {
2991
- return Promise.race([
2992
- p.then((value) => ({ done: true, value })),
2993
- new Promise((resolve) => setTimeout(() => resolve({ done: false }), ms)),
2994
- ]);
2995
- }
2996
- function startScanJob(project) {
2997
- const job = {
2998
- project,
2999
- startedAt: Date.now(),
3000
- done: false,
3001
- promise: undefined,
3002
- };
3003
- job.promise = runScanCandidates(project)
3004
- .then((r) => {
3005
- job.result = r;
3006
- job.done = true;
3007
- return r;
3008
- })
3009
- .catch((e) => {
3010
- const r = {
3011
- batchId: null,
3012
- candidates: [],
3013
- blocked: `The scan crashed before producing a result: ${String(e?.message || e)}. ` +
3014
- `Call scan_candidates again to retry.`,
3015
- };
3016
- job.result = r;
3017
- job.done = true;
3018
- return r;
3019
- });
3020
- return job;
3021
- }
3022
- // Read the on-brand drafting guidance for the given projects from config.json so
3023
- // scan_candidates can hand it to the agent INLINE. Without this the autopilot run
3024
- // has to read the config itself (project_config returns status, not the voice
3025
- // fields) — and a headless run improvises Bash/Read for that, hitting an
3026
- // un-approved tool and hanging. Returning the voice here removes the need to read
3027
- // any file. Best-effort: a missing/unreadable config yields {} and the agent
3028
- // drafts from thread context, same as before.
3029
- function readProjectVoices(projectNames) {
3030
- const out = {};
3031
- const want = new Set(projectNames.filter(Boolean));
3032
- if (!want.size)
3033
- return out;
3034
- try {
3035
- const cfg = JSON.parse(fs.readFileSync(path.join(repoDir(), "config.json"), "utf-8"));
3036
- const projects = Array.isArray(cfg?.projects) ? cfg.projects : [];
3037
- for (const p of projects) {
3038
- if (!p?.name || !want.has(p.name))
3039
- continue;
3040
- // Only the fields needed to draft on-brand — keep it lean.
3041
- out[p.name] = {
3042
- voice: p.voice,
3043
- voice_relationship: p.voice_relationship,
3044
- description: p.description,
3045
- differentiator: p.differentiator,
3046
- content_guardrails: p.content_guardrails,
3047
- website: p.website,
3048
- get_started_link: p.get_started_link,
3049
- };
3050
- }
3051
- }
3052
- catch {
3053
- /* best effort — no inline voice, agent falls back to thread context */
3054
- }
3055
- return out;
3056
- }
3057
- function formatScanResult(scan) {
3058
- if (!scan.batchId) {
3059
- return textContent(scan.blocked || "No candidates found.");
3060
- }
3061
- // The brand voice for every project in this batch, INLINE, so the agent never
3062
- // has to read a config file (that improvisation is what hangs headless runs).
3063
- const projects = Array.from(new Set(scan.candidates.map((c) => c.matched_project).filter(Boolean)));
3064
- const project_voices = readProjectVoices(projects);
3065
- return jsonContent({
3066
- batch_id: scan.batchId,
3067
- count: scan.candidates.length,
3068
- candidates: scan.candidates,
3069
- project_voices,
3070
- next_step: `Draft an on-brand reply (<=250 chars, match the thread's language) for each candidate you ` +
3071
- `judge genuinely worth engaging; skip the rest. The brand voice + guardrails for each ` +
3072
- `candidate's project are in project_voices[<matched_project>] (voice, description, ` +
3073
- `differentiator, content_guardrails) — draft from THAT. Do NOT read config files or call any ` +
3074
- `other tool to find the voice; everything you need is in this result. Then call submit_drafts ` +
3075
- `with batch_id "${scan.batchId}" and one entry per drafted reply ({candidate_id, reply_text}). ` +
3076
- `Nothing posts until the user approves.`,
3077
- });
3078
- }
3079
- function scanInProgress(job) {
3080
- const elapsed = Math.round((Date.now() - job.startedAt) / 1000);
3081
- return textContent(`Scan in progress (${elapsed}s elapsed). The X scan drives a real browser and usually takes ` +
3082
- `1-3 minutes. Call scan_candidates again RIGHT NOW to keep waiting — each call long-polls up ` +
3083
- `to ~${Math.round(SCAN_POLL_WAIT_MS / 1000)}s and returns the candidates the moment the scan ` +
3084
- `finishes. Do NOT sleep, run a background command, or do other work to bridge the gap; just ` +
3085
- `re-call scan_candidates until it returns candidates or reports no candidates.`);
3086
- }
3087
- tool("scan_candidates", {
3088
- title: "Scan X for reply candidates (no drafting, no posting)",
3089
- description: "Step 1 of a hands-free / scheduled autopilot run. Runs the scan+score half of the pipeline " +
3090
- "and returns the top scored X/Twitter threads worth replying to — WITHOUT drafting or posting, " +
3091
- "and without spending any `claude -p` budget. You (this session) then draft an on-brand reply " +
3092
- "for each good candidate YOURSELF and submit them with `submit_drafts`. Each candidate includes " +
3093
- "its candidate_id (pass it back), the thread text/author, the matched project, and engagement " +
3094
- "metrics. The result ALSO includes `project_voices` — the brand voice + guardrails for each " +
3095
- "project in the batch — so you draft on-brand WITHOUT reading config files or calling other " +
3096
- "tools (do not improvise a config read). Optional `project` scopes the scan to one configured " +
3097
- "project. The scan drives a real " +
3098
- "browser and can take 1-3 minutes: this call long-polls and may return a `Scan in progress` " +
3099
- "status instead of candidates — if so, just call scan_candidates again (same args) and keep " +
3100
- "re-calling until it returns candidates. Never sleep or use a background wait to bridge the gap.",
3101
- inputSchema: { project: z.string().optional() },
3102
- }, async (args) => {
3103
- // MUTUAL EXCLUSION (the real hijack fix): while a post batch is in progress
3104
- // (or its grace-hold is active), DEFER scanning entirely rather than launching
3105
- // a scan that races the post for the one shared browser. Both the scan and the
3106
- // post are children of THIS MCP, so this in-process gate is exact — the
3107
- // autopilot never even starts a scan to interrupt a post. Posting always wins;
3108
- // the autopilot just re-calls and runs once the batch drains.
3109
- if (postingActive || isPostingFlagFresh())
3110
- return scanDeferredForPost();
3111
- // Long-poll the single in-flight scan job (see the ScanJob registry above).
3112
- // A finished-but-unconsumed job: hand back its result and clear the slot so a
3113
- // later call starts a fresh scan.
3114
- if (currentScanJob?.done) {
3115
- const finished = currentScanJob;
3116
- currentScanJob = null;
3117
- if (finished.result?.busy) {
3118
- scanBusyUntil = Date.now() + SCAN_BUSY_COOLDOWN_MS;
3119
- return scanBusy();
3120
- }
3121
- return formatScanResult(finished.result);
3122
- }
3123
- // Contention cooldown: another scan holds the slot — report busy without
3124
- // launching yet another no-op scan against the locked pipeline.
3125
- if (!currentScanJob && Date.now() < scanBusyUntil) {
3126
- return scanBusy();
3127
- }
3128
- // Nothing running -> start one scan (single-flight: never spawn a second
3129
- // run-twitter-cycle.sh that would just hit the pipeline's too_many_inflight).
3130
- if (!currentScanJob) {
3131
- currentScanJob = startScanJob(args?.project);
3132
- }
3133
- // Wait for the in-flight scan, but no longer than the client timeout allows.
3134
- const waited = await waitUpTo(currentScanJob.promise, SCAN_POLL_WAIT_MS);
3135
- if (waited.done) {
3136
- currentScanJob = null;
3137
- if (waited.value.busy) {
3138
- scanBusyUntil = Date.now() + SCAN_BUSY_COOLDOWN_MS;
3139
- return scanBusy();
3140
- }
3141
- return formatScanResult(waited.value);
3142
- }
3143
- // Still scanning — tell the agent to simply call scan_candidates again.
3144
- return scanInProgress(currentScanJob);
3145
- });
3146
- tool("submit_drafts", {
3147
- title: "Submit drafted replies for review",
3148
- description: "Step 2 of a hands-free / scheduled autopilot run. Hand back the replies you drafted for the " +
3149
- "candidates returned by scan_candidates. Writes them into the SAME review plan the menu-bar " +
3150
- "approval UI and post_drafts already use — nothing is posted until the user approves. Provide " +
3151
- "batch_id (from scan_candidates) and a `drafts` array; each entry needs candidate_id and " +
3152
- "reply_text (engagement_style, language, link_keyword optional).",
3153
- inputSchema: {
3154
- batch_id: z.string(),
3155
- drafts: z
3156
- .array(z.object({
3157
- candidate_id: z.union([z.string(), z.number()]),
3158
- reply_text: z.string(),
3159
- engagement_style: z.string().optional(),
3160
- language: z.string().optional(),
3161
- link_keyword: z.string().optional(),
3162
- }))
3163
- .min(1),
3164
- },
3165
- }, async (args) => {
3166
- // Reload the scan candidates for thread metadata (url / author / text /
3167
- // project / topic). If the scan file is gone (e.g. /tmp cleared), we still
3168
- // build a plan from the drafts alone; the review cards just lack context.
3169
- const scanById = new Map();
3170
- try {
3171
- const raw = JSON.parse(fs.readFileSync(scanResultPath(args.batch_id), "utf-8"));
3172
- for (const c of (raw.candidates ?? [])) {
3173
- scanById.set(String(c.id), c);
3174
- }
3175
- }
3176
- catch {
3177
- /* scan file missing — proceed with draft-only context */
3178
- }
3179
- const candidates = args.drafts.map((d) => {
3180
- const sc = scanById.get(String(d.candidate_id));
3181
- return {
3182
- candidate_id: d.candidate_id,
3183
- candidate_url: sc?.tweet_url,
3184
- thread_author: sc?.author_handle,
3185
- thread_text: sc?.tweet_text,
3186
- reply_text: d.reply_text,
3187
- engagement_style: d.engagement_style,
3188
- language: d.language,
3189
- link_keyword: d.link_keyword,
3190
- search_topic: sc?.search_topic,
3191
- matched_project: sc?.matched_project,
3192
- };
3193
- });
3194
- const firstSc = scanById.get(String(args.drafts[0].candidate_id));
3195
- const project = (candidates.map((c) => c.matched_project).find((p) => !!p) ||
3196
- firstSc?.matched_project ||
3197
- "default");
3198
- // Stage the new drafts under the scan batch id and bake link targets into them
3199
- // (sub-second at TWITTER_PAGE_GEN_RATE=0). Best-effort: posting falls back to the
3200
- // plain project URL per-candidate if gen is skipped.
3201
- writePlan(args.batch_id, { candidates });
3202
- try {
3203
- await runPython("scripts/twitter_gen_links.py", ["--plan", planPath(args.batch_id)], {
3204
- timeoutMs: 120_000,
3205
- env: { TWITTER_PAGE_GEN_RATE: "0", SAPS_REPO_DIR: repoDir(), PATH: pipelinePath() },
3206
- });
3207
- }
3208
- catch {
3209
- /* best effort — plan still posts with a plain-URL fallback */
3210
- }
3211
- const staged = readPlan(args.batch_id)?.candidates ?? candidates;
3212
- // Accumulate into ONE persistent review queue so a continuous autopilot's drafts
3213
- // PILE UP in the menu-bar cards instead of each run overwriting the last. New
3214
- // drafts are appended; a thread already in the queue (by URL) is skipped (one
3215
- // draft per thread). Posted entries are KEPT in place so the 1-based card
3216
- // numbering stays stable across runs — the menu bar, the chat table, and
3217
- // post_drafts all index the full array and filter finished rows.
3218
- const queue = [
3219
- ...(readPlan(REVIEW_QUEUE_ID)?.candidates ?? []),
3220
- ];
3221
- const seen = new Set(queue.map((c) => c.candidate_url).filter((u) => !!u));
3222
- let added = 0;
3223
- for (const nc of staged) {
3224
- if (nc.candidate_url && seen.has(nc.candidate_url))
3225
- continue;
3226
- queue.push(nc);
3227
- if (nc.candidate_url)
3228
- seen.add(nc.candidate_url);
3229
- added++;
3230
- }
3231
- writePlan(REVIEW_QUEUE_ID, { candidates: queue });
3232
- // Pending = NOT YET DECIDED. A card that's posted, terminal (rejected/dead), OR
3233
- // already approved is a settled decision and must never be re-presented for
3234
- // review — approved ones just proceed to post (see drainApprovedBacklog).
3235
- const pending = queue.filter((c) => c.posted !== true && c.terminal !== true && c.approved !== true);
3236
- // Drafts queued = the pipeline verified end-to-end without posting. This is the
3237
- // onboarding "draft_verified" terminal goal (formerly completed by draft_cycle).
3238
- if (added > 0)
3239
- completeOnboardingMilestone("draft_verified", { outcome: "review_batch", draft_count: added });
3240
- // Point the menu-bar review cards at the accumulated queue.
3241
- writeReviewRequest({
3242
- batch_id: REVIEW_QUEUE_ID,
3243
- project,
3244
- count: pending.length,
3245
- plan_path: planPath(REVIEW_QUEUE_ID),
3246
- created_at: new Date().toISOString(),
3247
- });
3248
- return textContent(`Queued ${added} new draft(s); ${pending.length} now awaiting approval in the menu-bar cards ` +
3249
- `(review queue "${REVIEW_QUEUE_ID}"). Nothing posts until approved.\n\n` +
3250
- renderDraftsTable({ candidates: queue }) +
3251
- `\n\nTo post: the user approves in the menu bar, or call post_drafts with batch_id ` +
3252
- `"${REVIEW_QUEUE_ID}" and the numbers to post.`);
3253
- });
3254
2732
  appTool("dashboard", {
3255
2733
  title: "S4L dashboard",
3256
2734
  description: "Render the S4L dashboard in chat: a visual surface showing project setup, X " +
3257
2735
  "connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, connect X, " +
3258
2736
  "and refresh. Use when the user asks to see the dashboard, panel, " +
3259
2737
  "status, or controls. ALSO call this at the end of any state-changing or results-producing " +
3260
- "action (scan_candidates, submit_drafts, post_drafts, get_stats) so the user sees the " +
2738
+ "action (run_draft_cycle, post_drafts, get_stats) so the user sees the " +
3261
2739
  "updated dashboard. Hosts without UI support get the same data as text.",
3262
2740
  inputSchema: {},
3263
2741
  // fallback_url is set only when the host can't render the ui:// resource and
@@ -3483,15 +2961,6 @@ async function main() {
3483
2961
  if (ensureRuntimeProvisioned()) {
3484
2962
  console.error("[social-autoposter-mcp] owned runtime not ready; provisioning on boot");
3485
2963
  }
3486
- // Same problem for the scheduled task's prompt: the host owns its SKILL.md and
3487
- // a plugin update never touches it. Rewrite it on boot when this build ships a
3488
- // newer prompt version, so behavior changes (no-improvise / voice-inline /
3489
- // stop-cleanly) reach an already-scheduled task on its next fire. Best-effort.
3490
- ensureAutopilotPromptCurrent();
3491
- // Pre-approve THIS server's tools in ~/.claude/settings.json so the unattended
3492
- // scan -> submit path never stalls on an "Ask mode" permission prompt (see the
3493
- // helper's note). Best-effort, allow-only, gated on the task existing.
3494
- ensureAutopilotToolsAllowed();
3495
2964
  // Queue-backed drafting (2026-06-23): keep the two worker-task prompts current,
3496
2965
  // pre-approve their tools EAGERLY (before onboarding even creates the tasks, so
3497
2966
  // the first unattended fire can't stall), and (re)install the launchd kicker
@@ -3502,6 +2971,13 @@ async function main() {
3502
2971
  void ensureQueueKickerInstalled()
3503
2972
  .then((r) => console.error(`[queue-worker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
3504
2973
  .catch((e) => console.error("[queue-worker] kicker install failed:", e?.message || e));
2974
+ // Self-healing reaper for the agent-mode session leak the queue autopilot
2975
+ // produces (finished `claude` worker sessions Desktop never tears down). A
2976
+ // standalone guardrail; install unconditionally so it caps memory even on a
2977
+ // box whose project isn't ready yet. Best-effort; must never block boot.
2978
+ void ensureClaudeReaperInstalled()
2979
+ .then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
2980
+ .catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
3505
2981
  // Heal installs onboarded before short_links_live defaulted to false: such a
3506
2982
  // project wraps short links against the customer's own domain, which has no
3507
2983
  // /r/[code] resolver, so every minted link 404s. Re-point them at the s4l.ai