social-autoposter 1.6.88 → 1.6.89

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
@@ -11,6 +11,7 @@
11
11
  // stays in the Python/shell scripts; we only orchestrate and present.
12
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { execFileSync } from "node:child_process";
14
15
  import { z } from "zod";
15
16
  import { screencast, bringBrowserToFront } from "./screencast.js";
16
17
  import os from "node:os";
@@ -539,6 +540,10 @@ async function postApproved(batchId, plan) {
539
540
  // approved post takes the browser immediately instead of waiting on the lock.
540
541
  // Plugin pipeline only — never affects the plist autopilot.
541
542
  preemptScanForPost();
543
+ // Hold the /tmp shell browser lock (the one the scanner respects) for the WHOLE
544
+ // batch so the every-minute autopilot scan queues behind the post instead of
545
+ // seizing Chrome mid-batch — the root cause of approved batches landing 0/N.
546
+ const heldShellLock = await acquireShellBrowserLock();
542
547
  const approvedBatch = `${batchId}_approved`;
543
548
  writePlan(approvedBatch, { ...plan, candidates: approved });
544
549
  // SAPS_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
@@ -546,28 +551,51 @@ async function postApproved(batchId, plan) {
546
551
  // twitter_browser.py's reply handler reads this env (inherited through
547
552
  // twitter_post_plan.py's subprocess). The cron pipeline doesn't set it, so the
548
553
  // A/B disclosure experiment keeps running on autopilot/cron and on Reddit.
549
- const res = await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
550
- timeoutMs: 900_000,
551
- env: {
552
- SAPS_SKIP_CAMPAIGN_SUFFIX: "1",
553
- // Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
554
- // runs TWITTER_TAIL_LINK_RATE=0.9 (from .env) so ~10% of autopilot posts
555
- // ship link-less as an experiment arm. But when the user hand-reviews a
556
- // draft, sees the link target in the table, and approves it, dropping the
557
- // link is surprising and unwanted. Force 1.0 here so every approved draft
558
- // carries its link. This wins over .env / process.env because run() spreads
559
- // opts.env AFTER process.env, and twitter_post_plan.py never load_dotenv's
560
- // with override, so nothing clobbers it. Cron is untouched (it never goes
561
- // through this MCP path), so the 0.9 experiment keeps running there.
562
- TWITTER_TAIL_LINK_RATE: "1.0",
563
- // The poster attaches to the twitter-harness Chrome over CDP. The cron
564
- // pipeline exports this from skill/lib/twitter-backend.sh; the MCP path
565
- // must set it explicitly or twitter_browser.py fails with "No twitter-
566
- // harness Chrome reachable". Honor an inherited value (AppMaker / VM
567
- // BYO-Chrome), else default to the local harness on port 9555.
568
- TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
569
- },
570
- });
554
+ const res = await (async () => {
555
+ try {
556
+ return await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
557
+ timeoutMs: 900_000,
558
+ env: {
559
+ SAPS_SKIP_CAMPAIGN_SUFFIX: "1",
560
+ // Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
561
+ // runs TWITTER_TAIL_LINK_RATE=0.9 (from .env) so ~10% of autopilot posts
562
+ // ship link-less as an experiment arm. But when the user hand-reviews a
563
+ // draft, sees the link target in the table, and approves it, dropping the
564
+ // link is surprising and unwanted. Force 1.0 here so every approved draft
565
+ // carries its link. This wins over .env / process.env because run() spreads
566
+ // opts.env AFTER process.env, and twitter_post_plan.py never load_dotenv's
567
+ // with override, so nothing clobbers it. Cron is untouched (it never goes
568
+ // through this MCP path), so the 0.9 experiment keeps running there.
569
+ TWITTER_TAIL_LINK_RATE: "1.0",
570
+ // The poster attaches to the twitter-harness Chrome over CDP. The cron
571
+ // pipeline exports this from skill/lib/twitter-backend.sh; the MCP path
572
+ // must set it explicitly or twitter_browser.py fails with "No twitter-
573
+ // harness Chrome reachable". Honor an inherited value (AppMaker / VM
574
+ // BYO-Chrome), else default to the local harness on port 9555.
575
+ TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
576
+ },
577
+ });
578
+ }
579
+ finally {
580
+ // Always free the lock so a queued scan can proceed, even on throw/timeout.
581
+ if (heldShellLock)
582
+ releaseShellBrowserLock();
583
+ }
584
+ })();
585
+ // Persist the poster's own stdout/stderr to a dated log. Without this the post
586
+ // run was invisible: twitter_post_plan.py's output streamed to this MCP
587
+ // instance's stderr and was never tee'd anywhere on disk, so a 0/N batch left
588
+ // no on-box trace to debug. Best-effort; never breaks posting.
589
+ try {
590
+ const postLogDir = path.join(repoDir(), "skill", "logs");
591
+ fs.mkdirSync(postLogDir, { recursive: true });
592
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
593
+ fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${approved.length} exit=${res.code} ` +
594
+ `shell_lock=${heldShellLock}\n\n=== stdout ===\n${res.stdout}\n\n=== stderr ===\n${res.stderr}\n`);
595
+ }
596
+ catch {
597
+ /* best effort */
598
+ }
571
599
  let summary = res.stdout.trim();
572
600
  try {
573
601
  const lines = res.stdout.trim().split("\n");
@@ -1968,13 +1996,154 @@ let currentScanJob = null;
1968
1996
  // immediately. Only ever references a scan the MCP server itself launched — never
1969
1997
  // the plist autopilot's launchd scan.
1970
1998
  let scanChild = null;
1971
- // Posting takes priority over scanning (plugin pipeline only). When the user
1972
- // approves a post, abort any in-flight plugin scan so the browser frees up at
1973
- // once: killing the scan's bash leaves a dead lock-holder pid that the poster's
1974
- // twitter_browser.py steals via its liveness check, so the post acquires the
1975
- // browser without the 45s lock wait. The aborted scan just re-runs next cron
1976
- // tick. Best-effort; never throws. Does NOT touch the plist autopilot (separate
1977
- // process, no handle here) or any locked pipeline script.
1999
+ // ---- Cross-process browser-lock bridge (the REAL posting-priority fix) ------
2000
+ // The SCANNER (run-twitter-cycle.sh) serializes browser access on a mkdir-based
2001
+ // DIRECTORY lock at /tmp/social-autoposter-twitter-browser.lock (skill/lock.sh).
2002
+ // The POSTER (twitter_post_plan.py / twitter_browser.py) serializes on a totally
2003
+ // SEPARATE json file lock (~/.claude/twitter-browser-lock.json) with role:"post"
2004
+ // preemption. The two locks never reference each other, so a post launched from
2005
+ // THIS MCP (or a sibling MCP instance every autopilot agent session spawns its
2006
+ // own) never actually excluded a live scan: both held "their" lock and drove the
2007
+ // one shared harness Chrome at once, so an approved batch landed 0/N while a scan
2008
+ // churned 118 queries for ~10min (proven live on the remote box 2026-06-23:
2009
+ // /tmp lock pid=scanner AND json lock python:poster role=post, simultaneously).
2010
+ //
2011
+ // preemptScanForPost's old body only killed scanChild — a process-LOCAL handle to
2012
+ // a scan THIS instance launched. The scan that actually holds the browser is
2013
+ // almost always a SIBLING instance's, which we have no ChildProcess for. So we
2014
+ // bridge to the lock the scanner truly respects: read its /tmp pid file, and if a
2015
+ // live run-twitter-cycle.sh holds it, signal it cross-process. Then the post
2016
+ // HOLDS that same /tmp lock for the whole batch so the every-minute autopilot
2017
+ // scan queues behind us (its acquire_lock waits on our live pid) instead of
2018
+ // seizing Chrome mid-post. skill/lock.sh's ownership guard + kill-0 liveness +
2019
+ // 3h stale-reclaim recover the dir if we ever leak it. Never touches a locked
2020
+ // pipeline script or the python json lock.
2021
+ const TW_BROWSER_LOCK_DIR = "/tmp/social-autoposter-twitter-browser.lock";
2022
+ function shellLockHolderPid() {
2023
+ try {
2024
+ const pid = parseInt(fs.readFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), "utf-8").trim(), 10);
2025
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
2026
+ }
2027
+ catch {
2028
+ return null; // no dir / no pid file == lock is free
2029
+ }
2030
+ }
2031
+ function pidAlive(pid) {
2032
+ try {
2033
+ process.kill(pid, 0);
2034
+ return true;
2035
+ }
2036
+ catch {
2037
+ return false;
2038
+ }
2039
+ }
2040
+ // True ONLY when pid is a run-twitter-cycle.sh scan — the one holder a post is
2041
+ // allowed to preempt. Never preempt another poster or an unknown holder.
2042
+ function pidIsScan(pid) {
2043
+ try {
2044
+ const cmd = execFileSync("ps", ["-o", "command=", "-p", String(pid)], {
2045
+ encoding: "utf-8",
2046
+ timeout: 4000,
2047
+ });
2048
+ return /run-twitter-cycle\.sh/.test(cmd);
2049
+ }
2050
+ catch {
2051
+ return false;
2052
+ }
2053
+ }
2054
+ function rmShellLockDir() {
2055
+ try {
2056
+ fs.rmSync(TW_BROWSER_LOCK_DIR, { recursive: true, force: true });
2057
+ }
2058
+ catch {
2059
+ /* best effort */
2060
+ }
2061
+ }
2062
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
2063
+ // SIGTERM a live scan holding the shell browser lock so the post takes the
2064
+ // browser at once. Best-effort; only ever targets a run-twitter-cycle.sh.
2065
+ function preemptScanHoldingBrowser() {
2066
+ try {
2067
+ const pid = shellLockHolderPid();
2068
+ if (pid && pidAlive(pid) && pidIsScan(pid)) {
2069
+ console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid})`);
2070
+ try {
2071
+ process.kill(pid, "SIGTERM");
2072
+ }
2073
+ catch {
2074
+ /* already gone */
2075
+ }
2076
+ }
2077
+ }
2078
+ catch {
2079
+ /* best effort */
2080
+ }
2081
+ }
2082
+ // Take the shell browser lock for the batch. Preempts a scan holder; never
2083
+ // steals from a live non-scan holder (a peer poster) — there it returns false
2084
+ // and posting proceeds unguarded (no worse than before). Best-effort.
2085
+ async function acquireShellBrowserLock() {
2086
+ for (let attempt = 0; attempt < 8; attempt++) {
2087
+ try {
2088
+ fs.mkdirSync(TW_BROWSER_LOCK_DIR); // atomic mutex — only one winner
2089
+ try {
2090
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
2091
+ }
2092
+ catch {
2093
+ /* best effort */
2094
+ }
2095
+ try {
2096
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
2097
+ }
2098
+ catch {
2099
+ /* best effort */
2100
+ }
2101
+ console.error(`[post] holding twitter-browser shell lock pid=${process.pid} — scans queue behind the post`);
2102
+ return true;
2103
+ }
2104
+ catch {
2105
+ // Dir exists. Reclaim if the holder is dead; preempt if it's a scan;
2106
+ // otherwise (a live peer poster) leave it and post unguarded.
2107
+ const pid = shellLockHolderPid();
2108
+ if (!pid || !pidAlive(pid)) {
2109
+ rmShellLockDir();
2110
+ }
2111
+ else if (pidIsScan(pid)) {
2112
+ try {
2113
+ process.kill(pid, "SIGTERM");
2114
+ }
2115
+ catch {
2116
+ /* already gone */
2117
+ }
2118
+ await sleepMs(400);
2119
+ rmShellLockDir();
2120
+ }
2121
+ else {
2122
+ return false; // a real peer holds it — don't steal; proceed
2123
+ }
2124
+ await sleepMs(250);
2125
+ }
2126
+ }
2127
+ return false;
2128
+ }
2129
+ // Release only if it's still OURS (mirror skill/lock.sh's ownership guard) so we
2130
+ // never wipe a scan that legitimately re-acquired after the batch finished.
2131
+ function releaseShellBrowserLock() {
2132
+ try {
2133
+ if (shellLockHolderPid() === process.pid) {
2134
+ rmShellLockDir();
2135
+ console.error(`[post] released twitter-browser shell lock pid=${process.pid}`);
2136
+ }
2137
+ }
2138
+ catch {
2139
+ /* best effort */
2140
+ }
2141
+ }
2142
+ // Posting takes priority over scanning. When the user approves a post, abort any
2143
+ // in-flight scan so the browser frees up at once. Two layers: (1) kill scanChild
2144
+ // if THIS instance launched the scan, and (2) cross-process — kill whoever holds
2145
+ // the /tmp shell lock if it's a scan (covers sibling MCP instances we have no
2146
+ // handle to). Best-effort; never throws; never touches a locked pipeline script.
1978
2147
  function preemptScanForPost() {
1979
2148
  try {
1980
2149
  if (scanChild && scanChild.pid && scanChild.exitCode === null) {
@@ -1994,6 +2163,9 @@ function preemptScanForPost() {
1994
2163
  // the next scan_candidates call starts fresh.
1995
2164
  currentScanJob = null;
1996
2165
  scanChild = null;
2166
+ // Cross-process: the scan that actually holds the shared Chrome is usually a
2167
+ // sibling MCP instance's, not ours. Kill it via the lock it truly respects.
2168
+ preemptScanHoldingBrowser();
1997
2169
  }
1998
2170
  // Per-call long-poll window. Must stay under the ~60s MCP client request timeout
1999
2171
  // so every scan_candidates call returns a real response before the client gives
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.88",
3
- "installedAt": "2026-06-23T18:40:27.430Z"
2
+ "version": "1.6.89",
3
+ "installedAt": "2026-06-23T18:52:53.457Z"
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": "S4L",
5
- "version": "1.6.88",
5
+ "version": "1.6.89",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the S4L pipeline.",
7
7
  "long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Click **Configure** and set every tool permission to **Always Allow**.\n2. Copy this prompt: **Set me up on S4L end to end**.\n3. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
8
8
  "author": {
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.88",
3
+ "version": "1.6.89",
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
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.88",
3
+ "version": "1.6.89",
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"