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 +201 -29
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
//
|
|
1972
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
1976
|
-
//
|
|
1977
|
-
//
|
|
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
|
package/mcp/dist/version.json
CHANGED
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.
|
|
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.
|
|
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