social-autoposter 1.6.87 → 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/bin/cli.js CHANGED
@@ -591,10 +591,9 @@ function generatePlists() {
591
591
  stderrLog: `${DEST}/skill/logs/launchd-self-update-stderr.log`,
592
592
  },
593
593
  {
594
- // On-screen overlay watcher supervisor. The overlay (harness status banner;
595
- // the interactive draft sidebar is OFF by default as of 2026-06-23, opt back
596
- // in with SAPS_SIDEBAR=1) only renders WHILE harness_overlay.py watch runs.
597
- // The supervisor is idempotent (pgrep guard), so a 60s StartInterval
594
+ // On-screen overlay watcher supervisor. The overlay (harness status banner)
595
+ // only renders WHILE harness_overlay.py watch runs. The supervisor is
596
+ // idempotent (pgrep guard), so a 60s StartInterval
598
597
  // is a no-op while the watcher is up and re-spawns it within a minute if it
599
598
  // ever dies. RunAtLoad starts it right after install. This is what makes the
600
599
  // overlay appear on headless / remote installs (Lane A); the MCP covers the
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";
@@ -346,9 +347,8 @@ function cycleProgressMessage(line) {
346
347
  return null;
347
348
  }
348
349
  // Start the twitter-harness on-screen overlay watcher if it isn't already up.
349
- // The overlay (status banner; the interactive draft sidebar is OFF by default as
350
- // of 2026-06-23, opt back in with SAPS_SIDEBAR=1) only renders WHILE
351
- // `harness_overlay.py watch` runs. The supervisor script is idempotent (pgrep
350
+ // The overlay (status banner) only renders WHILE `harness_overlay.py watch`
351
+ // runs. The supervisor script is idempotent (pgrep
352
352
  // guard), so calling this on every draft_cycle / autopilot-enable / show-browser
353
353
  // is safe: it spawns at most one detached watcher and is a fast no-op otherwise.
354
354
  //
@@ -540,6 +540,10 @@ async function postApproved(batchId, plan) {
540
540
  // approved post takes the browser immediately instead of waiting on the lock.
541
541
  // Plugin pipeline only — never affects the plist autopilot.
542
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();
543
547
  const approvedBatch = `${batchId}_approved`;
544
548
  writePlan(approvedBatch, { ...plan, candidates: approved });
545
549
  // SAPS_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
@@ -547,28 +551,51 @@ async function postApproved(batchId, plan) {
547
551
  // twitter_browser.py's reply handler reads this env (inherited through
548
552
  // twitter_post_plan.py's subprocess). The cron pipeline doesn't set it, so the
549
553
  // A/B disclosure experiment keeps running on autopilot/cron and on Reddit.
550
- const res = await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
551
- timeoutMs: 900_000,
552
- env: {
553
- SAPS_SKIP_CAMPAIGN_SUFFIX: "1",
554
- // Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
555
- // runs TWITTER_TAIL_LINK_RATE=0.9 (from .env) so ~10% of autopilot posts
556
- // ship link-less as an experiment arm. But when the user hand-reviews a
557
- // draft, sees the link target in the table, and approves it, dropping the
558
- // link is surprising and unwanted. Force 1.0 here so every approved draft
559
- // carries its link. This wins over .env / process.env because run() spreads
560
- // opts.env AFTER process.env, and twitter_post_plan.py never load_dotenv's
561
- // with override, so nothing clobbers it. Cron is untouched (it never goes
562
- // through this MCP path), so the 0.9 experiment keeps running there.
563
- TWITTER_TAIL_LINK_RATE: "1.0",
564
- // The poster attaches to the twitter-harness Chrome over CDP. The cron
565
- // pipeline exports this from skill/lib/twitter-backend.sh; the MCP path
566
- // must set it explicitly or twitter_browser.py fails with "No twitter-
567
- // harness Chrome reachable". Honor an inherited value (AppMaker / VM
568
- // BYO-Chrome), else default to the local harness on port 9555.
569
- TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
570
- },
571
- });
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
+ }
572
599
  let summary = res.stdout.trim();
573
600
  try {
574
601
  const lines = res.stdout.trim().split("\n");
@@ -1969,13 +1996,154 @@ let currentScanJob = null;
1969
1996
  // immediately. Only ever references a scan the MCP server itself launched — never
1970
1997
  // the plist autopilot's launchd scan.
1971
1998
  let scanChild = null;
1972
- // Posting takes priority over scanning (plugin pipeline only). When the user
1973
- // approves a post, abort any in-flight plugin scan so the browser frees up at
1974
- // once: killing the scan's bash leaves a dead lock-holder pid that the poster's
1975
- // twitter_browser.py steals via its liveness check, so the post acquires the
1976
- // browser without the 45s lock wait. The aborted scan just re-runs next cron
1977
- // tick. Best-effort; never throws. Does NOT touch the plist autopilot (separate
1978
- // 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.
1979
2147
  function preemptScanForPost() {
1980
2148
  try {
1981
2149
  if (scanChild && scanChild.pid && scanChild.exitCode === null) {
@@ -1995,6 +2163,9 @@ function preemptScanForPost() {
1995
2163
  // the next scan_candidates call starts fresh.
1996
2164
  currentScanJob = null;
1997
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();
1998
2169
  }
1999
2170
  // Per-call long-poll window. Must stay under the ~60s MCP client request timeout
2000
2171
  // so every scan_candidates call returns a real response before the client gives
@@ -2409,7 +2580,7 @@ tool("show_browser_to_user", {
2409
2580
  return jsonContent({ ok: true, brought_to_front: true, port: res.port });
2410
2581
  }
2411
2582
  // If the user is about to watch the live browser, make sure the on-screen
2412
- // overlay watcher is up too so the harness window carries status + drafts.
2583
+ // overlay watcher is up too so the harness window carries its status banner.
2413
2584
  if (action === "start")
2414
2585
  await ensureOverlayWatch();
2415
2586
  const ensured = await screencast.ensure(typeof args?.port === "number" ? args.port : undefined);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.87",
3
- "installedAt": "2026-06-23T18:34:55.294Z"
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.87",
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": {
@@ -173,7 +173,7 @@ class S4LMenuBar(rumps.App):
173
173
  # indicator + the "Please update now" menu item.
174
174
  self._update_available = False
175
175
  self._latest_version = None
176
- self._upd_timer = rumps.Timer(self._check_update, 1800) # every 30 min
176
+ self._upd_timer = rumps.Timer(self._check_update, 60) # every 1 min
177
177
  self._upd_timer.start()
178
178
  self._check_update(None)
179
179
  self._tick(None)
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.87",
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.87",
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"
@@ -3,9 +3,7 @@
3
3
  #
4
4
  # WHAT: keeps exactly ONE `harness_overlay.py watch` process alive. That watcher
5
5
  # injects the status overlay into the twitter-harness Chrome window so a human
6
- # watching the harness sees what the pipeline is doing. (The interactive "Drafts
7
- # to post" sidebar is OFF by default as of 2026-06-23; opt back in with
8
- # SAPS_SIDEBAR=1.)
6
+ # watching the harness sees what the pipeline is doing.
9
7
  #
10
8
  # WHY a supervisor: the overlay only renders WHILE the watch process runs. It was
11
9
  # previously a manual, local-only process, so it never appeared on headless /