social-autoposter 1.6.88 → 1.6.90

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";
@@ -22,7 +23,7 @@ import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from
22
23
  import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, } from "./runtime.js";
23
24
  import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
24
25
  import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
25
- import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
26
+ import { initSentry, sendHeartbeat, captureError, flushSentry, startLogStreaming, flushLogs } from "./telemetry.js";
26
27
  import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
27
28
  import { fileURLToPath } from "node:url";
28
29
  import http from "node:http";
@@ -531,14 +532,72 @@ function renderDraftsTable(plan) {
531
532
  })
532
533
  .join("\n\n");
533
534
  }
535
+ // Resolve the configured posting handle the SAME way account_resolver.py does:
536
+ // AUTOPOSTER_TWITTER_HANDLE env first, then config.json accounts.twitter.handle.
537
+ // Returns the bare handle (no @) or null. The post preflight uses it so a missing
538
+ // handle fails ONCE, loudly, instead of as N silent per-reply no_account_configured
539
+ // skips (twitter_browser.py refuses to post with no handle — no impersonation).
540
+ function readConfiguredTwitterHandle() {
541
+ const env = (process.env.AUTOPOSTER_TWITTER_HANDLE || "").trim().replace(/^@/, "");
542
+ if (env)
543
+ return env;
544
+ try {
545
+ const cfg = JSON.parse(fs.readFileSync(path.join(repoDir(), "config.json"), "utf-8"));
546
+ const h = cfg?.accounts?.twitter?.handle;
547
+ const s = (typeof h === "string" ? h : "").trim().replace(/^@/, "");
548
+ return s || null;
549
+ }
550
+ catch {
551
+ return null;
552
+ }
553
+ }
554
+ // Self-heal a missing handle: read the live logged-in @handle from the managed
555
+ // Chrome and persist it to config.json accounts.twitter.handle. This is ground
556
+ // truth (the poster posts through that exact session), NOT a guess — so it's safe
557
+ // where a hardcoded fallback would not be. Closes the onboarding gap where
558
+ // connect_x's best-effort handle capture silently no-op'd and left posting dead.
559
+ // Best-effort; never throws — the caller re-checks and refuses loudly if still unset.
560
+ async function ensurePostingHandle() {
561
+ try {
562
+ await runPython("scripts/setup_twitter_auth.py", ["resolve-handle"], {
563
+ timeoutMs: 60_000,
564
+ env: { SAPS_REPO_DIR: repoDir(), PATH: pipelinePath() },
565
+ });
566
+ }
567
+ catch {
568
+ /* best effort */
569
+ }
570
+ }
534
571
  async function postApproved(batchId, plan) {
535
572
  const approved = (plan.candidates || []).filter((c) => c.approved === true);
536
573
  if (approved.length === 0)
537
574
  return { attempted: 0, exit_code: 0, summary: "nothing approved" };
575
+ // PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
576
+ // EVERY reply with no_account_configured and the whole batch skips — invisibly.
577
+ // If onboarding never persisted it, self-heal from the live session; if even that
578
+ // can't determine it, refuse here with a clear reason rather than launching a
579
+ // poster that silently burns the whole batch.
580
+ if (!readConfiguredTwitterHandle())
581
+ await ensurePostingHandle();
582
+ if (!readConfiguredTwitterHandle()) {
583
+ return {
584
+ attempted: 0,
585
+ exit_code: 0,
586
+ posted: 0,
587
+ summary: "no_account_configured",
588
+ error: "X is connected but no posting @handle is configured, so every reply would be refused " +
589
+ "(no_account_configured). Re-run project_config action:'connect_x' to capture the handle, " +
590
+ "or set accounts.twitter.handle in config.json.",
591
+ };
592
+ }
538
593
  // Posting is a priority over scanning: abort any in-flight plugin scan so the
539
594
  // approved post takes the browser immediately instead of waiting on the lock.
540
595
  // Plugin pipeline only — never affects the plist autopilot.
541
596
  preemptScanForPost();
597
+ // Hold the /tmp shell browser lock (the one the scanner respects) for the WHOLE
598
+ // batch so the every-minute autopilot scan queues behind the post instead of
599
+ // seizing Chrome mid-batch — the root cause of approved batches landing 0/N.
600
+ const heldShellLock = await acquireShellBrowserLock();
542
601
  const approvedBatch = `${batchId}_approved`;
543
602
  writePlan(approvedBatch, { ...plan, candidates: approved });
544
603
  // SAPS_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
@@ -546,28 +605,62 @@ async function postApproved(batchId, plan) {
546
605
  // twitter_browser.py's reply handler reads this env (inherited through
547
606
  // twitter_post_plan.py's subprocess). The cron pipeline doesn't set it, so the
548
607
  // 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
- });
608
+ const res = await (async () => {
609
+ try {
610
+ return await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
611
+ timeoutMs: 900_000,
612
+ env: {
613
+ SAPS_SKIP_CAMPAIGN_SUFFIX: "1",
614
+ // Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
615
+ // runs TWITTER_TAIL_LINK_RATE=0.9 (from .env) so ~10% of autopilot posts
616
+ // ship link-less as an experiment arm. But when the user hand-reviews a
617
+ // draft, sees the link target in the table, and approves it, dropping the
618
+ // link is surprising and unwanted. Force 1.0 here so every approved draft
619
+ // carries its link. This wins over .env / process.env because run() spreads
620
+ // opts.env AFTER process.env, and twitter_post_plan.py never load_dotenv's
621
+ // with override, so nothing clobbers it. Cron is untouched (it never goes
622
+ // through this MCP path), so the 0.9 experiment keeps running there.
623
+ TWITTER_TAIL_LINK_RATE: "1.0",
624
+ // The poster attaches to the twitter-harness Chrome over CDP. The cron
625
+ // pipeline exports this from skill/lib/twitter-backend.sh; the MCP path
626
+ // must set it explicitly or twitter_browser.py fails with "No twitter-
627
+ // harness Chrome reachable". Honor an inherited value (AppMaker / VM
628
+ // BYO-Chrome), else default to the local harness on port 9555.
629
+ TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
630
+ },
631
+ // Stream the poster's output live (like scan_candidates does) so HANDLED
632
+ // failures — e.g. every reply refused with no_account_configured, which
633
+ // returns a reason instead of throwing — surface in main.log + telemetry
634
+ // in real time. Without this the poster's stdout was buffered in-process
635
+ // and only flushed to post-*.log at the END, so a 0/N batch was invisible
636
+ // while the menu bar showed "posting N/89" climbing.
637
+ onLine: (line) => {
638
+ const t = line.replace(/\s+$/, "");
639
+ if (t.trim())
640
+ console.error(`[post] ${t}`);
641
+ },
642
+ });
643
+ }
644
+ finally {
645
+ // Always free the lock so a queued scan can proceed, even on throw/timeout.
646
+ if (heldShellLock)
647
+ releaseShellBrowserLock();
648
+ }
649
+ })();
650
+ // Persist the poster's own stdout/stderr to a dated log. Without this the post
651
+ // run was invisible: twitter_post_plan.py's output streamed to this MCP
652
+ // instance's stderr and was never tee'd anywhere on disk, so a 0/N batch left
653
+ // no on-box trace to debug. Best-effort; never breaks posting.
654
+ try {
655
+ const postLogDir = path.join(repoDir(), "skill", "logs");
656
+ fs.mkdirSync(postLogDir, { recursive: true });
657
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
658
+ fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${approved.length} exit=${res.code} ` +
659
+ `shell_lock=${heldShellLock}\n\n=== stdout ===\n${res.stdout}\n\n=== stderr ===\n${res.stderr}\n`);
660
+ }
661
+ catch {
662
+ /* best effort */
663
+ }
571
664
  let summary = res.stdout.trim();
572
665
  try {
573
666
  const lines = res.stdout.trim().split("\n");
@@ -616,6 +709,7 @@ async function postApproved(batchId, plan) {
616
709
  });
617
710
  void flushSentry(2000);
618
711
  }
712
+ void flushLogs();
619
713
  return {
620
714
  attempted: approved.length,
621
715
  posted: realPosted,
@@ -1968,13 +2062,154 @@ let currentScanJob = null;
1968
2062
  // immediately. Only ever references a scan the MCP server itself launched — never
1969
2063
  // the plist autopilot's launchd scan.
1970
2064
  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.
2065
+ // ---- Cross-process browser-lock bridge (the REAL posting-priority fix) ------
2066
+ // The SCANNER (run-twitter-cycle.sh) serializes browser access on a mkdir-based
2067
+ // DIRECTORY lock at /tmp/social-autoposter-twitter-browser.lock (skill/lock.sh).
2068
+ // The POSTER (twitter_post_plan.py / twitter_browser.py) serializes on a totally
2069
+ // SEPARATE json file lock (~/.claude/twitter-browser-lock.json) with role:"post"
2070
+ // preemption. The two locks never reference each other, so a post launched from
2071
+ // THIS MCP (or a sibling MCP instance every autopilot agent session spawns its
2072
+ // own) never actually excluded a live scan: both held "their" lock and drove the
2073
+ // one shared harness Chrome at once, so an approved batch landed 0/N while a scan
2074
+ // churned 118 queries for ~10min (proven live on the remote box 2026-06-23:
2075
+ // /tmp lock pid=scanner AND json lock python:poster role=post, simultaneously).
2076
+ //
2077
+ // preemptScanForPost's old body only killed scanChild — a process-LOCAL handle to
2078
+ // a scan THIS instance launched. The scan that actually holds the browser is
2079
+ // almost always a SIBLING instance's, which we have no ChildProcess for. So we
2080
+ // bridge to the lock the scanner truly respects: read its /tmp pid file, and if a
2081
+ // live run-twitter-cycle.sh holds it, signal it cross-process. Then the post
2082
+ // HOLDS that same /tmp lock for the whole batch so the every-minute autopilot
2083
+ // scan queues behind us (its acquire_lock waits on our live pid) instead of
2084
+ // seizing Chrome mid-post. skill/lock.sh's ownership guard + kill-0 liveness +
2085
+ // 3h stale-reclaim recover the dir if we ever leak it. Never touches a locked
2086
+ // pipeline script or the python json lock.
2087
+ const TW_BROWSER_LOCK_DIR = "/tmp/social-autoposter-twitter-browser.lock";
2088
+ function shellLockHolderPid() {
2089
+ try {
2090
+ const pid = parseInt(fs.readFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), "utf-8").trim(), 10);
2091
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
2092
+ }
2093
+ catch {
2094
+ return null; // no dir / no pid file == lock is free
2095
+ }
2096
+ }
2097
+ function pidAlive(pid) {
2098
+ try {
2099
+ process.kill(pid, 0);
2100
+ return true;
2101
+ }
2102
+ catch {
2103
+ return false;
2104
+ }
2105
+ }
2106
+ // True ONLY when pid is a run-twitter-cycle.sh scan — the one holder a post is
2107
+ // allowed to preempt. Never preempt another poster or an unknown holder.
2108
+ function pidIsScan(pid) {
2109
+ try {
2110
+ const cmd = execFileSync("ps", ["-o", "command=", "-p", String(pid)], {
2111
+ encoding: "utf-8",
2112
+ timeout: 4000,
2113
+ });
2114
+ return /run-twitter-cycle\.sh/.test(cmd);
2115
+ }
2116
+ catch {
2117
+ return false;
2118
+ }
2119
+ }
2120
+ function rmShellLockDir() {
2121
+ try {
2122
+ fs.rmSync(TW_BROWSER_LOCK_DIR, { recursive: true, force: true });
2123
+ }
2124
+ catch {
2125
+ /* best effort */
2126
+ }
2127
+ }
2128
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
2129
+ // SIGTERM a live scan holding the shell browser lock so the post takes the
2130
+ // browser at once. Best-effort; only ever targets a run-twitter-cycle.sh.
2131
+ function preemptScanHoldingBrowser() {
2132
+ try {
2133
+ const pid = shellLockHolderPid();
2134
+ if (pid && pidAlive(pid) && pidIsScan(pid)) {
2135
+ console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid})`);
2136
+ try {
2137
+ process.kill(pid, "SIGTERM");
2138
+ }
2139
+ catch {
2140
+ /* already gone */
2141
+ }
2142
+ }
2143
+ }
2144
+ catch {
2145
+ /* best effort */
2146
+ }
2147
+ }
2148
+ // Take the shell browser lock for the batch. Preempts a scan holder; never
2149
+ // steals from a live non-scan holder (a peer poster) — there it returns false
2150
+ // and posting proceeds unguarded (no worse than before). Best-effort.
2151
+ async function acquireShellBrowserLock() {
2152
+ for (let attempt = 0; attempt < 8; attempt++) {
2153
+ try {
2154
+ fs.mkdirSync(TW_BROWSER_LOCK_DIR); // atomic mutex — only one winner
2155
+ try {
2156
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
2157
+ }
2158
+ catch {
2159
+ /* best effort */
2160
+ }
2161
+ try {
2162
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
2163
+ }
2164
+ catch {
2165
+ /* best effort */
2166
+ }
2167
+ console.error(`[post] holding twitter-browser shell lock pid=${process.pid} — scans queue behind the post`);
2168
+ return true;
2169
+ }
2170
+ catch {
2171
+ // Dir exists. Reclaim if the holder is dead; preempt if it's a scan;
2172
+ // otherwise (a live peer poster) leave it and post unguarded.
2173
+ const pid = shellLockHolderPid();
2174
+ if (!pid || !pidAlive(pid)) {
2175
+ rmShellLockDir();
2176
+ }
2177
+ else if (pidIsScan(pid)) {
2178
+ try {
2179
+ process.kill(pid, "SIGTERM");
2180
+ }
2181
+ catch {
2182
+ /* already gone */
2183
+ }
2184
+ await sleepMs(400);
2185
+ rmShellLockDir();
2186
+ }
2187
+ else {
2188
+ return false; // a real peer holds it — don't steal; proceed
2189
+ }
2190
+ await sleepMs(250);
2191
+ }
2192
+ }
2193
+ return false;
2194
+ }
2195
+ // Release only if it's still OURS (mirror skill/lock.sh's ownership guard) so we
2196
+ // never wipe a scan that legitimately re-acquired after the batch finished.
2197
+ function releaseShellBrowserLock() {
2198
+ try {
2199
+ if (shellLockHolderPid() === process.pid) {
2200
+ rmShellLockDir();
2201
+ console.error(`[post] released twitter-browser shell lock pid=${process.pid}`);
2202
+ }
2203
+ }
2204
+ catch {
2205
+ /* best effort */
2206
+ }
2207
+ }
2208
+ // Posting takes priority over scanning. When the user approves a post, abort any
2209
+ // in-flight scan so the browser frees up at once. Two layers: (1) kill scanChild
2210
+ // if THIS instance launched the scan, and (2) cross-process — kill whoever holds
2211
+ // the /tmp shell lock if it's a scan (covers sibling MCP instances we have no
2212
+ // handle to). Best-effort; never throws; never touches a locked pipeline script.
1978
2213
  function preemptScanForPost() {
1979
2214
  try {
1980
2215
  if (scanChild && scanChild.pid && scanChild.exitCode === null) {
@@ -1994,6 +2229,9 @@ function preemptScanForPost() {
1994
2229
  // the next scan_candidates call starts fresh.
1995
2230
  currentScanJob = null;
1996
2231
  scanChild = null;
2232
+ // Cross-process: the scan that actually holds the shared Chrome is usually a
2233
+ // sibling MCP instance's, not ours. Kill it via the lock it truly respects.
2234
+ preemptScanHoldingBrowser();
1997
2235
  }
1998
2236
  // Per-call long-poll window. Must stay under the ~60s MCP client request timeout
1999
2237
  // so every scan_candidates call returns a real response before the client gives
@@ -2458,6 +2696,11 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
2458
2696
  }));
2459
2697
  async function main() {
2460
2698
  initSentry();
2699
+ // Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
2700
+ // Cloud Run relay (-> Cloud Logging) so we can troubleshoot/rescue any user
2701
+ // scenario (silent stalls, partial onboarding) without asking them to ship a
2702
+ // log file. Best-effort; disabled with SAPS_LOG_STREAM=0.
2703
+ startLogStreaming();
2461
2704
  // A plugin UPDATE refreshes this server (dist/) but not the materialized
2462
2705
  // pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
2463
2706
  // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
@@ -2512,6 +2755,7 @@ async function main() {
2512
2755
  main().catch(async (err) => {
2513
2756
  console.error("[social-autoposter-mcp] fatal:", err);
2514
2757
  captureError(err, { component: "main" });
2758
+ await flushLogs();
2515
2759
  await flushSentry();
2516
2760
  process.exit(1);
2517
2761
  });
package/mcp/dist/repo.js CHANGED
@@ -30,6 +30,27 @@ export function repoDir() {
30
30
  // "No drafts in batch ...". Default to /tmp to match the script; allow an explicit
31
31
  // override for non-standard installs.
32
32
  export const TMP_DIR = process.env.SAPS_TMP_DIR || "/tmp";
33
+ let lineSink = null;
34
+ export function setLineSink(fn) {
35
+ lineSink = fn;
36
+ }
37
+ // Derive a short, stable context label for a spawned command so log lines can
38
+ // be grouped by which script produced them (e.g. "scripts/seed_search_topics.py",
39
+ // "skill/run-twitter-cycle.sh"). Best-effort; never throws.
40
+ function deriveContext(cmd, args) {
41
+ try {
42
+ const base = cmd.split("/").pop() || cmd;
43
+ if (/python/i.test(base) || base === "bash" || base === "sh" || base === "node") {
44
+ const first = args.find((a) => !a.startsWith("-"));
45
+ if (first)
46
+ return first;
47
+ }
48
+ return [base, args[0] ?? ""].join(" ").trim();
49
+ }
50
+ catch {
51
+ return cmd;
52
+ }
53
+ }
33
54
  // Spawn a process inside the repo, inheriting the repo env (API base + keys
34
55
  // come from the install's environment / .env loaded by the scripts themselves).
35
56
  //
@@ -73,6 +94,29 @@ export function run(cmd, args, opts = {}) {
73
94
  }
74
95
  return buf;
75
96
  };
97
+ // Parallel whole-line splitter that tees to the telemetry sink (if any),
98
+ // kept separate from the onLine pump so neither path can affect the other.
99
+ const logCtx = opts.logContext || deriveContext(cmd, args);
100
+ let sinkOutBuf = "";
101
+ let sinkErrBuf = "";
102
+ const sinkPump = (chunk, which, buf) => {
103
+ const sink = lineSink;
104
+ if (!sink)
105
+ return buf;
106
+ buf += chunk;
107
+ let nl;
108
+ while ((nl = buf.indexOf("\n")) !== -1) {
109
+ const line = buf.slice(0, nl);
110
+ buf = buf.slice(nl + 1);
111
+ try {
112
+ sink(line, which, logCtx);
113
+ }
114
+ catch {
115
+ /* the telemetry sink must never break the wrapped command */
116
+ }
117
+ }
118
+ return buf;
119
+ };
76
120
  let timer;
77
121
  if (opts.timeoutMs) {
78
122
  timer = setTimeout(() => {
@@ -83,11 +127,13 @@ export function run(cmd, args, opts = {}) {
83
127
  const s = d.toString();
84
128
  stdout += s;
85
129
  outBuf = pump(s, "stdout", outBuf);
130
+ sinkOutBuf = sinkPump(s, "stdout", sinkOutBuf);
86
131
  });
87
132
  child.stderr.on("data", (d) => {
88
133
  const s = d.toString();
89
134
  stderr += s;
90
135
  errBuf = pump(s, "stderr", errBuf);
136
+ sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
91
137
  });
92
138
  child.on("close", (code) => {
93
139
  if (timer)
@@ -109,6 +155,23 @@ export function run(cmd, args, opts = {}) {
109
155
  /* ignore */
110
156
  }
111
157
  }
158
+ const sink = lineSink;
159
+ if (sink) {
160
+ if (sinkOutBuf)
161
+ try {
162
+ sink(sinkOutBuf, "stdout", logCtx);
163
+ }
164
+ catch {
165
+ /* ignore */
166
+ }
167
+ if (sinkErrBuf)
168
+ try {
169
+ sink(sinkErrBuf, "stderr", logCtx);
170
+ }
171
+ catch {
172
+ /* ignore */
173
+ }
174
+ }
112
175
  resolve({ code: code ?? -1, stdout, stderr });
113
176
  });
114
177
  child.on("error", (err) => {
@@ -9,7 +9,7 @@
9
9
  import * as Sentry from "@sentry/node";
10
10
  import path from "node:path";
11
11
  import fs from "node:fs";
12
- import { repoDir, runPython } from "./repo.js";
12
+ import { repoDir, runPython, setLineSink } from "./repo.js";
13
13
  import { VERSION } from "./version.js";
14
14
  // Sentry DSN is a client-side identifier (safe to embed, same posture as Fazm's
15
15
  // hardcoded Swift DSN). Overridable via env for dev. Empty -> Sentry disabled.
@@ -102,3 +102,168 @@ export async function sendHeartbeat(reason) {
102
102
  console.error("[social-autoposter-mcp] heartbeat failed:", err?.message || err);
103
103
  }
104
104
  }
105
+ // ---- Raw subprocess log streaming ------------------------------------------
106
+ // Tees the verbatim stdout/stderr of every pipeline subprocess (via the
107
+ // repo.ts run() boundary) to the s4l Cloud Run relay, which simply
108
+ // console.log()s each line so Cloud Run's runtime ships it to Cloud Logging.
109
+ // No database, no service-account key on the client — the relay is the only
110
+ // thing authenticated to GCP, and it authenticates implicitly via its Cloud
111
+ // Run runtime identity. Lines are buffered in memory and flushed in small
112
+ // batches under the same X-Installation identity the heartbeat uses.
113
+ //
114
+ // Best-effort: NEVER throws into the server, never blocks the child's I/O, and
115
+ // drops on overflow rather than growing unbounded. Disable with
116
+ // SAPS_LOG_STREAM=0.
117
+ //
118
+ // IMPORTANT: logs go to the CLOUD RUN host (AUTOPOSTER_LOG_BASE, default
119
+ // app.s4l.ai), NOT the Vercel host (AUTOPOSTER_API_BASE / s4l.ai) the heartbeat
120
+ // and onboarding-events use. Cloud Run's native stdout -> Cloud Logging path is
121
+ // the whole point of this lane.
122
+ const LOG_STREAM_ENABLED = process.env.SAPS_LOG_STREAM !== "0";
123
+ const LOG_MAX_LINE_LEN = 8192; // mirror the relay cap
124
+ const LOG_MAX_BUFFER = 1000; // drop oldest beyond this (overflow protection)
125
+ const LOG_FLUSH_BATCH = 100; // flush eagerly once we have this many lines
126
+ const LOG_MAX_PER_POST = 200; // relay accepts 1-200 per request
127
+ const LOG_FLUSH_MS = 3000; // otherwise flush on this cadence
128
+ // Drop genuinely useless high-volume lines before they ever buffer, so a chatty
129
+ // run doesn't crowd out the signal (and to keep Cloud Logging volume sane).
130
+ // Empty/whitespace-only lines plus an env-extensible regex of obvious dump
131
+ // signatures. Deliberately conservative: real pipeline output is the value, so
132
+ // we only filter clear noise. Extend via SAPS_LOG_NOISE_RE (a JS regex source).
133
+ let logNoiseRe = null;
134
+ try {
135
+ const extra = (process.env.SAPS_LOG_NOISE_RE || "").trim();
136
+ // Default signatures: a bare `ps`/`launchctl`-style table dump row is rare in
137
+ // pipeline output but floods when an agent shells one in. Keep this tight.
138
+ const sources = [
139
+ extra,
140
+ ].filter(Boolean);
141
+ logNoiseRe = sources.length ? new RegExp(sources.join("|")) : null;
142
+ }
143
+ catch {
144
+ logNoiseRe = null;
145
+ }
146
+ function isNoise(line) {
147
+ if (!line || !line.trim())
148
+ return true; // blank / whitespace-only
149
+ if (logNoiseRe && logNoiseRe.test(line))
150
+ return true;
151
+ return false;
152
+ }
153
+ const logBuffer = [];
154
+ let logDropped = 0; // count of lines dropped on overflow (surfaced periodically)
155
+ let logFlushing = false;
156
+ let logTimer;
157
+ let cachedInstallHeader = null;
158
+ let logStreamingStarted = false;
159
+ async function installHeader() {
160
+ if (cachedInstallHeader)
161
+ return cachedInstallHeader;
162
+ try {
163
+ const idScript = path.join(repoDir(), "scripts", "identity.py");
164
+ if (!fs.existsSync(idScript))
165
+ return null;
166
+ const res = await runPython("scripts/identity.py", ["header"], { timeoutMs: 10_000 });
167
+ const header = (res.stdout || "").trim();
168
+ if (res.code === 0 && header) {
169
+ cachedInstallHeader = header;
170
+ return header;
171
+ }
172
+ }
173
+ catch {
174
+ /* best-effort */
175
+ }
176
+ return null;
177
+ }
178
+ // Buffer one raw line. Called from the repo.ts line sink, so it must be cheap
179
+ // and total non-throwing.
180
+ export function logLine(stream, line, context) {
181
+ if (!LOG_STREAM_ENABLED)
182
+ return;
183
+ try {
184
+ if (isNoise(line))
185
+ return;
186
+ logBuffer.push({
187
+ ts: new Date().toISOString(),
188
+ stream,
189
+ line: line.length > LOG_MAX_LINE_LEN ? line.slice(0, LOG_MAX_LINE_LEN) : line,
190
+ context: context || "",
191
+ });
192
+ if (logBuffer.length > LOG_MAX_BUFFER) {
193
+ // Drop oldest to bound memory; the newest lines are the most useful.
194
+ logDropped += logBuffer.length - LOG_MAX_BUFFER;
195
+ logBuffer.splice(0, logBuffer.length - LOG_MAX_BUFFER);
196
+ }
197
+ if (logBuffer.length >= LOG_FLUSH_BATCH)
198
+ void flushLogs();
199
+ }
200
+ catch {
201
+ /* never throw into the run() boundary */
202
+ }
203
+ }
204
+ export async function flushLogs() {
205
+ if (!LOG_STREAM_ENABLED)
206
+ return;
207
+ if (logFlushing || logBuffer.length === 0)
208
+ return;
209
+ logFlushing = true;
210
+ try {
211
+ const header = await installHeader();
212
+ if (!header)
213
+ return; // runtime not unpacked yet; keep buffering
214
+ // Cloud Run relay host (NOT the Vercel API host). app.s4l.ai serves
215
+ // bin/server.js, whose POST /api/v1/installations/logs console.log()s each
216
+ // line into Cloud Logging.
217
+ const base = (process.env.AUTOPOSTER_LOG_BASE || "https://app.s4l.ai").replace(/\/+$/, "");
218
+ // Drain in <=200-line POSTs until the buffer empties (or a POST fails).
219
+ while (logBuffer.length > 0) {
220
+ const batch = logBuffer.splice(0, LOG_MAX_PER_POST);
221
+ const lines = batch.map((b) => ({
222
+ ts: b.ts,
223
+ stream: b.stream,
224
+ line: b.line,
225
+ context: b.context || undefined,
226
+ }));
227
+ try {
228
+ const resp = await fetch(`${base}/api/v1/installations/logs`, {
229
+ method: "POST",
230
+ headers: { "X-Installation": header, "content-type": "application/json" },
231
+ body: JSON.stringify({ lines }),
232
+ signal: AbortSignal.timeout(15_000),
233
+ });
234
+ if (!resp.ok) {
235
+ // Drop this batch (don't re-buffer): a persistent 4xx/5xx would grow
236
+ // the buffer unbounded. The raw stream is best-effort.
237
+ console.error(`[social-autoposter-mcp] log flush http ${resp.status}`);
238
+ break;
239
+ }
240
+ }
241
+ catch (err) {
242
+ // Network blip: drop this batch, stop draining, try again next tick.
243
+ console.error("[social-autoposter-mcp] log flush failed:", err?.message || err);
244
+ break;
245
+ }
246
+ }
247
+ if (logDropped > 0) {
248
+ console.error(`[social-autoposter-mcp] log stream dropped ${logDropped} line(s) on overflow`);
249
+ logDropped = 0;
250
+ }
251
+ }
252
+ finally {
253
+ logFlushing = false;
254
+ }
255
+ }
256
+ // Register the repo.ts line sink and start the periodic flush. Idempotent.
257
+ export function startLogStreaming() {
258
+ if (!LOG_STREAM_ENABLED || logStreamingStarted)
259
+ return;
260
+ logStreamingStarted = true;
261
+ try {
262
+ setLineSink((line, stream, context) => logLine(stream, line, context));
263
+ logTimer = setInterval(() => void flushLogs(), LOG_FLUSH_MS);
264
+ logTimer.unref();
265
+ }
266
+ catch (err) {
267
+ console.error("[social-autoposter-mcp] log streaming start failed:", err?.message || err);
268
+ }
269
+ }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.88",
3
- "installedAt": "2026-06-23T18:40:27.430Z"
2
+ "version": "1.6.90",
3
+ "installedAt": "2026-06-23T19:42:27.028Z"
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.90",
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": {
@@ -1,10 +1,12 @@
1
1
  """Corner pop-up review cards for draft approval (AppKit / pyobjc).
2
2
 
3
- `present_review(drafts, on_complete)` shows one small floating panel per draft in
4
- the top-right corner: thread context, an EDITABLE reply field, a counter, and
5
- Reject / Approve (+ Reject all). Advancing through every card calls on_complete
6
- with the decisions. The whole AppKit surface is isolated behind that one
7
- function so the menu bar wiring doesn't depend on the windowing details.
3
+ `present_review(drafts, on_decision, on_complete)` shows one small floating panel
4
+ per draft in the top-right corner: thread context, an EDITABLE reply field, a
5
+ counter, and Reject / Approve. `on_decision` fires the INSTANT each card is
6
+ approved/rejected (so an approved draft can post right away), and `on_complete`
7
+ fires once the last card is decided or the window is closed. The whole AppKit
8
+ surface is isolated behind that one function so the menu bar wiring doesn't
9
+ depend on the windowing details.
8
10
 
9
11
  Decision shape: {"n": int, "approved": bool, "text": str, "edited": bool}
10
12
 
@@ -70,11 +72,12 @@ def _label(frame, text, *, size=12, bold=False, muted=False):
70
72
 
71
73
 
72
74
  class _ReviewController(NSObject):
73
- def initWithDrafts_onComplete_(self, drafts, on_complete):
75
+ def initWithDrafts_onDecision_onComplete_(self, drafts, on_decision, on_complete):
74
76
  self = objc.super(_ReviewController, self).init()
75
77
  if self is None:
76
78
  return None
77
79
  self._drafts = list(drafts)
80
+ self._on_decision = on_decision
78
81
  self._on_complete = on_complete
79
82
  self._idx = 0
80
83
  self._decisions = []
@@ -218,13 +221,29 @@ class _ReviewController(NSObject):
218
221
  else:
219
222
  self._render()
220
223
 
224
+ @objc.python_method
225
+ def _fire_decision(self):
226
+ # Fire the per-card callback the instant a decision is made, so an
227
+ # approved draft starts posting immediately instead of waiting for the
228
+ # whole batch to be reviewed. A throwing callback must never break the
229
+ # card flow (or the panel would wedge on the current card).
230
+ cb = self._on_decision
231
+ if cb is None or not self._decisions:
232
+ return
233
+ try:
234
+ cb(dict(self._decisions[-1]))
235
+ except Exception:
236
+ pass
237
+
221
238
  # ObjC selectors (trailing underscore -> "approve:" etc.)
222
239
  def approve_(self, sender):
223
240
  self._record(True)
241
+ self._fire_decision()
224
242
  self._advance()
225
243
 
226
244
  def reject_(self, sender):
227
245
  self._record(False)
246
+ self._fire_decision()
228
247
  self._advance()
229
248
 
230
249
  def windowShouldClose_(self, sender):
@@ -252,12 +271,17 @@ class _ReviewController(NSObject):
252
271
  _active = None
253
272
 
254
273
 
255
- def present_review(drafts, on_complete):
274
+ def present_review(drafts, on_decision=None, on_complete=None):
256
275
  """Show the review cards (main thread only). drafts: list of
257
- {n, thread_author, thread_text, reply_text, link_url}. on_complete(decisions)
258
- fires on the main thread when the user finishes or closes the window."""
276
+ {n, thread_author, thread_text, reply_text, link_url}.
277
+ on_decision(decision) fires the instant each card is approved/rejected (so an
278
+ approved draft posts right away); on_complete(decisions) fires when the user
279
+ finishes the last card or closes the window. Both run on the main thread."""
259
280
  global _active
260
281
  if not drafts:
261
- on_complete([])
282
+ if on_complete is not None:
283
+ on_complete([])
262
284
  return
263
- _active = _ReviewController.alloc().initWithDrafts_onComplete_(drafts, on_complete)
285
+ _active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_(
286
+ drafts, on_decision, on_complete
287
+ )
@@ -18,6 +18,7 @@ rather than rumps.notification (which needs a bundle id).
18
18
 
19
19
  import json
20
20
  import os
21
+ import queue
21
22
  import subprocess
22
23
  import sys
23
24
  import tempfile
@@ -152,6 +153,17 @@ class S4LMenuBar(rumps.App):
152
153
  # later batch for the life of this process (only a restart cleared it),
153
154
  # which is exactly the "drafts queued but no cards" bug.
154
155
  self._last_review_sig = None
156
+ # Per-card posting. Each approved card posts the INSTANT it's approved,
157
+ # serialized through one persistent worker so two posts never drive the
158
+ # shared harness Chrome at once (the poster lock fails a concurrent peer
159
+ # after 45s rather than queuing it, which would land the 2nd card 0/N).
160
+ # `_review_active` stays true while the panel is open OR posts are still
161
+ # draining, so a half-posted set is never re-presented as fresh cards.
162
+ self._post_q = queue.Queue()
163
+ self._post_worker = None
164
+ self._review_lock = threading.Lock()
165
+ self._panel_open = False
166
+ self._posts_outstanding = 0
155
167
  self._spin_i = 0
156
168
  self._spinner = None # fast rumps.Timer animating the title while busy
157
169
  # Reliable self-check of our own Accessibility (TCC) grant — this is the
@@ -467,11 +479,14 @@ class S4LMenuBar(rumps.App):
467
479
  if sig == self._last_review_sig:
468
480
  return
469
481
  self._review_active = True
482
+ self._panel_open = True
470
483
  try:
471
484
  import s4l_card
472
485
 
473
486
  s4l_card.present_review(
474
- drafts, lambda decisions: self._on_review_done(batch, decisions)
487
+ drafts,
488
+ on_decision=lambda d: self._on_card_decision(batch, d),
489
+ on_complete=lambda decisions: self._on_review_closed(batch, decisions),
475
490
  )
476
491
  # Record as shown only AFTER the cards are actually up, so a transient
477
492
  # card-UI failure never permanently suppresses this pending set.
@@ -479,41 +494,80 @@ class S4LMenuBar(rumps.App):
479
494
  except Exception as e:
480
495
  # Card UI unavailable — don't strand the batch; chat review still works.
481
496
  self._review_active = False
497
+ self._panel_open = False
482
498
  sys.stderr.write(f"[s4l-menubar] review cards failed: {e}\n")
483
499
  sys.stderr.flush()
484
500
  _capture(e, phase="review_cards")
485
501
 
486
- def _on_review_done(self, batch, decisions):
487
- # Runs on the main thread (from the card controller). Translate decisions
488
- # into post_drafts args and post on a background thread so the UI stays
489
- # responsive (posting can take minutes).
490
- approved = [d for d in decisions if d.get("approved")]
491
- post_nums = [d["n"] for d in approved if not d.get("edited")]
492
- edits = [{"n": d["n"], "text": d["text"]} for d in approved if d.get("edited")]
502
+ def _on_card_decision(self, batch, decision):
503
+ # Runs on the main thread the INSTANT a card is approved/rejected. An
504
+ # approved card is enqueued for immediate posting; a rejected card does
505
+ # nothing. We never post inline here — posting can take minutes and would
506
+ # freeze the card UI while the user reviews the rest of the stack.
507
+ if not decision.get("approved"):
508
+ return
509
+ with self._review_lock:
510
+ self._posts_outstanding += 1
511
+ self._review_active = True
512
+ self._post_q.put((batch, decision))
513
+ self._ensure_post_worker()
514
+
515
+ def _on_review_closed(self, batch, decisions):
516
+ # Fires when the card sequence ends (last card decided or window closed).
517
+ # The panel is gone, but approved cards may still be draining — keep the
518
+ # review "active" until the queue empties so the not-yet-posted remainder
519
+ # isn't re-presented as a fresh batch.
520
+ with self._review_lock:
521
+ self._panel_open = False
522
+ if self._posts_outstanding <= 0:
523
+ self._review_active = False
493
524
  st.clear_review_request()
494
- if not approved:
495
- self._review_active = False
525
+ if not any(d.get("approved") for d in decisions):
496
526
  self._notify("S4L", "No drafts approved — nothing posted.")
497
- return
498
- self._notify("S4L", f"Posting {len(approved)} draft(s)…")
499
-
500
- # The server's post_drafts writes "posting" activity, so the activity
501
- # spinner shows automatically while this runs — no local spinner needed.
502
- def work():
503
- res = st.post_drafts(batch, post=post_nums, edits=edits)
504
- if res is None:
505
- self._notify(
506
- "S4L", "Couldn't post — open Claude Desktop and try the draft again."
507
- )
508
- else:
509
- posted = res.get("posted") if isinstance(res, dict) else None
510
- self._notify(
511
- "S4L",
512
- f"Posted {posted if posted is not None else len(approved)} draft(s).",
513
- )
514
- self._review_active = False
515
527
 
516
- threading.Thread(target=work, daemon=True).start()
528
+ def _ensure_post_worker(self):
529
+ # One persistent daemon worker drains the approved-card queue. It never
530
+ # exits (avoids an enqueue-vs-exit race) — an idle parked thread is cheap.
531
+ if self._post_worker is not None and self._post_worker.is_alive():
532
+ return
533
+ self._post_worker = threading.Thread(target=self._post_worker_loop, daemon=True)
534
+ self._post_worker.start()
535
+
536
+ def _post_worker_loop(self):
537
+ # Serialized poster: one approved card at a time so two posts never drive
538
+ # the shared harness Chrome simultaneously. The server's post_drafts writes
539
+ # "posting" activity, so the menu-bar spinner shows automatically.
540
+ while True:
541
+ batch, decision = self._post_q.get() # blocks until a card is approved
542
+ n = decision.get("n")
543
+ try:
544
+ self._notify("S4L", f"Posting draft {n}…")
545
+ if decision.get("edited"):
546
+ res = st.post_drafts(
547
+ batch, edits=[{"n": n, "text": decision.get("text") or ""}]
548
+ )
549
+ else:
550
+ res = st.post_drafts(batch, post=[n])
551
+ if res is None:
552
+ self._notify(
553
+ "S4L", "Couldn't post — open Claude Desktop and try the draft again."
554
+ )
555
+ else:
556
+ posted = res.get("posted") if isinstance(res, dict) else None
557
+ if posted == 0:
558
+ self._notify("S4L", f"Draft {n} didn't post — see the dashboard for why.")
559
+ else:
560
+ self._notify("S4L", f"Posted draft {n}.")
561
+ except Exception as e:
562
+ sys.stderr.write(f"[s4l-menubar] post draft {n} failed: {e}\n")
563
+ sys.stderr.flush()
564
+ _capture(e, phase="post_card")
565
+ finally:
566
+ with self._review_lock:
567
+ self._posts_outstanding -= 1
568
+ if self._posts_outstanding <= 0 and not self._panel_open:
569
+ self._review_active = False
570
+ self._post_q.task_done()
517
571
 
518
572
  def _render_title(self, setup_complete, ob, blocker):
519
573
  if blocker:
@@ -641,9 +695,10 @@ class S4LMenuBar(rumps.App):
641
695
  rumps.MenuItem("Run draft cycle in Claude", callback=self._draft)
642
696
  )
643
697
  # No "Post approved drafts" item: approving a review card already posts
644
- # directly + programmatically (_on_review_done -> st.post_drafts -> the
645
- # CDP poster). A menu button that types a prompt into the chat to do the
646
- # same thing was a redundant detour through the model, so it's gone.
698
+ # that card directly + programmatically (_on_card_decision -> queue ->
699
+ # _post_worker_loop -> st.post_drafts -> the CDP poster). A menu button
700
+ # that types a prompt into the chat to do the same thing was a redundant
701
+ # detour through the model, so it's gone.
647
702
  return out
648
703
 
649
704
 
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.90",
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.90",
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"
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # Programmatic equivalent of the menu-bar "Please update now" button
3
+ # (mcp/menubar/s4l_menubar.py::_mcpb_update_work). Pulls the latest .mcpb from
4
+ # GitHub releases, unzips it over the Claude Desktop extension dir in place, and
5
+ # restarts Claude so the new MCP server loads. Designed to be run over SSH on a
6
+ # .mcpb box (e.g. `ssh macstadium 'bash -s' < scripts/s4l_box_update.sh`), where
7
+ # npm/npx is absent so the `runtime action:update` (npx) path is dead.
8
+ #
9
+ # Flags:
10
+ # --check Print installed vs latest and exit (no download, no restart).
11
+ # --no-restart Download + unpack the new .mcpb but do NOT restart Claude.
12
+ # (default) Download + unpack + restart Claude.
13
+ #
14
+ # Exits: 0 ok / already current, 2 download failed, 3 unpack failed, 4 no install.
15
+ set -euo pipefail
16
+
17
+ EXT_DIR="$HOME/Library/Application Support/Claude/Claude Extensions/local.mcpb.m13v.social-autoposter"
18
+ MCPB_URL="https://github.com/m13v/social-autoposter/releases/latest/download/social-autoposter.mcpb"
19
+ RELEASE_API="https://api.github.com/repos/m13v/social-autoposter/releases/latest"
20
+ PY="/usr/bin/python3"
21
+
22
+ mode="run"
23
+ case "${1:-}" in
24
+ --check) mode="check" ;;
25
+ --no-restart) mode="no-restart" ;;
26
+ "") mode="run" ;;
27
+ *) echo "unknown flag: $1" >&2; exit 64 ;;
28
+ esac
29
+
30
+ [ -f "$EXT_DIR/manifest.json" ] || { echo "no .mcpb install at $EXT_DIR" >&2; exit 4; }
31
+
32
+ installed="$("$PY" -c "import json,sys;print((json.load(open(sys.argv[1])) or {}).get('version',''))" "$EXT_DIR/manifest.json" 2>/dev/null || true)"
33
+ latest_tag="$(curl -fsSL -m 15 "$RELEASE_API" | "$PY" -c "import sys,json;print((json.load(sys.stdin) or {}).get('tag_name',''))" 2>/dev/null || true)"
34
+ latest="${latest_tag#v}"
35
+ echo "installed=$installed latest=$latest"
36
+
37
+ if [ "$mode" = "check" ]; then
38
+ [ -n "$latest" ] && [ "$installed" != "$latest" ] && echo "update_available=true" || echo "update_available=false"
39
+ exit 0
40
+ fi
41
+
42
+ if [ -n "$latest" ] && [ "$installed" = "$latest" ]; then
43
+ echo "already on latest ($installed); re-applying anyway would just restart Claude. skipping."
44
+ # Comment the next line out if you want a forced re-unpack even when current.
45
+ exit 0
46
+ fi
47
+
48
+ tmpd="$(mktemp -d -t s4l-update-XXXXXX)"
49
+ trap 'rm -rf "$tmpd"' EXIT
50
+ mcpb="$tmpd/social-autoposter.mcpb"
51
+
52
+ echo "downloading $MCPB_URL ..."
53
+ curl -fLs -m 300 "$MCPB_URL" -o "$mcpb" || { echo "download failed" >&2; exit 2; }
54
+ sz=$(stat -f%z "$mcpb" 2>/dev/null || echo 0)
55
+ [ "$sz" -ge 100000 ] || { echo "download too small ($sz bytes), aborting" >&2; exit 2; }
56
+
57
+ echo "unpacking into extension dir ..."
58
+ unzip -oq "$mcpb" -d "$EXT_DIR" || { echo "unpack failed" >&2; exit 3; }
59
+ new_ver="$("$PY" -c "import json,sys;print((json.load(open(sys.argv[1])) or {}).get('version',''))" "$EXT_DIR/manifest.json" 2>/dev/null || true)"
60
+ echo "unpacked version=$new_ver"
61
+
62
+ if [ "$mode" = "no-restart" ]; then
63
+ echo "done (no restart requested); restart Claude to load v$new_ver."
64
+ exit 0
65
+ fi
66
+
67
+ # Restart Claude. From an SSH session we skip the osascript graceful-quit the
68
+ # menu bar uses (it can trip an Automation TCC prompt for sshd and block
69
+ # unattended); killall sends SIGTERM and needs no automation grant.
70
+ echo "restarting Claude ..."
71
+ killall Claude 2>/dev/null || true
72
+ sleep 4
73
+ killall -9 Claude 2>/dev/null || true
74
+ sleep 1
75
+ open -a Claude 2>/dev/null || true
76
+ echo "done; Claude restarting on v$new_ver."
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # s4l-ctl: programmatic control of the running social-autoposter (S4L) plugin via
3
+ # its loopback tool server, for QA. Runs the same handlers as the in-chat MCP
4
+ # tools (POST /tool/<name>). MUST run ON the box (the loopback is 127.0.0.1-only),
5
+ # so over SSH use: ssh macstadium 'bash -s -- <subcommand> [args]' < scripts/s4l_ctl.sh
6
+ #
7
+ # Subcommands:
8
+ # status Dashboard snapshot (read-only).
9
+ # count Number of pending (unposted) drafts (read-only).
10
+ # drafts List pending drafts with their 1-based numbers (read-only).
11
+ # approve <n> [n...] Post the given card numbers. DESTRUCTIVE — requires --yes.
12
+ # approve-all Post EVERY pending card. DESTRUCTIVE — requires --yes.
13
+ #
14
+ # DESTRUCTIVE note: "approve" really posts replies to live X/Twitter threads. The
15
+ # write subcommands refuse to run unless --yes is present (no interactive prompt,
16
+ # because over piped SSH there is no tty). For host-level plugin UPDATE use the
17
+ # separate scripts/s4l_box_update.sh (different mechanism: works even when the
18
+ # loopback is down, and it restarts Claude).
19
+ set -euo pipefail
20
+
21
+ BATCH="review-queue"
22
+ PLAN="/tmp/twitter_cycle_plan_${BATCH}.json"
23
+ EP="$HOME/.social-autoposter-mcp/panel-endpoint.json"
24
+ PY="/usr/bin/python3"
25
+
26
+ YES=0; ARGS=()
27
+ for a in "$@"; do
28
+ case "$a" in --yes|-y) YES=1 ;; *) ARGS+=("$a") ;; esac
29
+ done
30
+ set -- ${ARGS[@]+"${ARGS[@]}"}
31
+ cmd="${1:-}"; [ $# -gt 0 ] && shift || true
32
+
33
+ [ -f "$EP" ] || { echo "no panel-endpoint.json — is Claude Desktop / the MCP running?" >&2; exit 1; }
34
+ URL="$("$PY" -c "import json;print(json.load(open('$EP'))['url'])")"
35
+ curl -s -m 3 "${URL}health" >/dev/null || { echo "loopback unreachable at $URL" >&2; exit 1; }
36
+
37
+ tool() { curl -s -m "${2:-900}" -X POST "${URL}tool/$1" -H 'Content-Type: application/json' -d "${3:-{}}"; }
38
+
39
+ pending_count() {
40
+ [ -f "$PLAN" ] || { echo 0; return; }
41
+ "$PY" -c "import json;d=json.load(open('$PLAN'));print(sum(1 for c in d.get('candidates',[]) if not c.get('posted')))"
42
+ }
43
+
44
+ case "$cmd" in
45
+ status)
46
+ tool dashboard 20 ; echo ;;
47
+ count)
48
+ echo "pending=$(pending_count)" ;;
49
+ drafts)
50
+ if [ ! -f "$PLAN" ]; then echo "no review-queue plan on box (0 drafts)"; exit 0; fi
51
+ "$PY" - "$PLAN" <<'PYEOF'
52
+ import json,sys
53
+ d=json.load(open(sys.argv[1]))
54
+ for i,c in enumerate(d.get("candidates",[]),1):
55
+ if c.get("posted"): continue
56
+ txt=(c.get("reply_text") or "").replace("\n"," ")
57
+ print(f"#{i:<4} @{(c.get('thread_author') or '?'):<18} {txt[:90]}")
58
+ PYEOF
59
+ echo "pending=$(pending_count)" ;;
60
+ approve)
61
+ [ $# -ge 1 ] || { echo "usage: approve <n> [n...] --yes" >&2; exit 64; }
62
+ nums="$(printf '%s\n' "$@" | paste -sd, -)"
63
+ if [ "$YES" != "1" ]; then
64
+ echo "REFUSING: 'approve $*' will POST those cards to live X. Re-run with --yes to confirm." >&2; exit 3; fi
65
+ echo "posting cards [$nums] ..."
66
+ tool post_drafts 900 "{\"batch_id\":\"$BATCH\",\"post\":[$nums]}" ; echo ;;
67
+ approve-all)
68
+ n="$(pending_count)"
69
+ if [ "$YES" != "1" ]; then
70
+ echo "REFUSING: approve-all will POST all $n pending cards to live X. Re-run with --yes to confirm." >&2; exit 3; fi
71
+ echo "posting all $n pending cards ..."
72
+ tool post_drafts 1800 "{\"batch_id\":\"$BATCH\",\"post_all\":true}" ; echo ;;
73
+ *)
74
+ echo "usage: s4l_ctl.sh {status|count|drafts|approve <n...>|approve-all} [--yes]" >&2; exit 64 ;;
75
+ esac
@@ -1209,6 +1209,37 @@ def cmd_connect(args) -> dict:
1209
1209
  }
1210
1210
 
1211
1211
 
1212
+ def cmd_resolve_handle(args) -> dict:
1213
+ """Read the live logged-in @handle from the managed Chrome and persist it to
1214
+ config.json accounts.twitter.handle.
1215
+
1216
+ The MCP post preflight calls this to self-heal a missing handle — the onboarding
1217
+ gap where connect_x's best-effort live-DOM read silently no-op'd, leaving the
1218
+ install logged in but with accounts:null, so twitter_browser.py refused EVERY
1219
+ reply with no_account_configured. Reading the handle from the SAME session the
1220
+ poster posts through is ground truth, not a guess, so it's safe where a hardcoded
1221
+ fallback would not be. Best-effort: returns state=browser_not_running / no_handle
1222
+ on failure and never raises."""
1223
+ try:
1224
+ ws, send = _attach()
1225
+ except Exception as e:
1226
+ return {"ok": False, "state": "browser_not_running", "error": str(e)}
1227
+ handle = None
1228
+ try:
1229
+ handle = _resolve_live_handle(send)
1230
+ except Exception:
1231
+ handle = None
1232
+ finally:
1233
+ try:
1234
+ ws.close()
1235
+ except Exception:
1236
+ pass
1237
+ if not handle:
1238
+ return {"ok": False, "state": "no_handle"}
1239
+ persisted = _write_handle_to_config(handle)
1240
+ return {"ok": True, "state": "resolved", "handle": handle, "persisted": persisted}
1241
+
1242
+
1212
1243
  def main() -> int:
1213
1244
  ap = argparse.ArgumentParser(description="Twitter/X session bootstrap for MCP setup.")
1214
1245
  sub = ap.add_subparsers(dest="cmd", required=True)
@@ -1216,6 +1247,10 @@ def main() -> int:
1216
1247
  sub.add_parser("detect-sources",
1217
1248
  help="List browsers/profiles to import the X session from "
1218
1249
  "(JSON, for the panel dropdown). No keychain prompt.")
1250
+ sub.add_parser("resolve-handle",
1251
+ help="Read the live logged-in @handle from the managed Chrome and "
1252
+ "persist it to config.json accounts.twitter.handle. Idempotent "
1253
+ "self-heal for the post preflight; never overwrites a real handle.")
1219
1254
  c = sub.add_parser("connect", help="Ensure browser + import/validate the X session.")
1220
1255
  c.add_argument("--source", default=None,
1221
1256
  help="Browser profile to import from (e.g. chrome:Default, arc:Default), "
@@ -1244,6 +1279,8 @@ def main() -> int:
1244
1279
  out = {"ok": False, "state": "error", "error": _WEBSOCKET_IMPORT_ERROR}
1245
1280
  elif args.cmd == "status":
1246
1281
  out = cmd_status(args)
1282
+ elif args.cmd == "resolve-handle":
1283
+ out = cmd_resolve_handle(args)
1247
1284
  else:
1248
1285
  out = cmd_connect(args)
1249
1286
  print(json.dumps(out, indent=2))
@@ -0,0 +1,123 @@
1
+ """Headless logic test for per-card serialized posting in the menu bar.
2
+
3
+ Stubs the heavy deps (rumps / sentry_init / s4l_state) so s4l_menubar imports
4
+ without AppKit, then drives the REAL _on_card_decision + _post_worker_loop on an
5
+ instance built via object.__new__ (bypassing rumps.App.__init__). Verifies:
6
+ 1. posts run strictly one-at-a-time (no overlap on the shared browser)
7
+ 2. order is preserved (FIFO)
8
+ 3. plain approvals -> post=[n]; edited approvals -> edits=[{n,text}]
9
+ 4. rejected cards never post
10
+ 5. _posts_outstanding / _review_active settle to idle once drained
11
+ """
12
+ import os
13
+ import queue
14
+ import sys
15
+ import threading
16
+ import time
17
+ import types
18
+
19
+ HERE = os.path.join(os.path.dirname(__file__), "..", "mcp", "menubar")
20
+ sys.path.insert(0, os.path.abspath(HERE))
21
+
22
+ # --- stub heavy deps so the import is headless --------------------------------
23
+ rumps = types.ModuleType("rumps")
24
+ class _App:
25
+ def __init__(self, *a, **k):
26
+ pass
27
+ rumps.App = _App
28
+ rumps.Timer = lambda *a, **k: types.SimpleNamespace(start=lambda: None, stop=lambda: None)
29
+ rumps.MenuItem = lambda *a, **k: object()
30
+ rumps.separator = object()
31
+ rumps.notification = lambda *a, **k: None
32
+ sys.modules["rumps"] = rumps
33
+
34
+ sentry_init = types.ModuleType("sentry_init")
35
+ sentry_init.init_sentry = lambda *a, **k: None
36
+ sentry_init.capture = lambda *a, **k: None
37
+ sys.modules["sentry_init"] = sentry_init
38
+
39
+ # Track concurrency + record every post_drafts call.
40
+ overlap_detected = []
41
+ inflight = {"n": 0}
42
+ inflight_lock = threading.Lock()
43
+ calls = []
44
+
45
+ def fake_post_drafts(batch_id, post=None, edits=None, timeout=900):
46
+ with inflight_lock:
47
+ inflight["n"] += 1
48
+ if inflight["n"] > 1:
49
+ overlap_detected.append(True)
50
+ calls.append({"batch": batch_id, "post": post or [], "edits": edits or []})
51
+ time.sleep(0.15) # simulate a slow post so overlaps would be caught
52
+ with inflight_lock:
53
+ inflight["n"] -= 1
54
+ # mimic the real shape: posted count
55
+ n_posted = len(post or []) + len(edits or [])
56
+ return {"posted": n_posted}
57
+
58
+ st = types.ModuleType("s4l_state")
59
+ st.post_drafts = fake_post_drafts
60
+ st.accessibility_trusted = lambda: True
61
+ st.clear_review_request = lambda: None
62
+ sys.modules["s4l_state"] = st
63
+
64
+ import s4l_menubar # noqa: E402
65
+
66
+ # --- build an instance without running rumps.App.__init__ ---------------------
67
+ app = object.__new__(s4l_menubar.S4LMenuBar)
68
+ app._post_q = queue.Queue()
69
+ app._post_worker = None
70
+ app._review_lock = threading.Lock()
71
+ app._panel_open = True
72
+ app._posts_outstanding = 0
73
+ app._review_active = False
74
+ app._notify = lambda title, msg: None # silence Notification Center
75
+
76
+ BATCH = "review-queue"
77
+
78
+ # Approve a quick burst (as if the user clicked Approve on several cards fast),
79
+ # one edited, plus a rejected card that must NOT post.
80
+ decisions = [
81
+ {"n": 1, "approved": True, "text": "reply one", "edited": False},
82
+ {"n": 2, "approved": True, "text": "edited two", "edited": True},
83
+ {"n": 3, "approved": False, "text": "skip", "edited": False},
84
+ {"n": 4, "approved": True, "text": "reply four", "edited": False},
85
+ ]
86
+ for d in decisions:
87
+ app._on_card_decision(BATCH, d)
88
+ time.sleep(0.02) # tight succession -> overlap would happen if not serialized
89
+
90
+ # Panel closes while posts may still be draining.
91
+ app._on_review_closed(BATCH, decisions)
92
+
93
+ # Wait for the queue to drain.
94
+ deadline = time.time() + 10
95
+ while time.time() < deadline:
96
+ with app._review_lock:
97
+ if app._posts_outstanding == 0 and app._post_q.empty():
98
+ break
99
+ time.sleep(0.05)
100
+
101
+ # --- assertions ---------------------------------------------------------------
102
+ fail = []
103
+ if overlap_detected:
104
+ fail.append(f"posts overlapped ({len(overlap_detected)} times) — not serialized")
105
+ posted_ns = [(c["post"], c["edits"]) for c in calls]
106
+ expected = [([1], []), ([], [{"n": 2, "text": "edited two"}]), ([4], [])]
107
+ if posted_ns != expected:
108
+ fail.append(f"wrong calls/order: got {posted_ns}\n expected {expected}")
109
+ if any(c["post"] == [3] or any(e.get("n") == 3 for e in c["edits"]) for c in calls):
110
+ fail.append("rejected card #3 was posted")
111
+ with app._review_lock:
112
+ if app._posts_outstanding != 0:
113
+ fail.append(f"_posts_outstanding leaked: {app._posts_outstanding}")
114
+ if app._review_active:
115
+ fail.append("_review_active stuck true after drain + panel closed")
116
+
117
+ if fail:
118
+ print("FAIL:")
119
+ for f in fail:
120
+ print(" -", f)
121
+ sys.exit(1)
122
+ print("PASS: 3 posts, serialized, FIFO order, #3 skipped, flags settled idle.")
123
+ print(" calls:", posted_ns)