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 +3 -4
- package/mcp/dist/index.js +204 -33
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/skill/run-overlay-watch.sh +1 -3
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
|
-
//
|
|
596
|
-
//
|
|
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
|
|
350
|
-
//
|
|
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
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
1976
|
-
//
|
|
1977
|
-
//
|
|
1978
|
-
//
|
|
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
|
|
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);
|
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": {
|
|
@@ -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,
|
|
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.
|
|
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
|
@@ -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.
|
|
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 /
|