social-autoposter 1.6.84 → 1.6.86

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
@@ -22,7 +22,7 @@ import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from
22
22
  import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, } from "./runtime.js";
23
23
  import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
24
24
  import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
25
- import { initSentry, sendHeartbeat, captureError, flushSentry, startLogStreaming, flushLogs } from "./telemetry.js";
25
+ import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
26
26
  import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import http from "node:http";
@@ -616,7 +616,6 @@ async function postApproved(batchId, plan) {
616
616
  });
617
617
  void flushSentry(2000);
618
618
  }
619
- void flushLogs();
620
619
  return {
621
620
  attempted: approved.length,
622
621
  posted: realPosted,
@@ -2384,11 +2383,6 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
2384
2383
  }));
2385
2384
  async function main() {
2386
2385
  initSentry();
2387
- // Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
2388
- // backend so we can troubleshoot/rescue any user scenario (silent stalls,
2389
- // partial onboarding) without asking them to ship a log file. Best-effort;
2390
- // disabled with SAPS_LOG_STREAM=0.
2391
- startLogStreaming();
2392
2386
  // A plugin UPDATE refreshes this server (dist/) but not the materialized
2393
2387
  // pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
2394
2388
  // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
@@ -2439,7 +2433,6 @@ async function main() {
2439
2433
  main().catch(async (err) => {
2440
2434
  console.error("[social-autoposter-mcp] fatal:", err);
2441
2435
  captureError(err, { component: "main" });
2442
- await flushLogs();
2443
2436
  await flushSentry();
2444
2437
  process.exit(1);
2445
2438
  });
package/mcp/dist/repo.js CHANGED
@@ -30,27 +30,6 @@ 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
- }
54
33
  // Spawn a process inside the repo, inheriting the repo env (API base + keys
55
34
  // come from the install's environment / .env loaded by the scripts themselves).
56
35
  //
@@ -94,29 +73,6 @@ export function run(cmd, args, opts = {}) {
94
73
  }
95
74
  return buf;
96
75
  };
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
- };
120
76
  let timer;
121
77
  if (opts.timeoutMs) {
122
78
  timer = setTimeout(() => {
@@ -127,13 +83,11 @@ export function run(cmd, args, opts = {}) {
127
83
  const s = d.toString();
128
84
  stdout += s;
129
85
  outBuf = pump(s, "stdout", outBuf);
130
- sinkOutBuf = sinkPump(s, "stdout", sinkOutBuf);
131
86
  });
132
87
  child.stderr.on("data", (d) => {
133
88
  const s = d.toString();
134
89
  stderr += s;
135
90
  errBuf = pump(s, "stderr", errBuf);
136
- sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
137
91
  });
138
92
  child.on("close", (code) => {
139
93
  if (timer)
@@ -155,23 +109,6 @@ export function run(cmd, args, opts = {}) {
155
109
  /* ignore */
156
110
  }
157
111
  }
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
- }
175
112
  resolve({ code: code ?? -1, stdout, stderr });
176
113
  });
177
114
  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, setLineSink } from "./repo.js";
12
+ import { repoDir, runPython } 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,129 +102,3 @@ 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 backend, so we can troubleshoot and
108
- // rescue any user scenario without asking them to ship a log file. Lines are
109
- // buffered in memory and flushed in small batches under the same X-Installation
110
- // identity the heartbeat uses. Best-effort: this NEVER throws into the server,
111
- // never blocks the child's I/O, and drops on overflow rather than growing
112
- // unbounded. Disable with SAPS_LOG_STREAM=0.
113
- const LOG_STREAM_ENABLED = process.env.SAPS_LOG_STREAM !== "0";
114
- const LOG_MAX_LINE_LEN = 8192; // mirror the backend cap
115
- const LOG_MAX_BUFFER = 1000; // drop oldest beyond this (overflow protection)
116
- const LOG_FLUSH_BATCH = 100; // flush eagerly once we have this many lines
117
- const LOG_MAX_PER_POST = 200; // backend accepts 1-200 per request
118
- const LOG_FLUSH_MS = 3000; // otherwise flush on this cadence
119
- const logBuffer = [];
120
- let logDropped = 0; // count of lines dropped on overflow (surfaced periodically)
121
- let logFlushing = false;
122
- let logTimer;
123
- let cachedInstallHeader = null;
124
- let logStreamingStarted = false;
125
- async function installHeader() {
126
- if (cachedInstallHeader)
127
- return cachedInstallHeader;
128
- try {
129
- const idScript = path.join(repoDir(), "scripts", "identity.py");
130
- if (!fs.existsSync(idScript))
131
- return null;
132
- const res = await runPython("scripts/identity.py", ["header"], { timeoutMs: 10_000 });
133
- const header = (res.stdout || "").trim();
134
- if (res.code === 0 && header) {
135
- cachedInstallHeader = header;
136
- return header;
137
- }
138
- }
139
- catch {
140
- /* best-effort */
141
- }
142
- return null;
143
- }
144
- // Buffer one raw line. Called from the repo.ts line sink, so it must be cheap
145
- // and total non-throwing.
146
- export function logLine(stream, line, context) {
147
- if (!LOG_STREAM_ENABLED)
148
- return;
149
- try {
150
- logBuffer.push({
151
- ts: new Date().toISOString(),
152
- stream,
153
- line: line.length > LOG_MAX_LINE_LEN ? line.slice(0, LOG_MAX_LINE_LEN) : line,
154
- context: context || "",
155
- });
156
- if (logBuffer.length > LOG_MAX_BUFFER) {
157
- // Drop oldest to bound memory; the newest lines are the most useful.
158
- logDropped += logBuffer.length - LOG_MAX_BUFFER;
159
- logBuffer.splice(0, logBuffer.length - LOG_MAX_BUFFER);
160
- }
161
- if (logBuffer.length >= LOG_FLUSH_BATCH)
162
- void flushLogs();
163
- }
164
- catch {
165
- /* never throw into the run() boundary */
166
- }
167
- }
168
- export async function flushLogs() {
169
- if (!LOG_STREAM_ENABLED)
170
- return;
171
- if (logFlushing || logBuffer.length === 0)
172
- return;
173
- logFlushing = true;
174
- try {
175
- const header = await installHeader();
176
- if (!header)
177
- return; // runtime not unpacked yet; keep buffering
178
- const base = (process.env.AUTOPOSTER_API_BASE || "https://s4l.ai").replace(/\/+$/, "");
179
- // Drain in <=200-line POSTs until the buffer empties (or a POST fails).
180
- while (logBuffer.length > 0) {
181
- const batch = logBuffer.splice(0, LOG_MAX_PER_POST);
182
- const lines = batch.map((b) => ({
183
- ts: b.ts,
184
- stream: b.stream,
185
- line: b.line,
186
- context: b.context || undefined,
187
- }));
188
- try {
189
- const resp = await fetch(`${base}/api/v1/installations/logs`, {
190
- method: "POST",
191
- headers: { "X-Installation": header, "content-type": "application/json" },
192
- body: JSON.stringify({ lines }),
193
- signal: AbortSignal.timeout(15_000),
194
- });
195
- if (!resp.ok) {
196
- // Drop this batch (don't re-buffer): a persistent 4xx/5xx would grow
197
- // the buffer unbounded. The raw stream is best-effort.
198
- console.error(`[social-autoposter-mcp] log flush http ${resp.status}`);
199
- break;
200
- }
201
- }
202
- catch (err) {
203
- // Network blip: drop this batch, stop draining, try again next tick.
204
- console.error("[social-autoposter-mcp] log flush failed:", err?.message || err);
205
- break;
206
- }
207
- }
208
- if (logDropped > 0) {
209
- console.error(`[social-autoposter-mcp] log stream dropped ${logDropped} line(s) on overflow`);
210
- logDropped = 0;
211
- }
212
- }
213
- finally {
214
- logFlushing = false;
215
- }
216
- }
217
- // Register the repo.ts line sink and start the periodic flush. Idempotent.
218
- export function startLogStreaming() {
219
- if (!LOG_STREAM_ENABLED || logStreamingStarted)
220
- return;
221
- logStreamingStarted = true;
222
- try {
223
- setLineSink((line, stream, context) => logLine(stream, line, context));
224
- logTimer = setInterval(() => void flushLogs(), LOG_FLUSH_MS);
225
- logTimer.unref();
226
- }
227
- catch (err) {
228
- console.error("[social-autoposter-mcp] log streaming start failed:", err?.message || err);
229
- }
230
- }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.84",
3
- "installedAt": "2026-06-23T02:53:37.786Z"
2
+ "version": "1.6.86",
3
+ "installedAt": "2026-06-23T16:16:31.100Z"
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.84",
5
+ "version": "1.6.86",
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.84",
3
+ "version": "1.6.86",
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.84",
3
+ "version": "1.6.86",
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,60 @@
1
+ import json, os, sys, time, subprocess, signal
2
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
3
+
4
+ LOCK = os.path.expanduser("~/.claude/twitter-browser-lock.json")
5
+ os.makedirs(os.path.dirname(LOCK), exist_ok=True)
6
+
7
+ def write_lock(pid, role):
8
+ with open(LOCK, "w") as f:
9
+ json.dump({"session_id": f"python:{pid}", "timestamp": int(time.time()), "role": role}, f)
10
+
11
+ def clean():
12
+ try: os.remove(LOCK)
13
+ except OSError: pass
14
+
15
+ # ---- TEST 1: poster preempts a LIVE scan holder ----
16
+ clean()
17
+ victim = subprocess.Popen(["sleep", "120"])
18
+ write_lock(victim.pid, "scan")
19
+ os.environ["SAPS_LOCK_ROLE"] = "post"
20
+ import importlib, twitter_browser
21
+ importlib.reload(twitter_browser)
22
+ t0 = time.time()
23
+ twitter_browser._acquire_browser_lock()
24
+ dt = time.time() - t0
25
+ held = json.load(open(LOCK))
26
+ victim_alive = victim.poll() is None
27
+ print(f"TEST1 poster-preempts-scan: took={dt:.1f}s holder_now={held['session_id']} role={held.get('role')} victim_alive={victim_alive}")
28
+ assert held["session_id"] == f"python:{os.getpid()}", "poster should hold lock"
29
+ assert held.get("role") == "post"
30
+ assert not victim_alive, "scan victim should be killed"
31
+ assert dt < 10, "should preempt fast, not wait 45s"
32
+ if victim.poll() is None: victim.kill()
33
+ print("TEST1 PASS")
34
+
35
+ # ---- TEST 2: scanner does NOT preempt a live POST holder (waits then gives up) ----
36
+ clean()
37
+ poster = subprocess.Popen(["sleep", "120"])
38
+ write_lock(poster.pid, "post")
39
+ os.environ["SAPS_LOCK_ROLE"] = "scan"
40
+ twitter_browser.LOCK_WAIT_MAX = 4 # shorten the give-up window for the test
41
+ twitter_browser.LOCK_ROLE = "scan"
42
+ twitter_browser._LOCK_SESSION_ID = f"python:{os.getpid()}"
43
+ twitter_browser._LOCK_INHERITED = False
44
+ import io, contextlib
45
+ t0 = time.time()
46
+ rc = None
47
+ try:
48
+ twitter_browser._acquire_browser_lock()
49
+ rc = "acquired"
50
+ except SystemExit as e:
51
+ rc = f"exit:{e.code}"
52
+ dt = time.time() - t0
53
+ poster_alive = poster.poll() is None
54
+ print(f"TEST2 scanner-vs-post: result={rc} took={dt:.1f}s poster_alive={poster_alive}")
55
+ assert rc == "exit:1", "scanner must NOT take a live post holder; should give up"
56
+ assert poster_alive, "scanner must NOT kill the poster"
57
+ if poster.poll() is None: poster.kill()
58
+ print("TEST2 PASS")
59
+ clean()
60
+ print("ALL PASS")
@@ -173,7 +173,11 @@ window.__sapsPaint = function(payload){
173
173
  // coordinates (Input.dispatchMouseEvent) and by Playwright hit-testing,
174
174
  // both of which an opaque clickable card sitting over a target would eat.
175
175
  s.position="fixed"; s.top="50%"; s.left="50%"; s.transform="translate(-50%,-50%)";
176
- s.zIndex="2147483647"; s.pointerEvents="none"; s.maxWidth="460px";
176
+ // Sit one below the announce modal (2147483647) so the one-time "S4L is
177
+ // running" notice + its OK button always stack ON TOP of this always-on
178
+ // status box. They're both screen-centered, so equal z-index would let
179
+ // whichever was appended last (this overlay) cover the OK button.
180
+ s.zIndex="2147483646"; s.pointerEvents="none"; s.maxWidth="460px";
177
181
  s.boxSizing="border-box"; s.padding="10px 14px"; s.borderRadius="12px";
178
182
  s.background="rgba(15,15,17,0.92)"; s.color="#fff";
179
183
  s.font="13px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif";
@@ -42,6 +42,8 @@ Exit codes:
42
42
  2 — argparse / IO failure before we could write any JSON
43
43
  """
44
44
 
45
+ from __future__ import annotations
46
+
45
47
  import argparse
46
48
  import json
47
49
  import os
@@ -56,6 +58,62 @@ REPO_DIR = os.path.expanduser("~/social-autoposter")
56
58
  RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
57
59
  CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
58
60
 
61
+ # --- X/Twitter length budget -------------------------------------------------
62
+ # X charges a FLAT 23 characters for any http/https URL (t.co wrapping),
63
+ # regardless of the link's real length. So the budget is fixed: text + 23 <= 280
64
+ # => at most 257 characters of text before the link. We only enforce this for
65
+ # twitter; reddit/linkedin have far larger ceilings and need no tail trim.
66
+ TWEET_LIMIT = 280
67
+ URL_WEIGHT = 23
68
+ TWITTER_TEXT_BUDGET = TWEET_LIMIT - URL_WEIGHT # 257 chars for everything but the URL
69
+ _URL_RE = re.compile(r"https?://\S+")
70
+
71
+
72
+ def x_weighted_len(text: str) -> int:
73
+ """Character count the way X computes it: every URL counts as 23."""
74
+ if not text:
75
+ return 0
76
+ return len(_URL_RE.sub("x" * URL_WEIGHT, text))
77
+
78
+
79
+ def _trim_to_chars(s: str, max_chars: int) -> str:
80
+ """Trim `s` to at most `max_chars`, backing off to a word boundary so we
81
+ never chop mid-word, then stripping trailing punctuation/space."""
82
+ s = s.strip()
83
+ if max_chars <= 0:
84
+ return ""
85
+ if len(s) <= max_chars:
86
+ return s
87
+ cut = s[:max_chars].rstrip()
88
+ sp = cut.rfind(" ")
89
+ # only back off to the word boundary when it doesn't gut more than half
90
+ if sp > max_chars * 0.5:
91
+ cut = cut[:sp]
92
+ return cut.rstrip(" ,;:-")
93
+
94
+
95
+ def enforce_budget(text: str, link_url: str,
96
+ limit: int = TWEET_LIMIT) -> tuple[str, bool]:
97
+ """Guarantee x_weighted_len(text) <= limit by trimming the BODY, never the
98
+ link. The link is the most important part of the reply and always stays at
99
+ the end. Returns (text, was_trimmed)."""
100
+ if x_weighted_len(text) <= limit:
101
+ return text, False
102
+ if link_url and link_url in text:
103
+ head, _, tail = text.rpartition(link_url)
104
+ head = head.rstrip()
105
+ tail = tail.strip() # normally empty; the URL ends the reply
106
+ # Budget left for the body after reserving the URL (23) + a joining
107
+ # space + any (rare) trailing chars after the URL.
108
+ max_head = limit - URL_WEIGHT - 1 - len(tail)
109
+ trimmed_head = _trim_to_chars(head, max_head)
110
+ joined = (trimmed_head + " " + link_url).strip()
111
+ if tail:
112
+ joined = (joined + " " + tail).strip()
113
+ return joined, True
114
+ # No link present (shouldn't happen on the twitter path): hard word-trim.
115
+ return _trim_to_chars(text, limit), True
116
+
59
117
 
60
118
  def resolve_voice_relationship(project: str) -> str:
61
119
  """Look up the matched project's `voice_relationship` field in config.json.
@@ -151,6 +209,26 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
151
209
  f" - \"we ship the same recall-on-revisit pattern in {project}, scores against a 4-axis rubric, {link_url}\""
152
210
  )
153
211
 
212
+ # X-specific hard length budget. Pass the agent the current body length AND
213
+ # the budget so it self-fits by compressing the body, keeping the link at
214
+ # the end. Other platforms (reddit/linkedin) have far larger ceilings, so we
215
+ # add no tight cap there.
216
+ length_rule = ""
217
+ if platform == "twitter":
218
+ body_len = len(reply_text or "")
219
+ length_rule = (
220
+ f"HARD LENGTH LIMIT (X counts EVERY link as exactly 23 characters, "
221
+ f"no matter how long it looks):\n"
222
+ f"- The entire final reply must be \u2264 {TWEET_LIMIT} characters with "
223
+ f"the URL counted as {URL_WEIGHT}. That means everything EXCEPT the "
224
+ f"URL must total \u2264 {TWITTER_TEXT_BUDGET} characters.\n"
225
+ f"- The drafted body is currently {body_len} characters. If body + "
226
+ f"bridge would exceed {TWITTER_TEXT_BUDGET} chars of text, COMPRESS "
227
+ f"the body to make room: tighten wording, drop the weakest clause, "
228
+ f"but keep the single strongest claim and keep the URL at the very "
229
+ f"end. Never drop or move the link.\n"
230
+ )
231
+
154
232
  return f"""You are writing the FINAL bridge sentence that folds a product link into a social media reply we already drafted. This is a one-shot task. Output ONLY the bridge sentence (no preamble, no explanation, no quotes).
155
233
 
156
234
  PLATFORM: {platform}
@@ -159,6 +237,8 @@ LANDING PAGE URL: {link_url}
159
237
 
160
238
  {voice_rule}
161
239
 
240
+ {length_rule}
241
+
162
242
  ORIGINAL THREAD WE ARE REPLYING TO:
163
243
  {thread_text}
164
244
 
@@ -289,7 +369,8 @@ THIRD_PARTY_VOICE_VIOLATIONS = (
289
369
 
290
370
 
291
371
  def passes_quality_gate(final_text: str, link_url: str,
292
- voice_relationship: str = "first_party"
372
+ voice_relationship: str = "first_party",
373
+ limit: int | None = None
293
374
  ) -> tuple[bool, str]:
294
375
  """Return (passes, reason_if_not).
295
376
 
@@ -327,6 +408,12 @@ def passes_quality_gate(final_text: str, link_url: str,
327
408
  # Length sanity: model returning a 5-word stub is a fail.
328
409
  if len(final_text.split()) < 8:
329
410
  return (False, "too_short")
411
+ # Upper-length backstop (twitter): X-weighted length must fit the cap. The
412
+ # caller trims the body to fit BEFORE the gate, so this should only trip on
413
+ # a degenerate trim; on trip we fall back to the (also budget-enforced)
414
+ # mechanical concat.
415
+ if limit is not None and x_weighted_len(final_text) > limit:
416
+ return (False, f"too_long:{x_weighted_len(final_text)}>{limit}")
330
417
  return (True, "")
331
418
 
332
419
 
@@ -375,6 +462,8 @@ def main() -> int:
375
462
  return 0
376
463
 
377
464
  voice_relationship = args.voice_relationship or resolve_voice_relationship(args.project)
465
+ # Length cap is X-specific; reddit/linkedin pass None (no tail trim).
466
+ limit = TWEET_LIMIT if args.platform == "twitter" else None
378
467
  prompt = build_prompt(
379
468
  reply_text=reply_text, link_url=link_url,
380
469
  thread_text=(args.thread_text or "").strip()[:2000],
@@ -388,12 +477,16 @@ def main() -> int:
388
477
  elapsed = round(time.time() - started, 2)
389
478
 
390
479
  if not ok:
480
+ fb_text, fb_trim = enforce_budget(
481
+ mechanical_fallback(reply_text, link_url), link_url,
482
+ limit if limit is not None else TWEET_LIMIT * 100)
391
483
  out = {
392
484
  "ok": True,
393
- "text": mechanical_fallback(reply_text, link_url),
485
+ "text": fb_text,
394
486
  "tail": link_url,
395
487
  "model_call_ok": False,
396
488
  "fallback_used": True,
489
+ "budget_trimmed": fb_trim,
397
490
  "error": err or "model_call_failed",
398
491
  "elapsed_sec": elapsed,
399
492
  }
@@ -401,15 +494,26 @@ def main() -> int:
401
494
  return 0
402
495
 
403
496
  cleaned = clean_output(raw)
497
+ # Trim the body to fit BEFORE the gate so a good model bridge is preserved
498
+ # (we trim the body, never the link) instead of being thrown away for being
499
+ # a few chars over. No-op on non-twitter (limit is None).
500
+ budget_trimmed = False
501
+ if limit is not None:
502
+ cleaned, budget_trimmed = enforce_budget(cleaned, link_url, limit)
404
503
  passes, reason = passes_quality_gate(cleaned, link_url,
405
- voice_relationship=voice_relationship)
504
+ voice_relationship=voice_relationship,
505
+ limit=limit)
406
506
  if not passes:
507
+ fb_text, fb_trim = enforce_budget(
508
+ mechanical_fallback(reply_text, link_url), link_url,
509
+ limit if limit is not None else TWEET_LIMIT * 100)
407
510
  out = {
408
511
  "ok": True,
409
- "text": mechanical_fallback(reply_text, link_url),
512
+ "text": fb_text,
410
513
  "tail": link_url,
411
514
  "model_call_ok": True,
412
515
  "fallback_used": True,
516
+ "budget_trimmed": fb_trim,
413
517
  "error": f"quality_gate_failed:{reason}",
414
518
  "raw_model_output": raw[:500],
415
519
  "elapsed_sec": elapsed,
@@ -436,6 +540,7 @@ def main() -> int:
436
540
  "tail": tail,
437
541
  "model_call_ok": True,
438
542
  "fallback_used": False,
543
+ "budget_trimmed": budget_trimmed,
439
544
  "elapsed_sec": elapsed,
440
545
  }
441
546
  print(json.dumps(out), flush=True)
@@ -122,30 +122,40 @@ def parse_urn_ids(*sources):
122
122
 
123
123
  VALID_PLATFORMS = ("reddit", "twitter", "linkedin", "github_issues", "moltbook")
124
124
 
125
- DEFAULT_ACCOUNTS = {
126
- "reddit": "Deep_Ad1959",
127
- "linkedin": "Matthew Diakonov",
128
- "github_issues": "m13v",
129
- "moltbook": "matthew-autoposter",
125
+ # Maps log_post's platform strings to account_resolver's platform keys.
126
+ # (log_post uses "github_issues"; account_resolver uses "github".)
127
+ _RESOLVER_PLATFORM = {
128
+ "twitter": "twitter",
129
+ "reddit": "reddit",
130
+ "linkedin": "linkedin",
131
+ "github_issues": "github",
132
+ "moltbook": "moltbook",
130
133
  }
131
134
 
132
135
 
133
136
  def _resolve_default_account(platform: str) -> str:
134
- """Return the canonical account handle for `platform` on this machine.
137
+ """Return the configured account handle for `platform` on this machine.
135
138
 
136
- Twitter is multi-machine-aware: each machine declares its own handle via
137
- `AUTOPOSTER_TWITTER_HANDLE` env or `accounts.twitter.handle` in
138
- config.json, and `twitter_account.resolve_handle()` returns the
139
- normalized form (no `@` prefix). Other platforms keep their static
140
- defaults until each gets the same multi-machine treatment.
139
+ Resolved ONLY from env (`AUTOPOSTER_<PLATFORM>_*`) or config.json
140
+ (`accounts.<platform>.*`) via account_resolver. There are NO hardcoded
141
+ handle fallbacks: a misconfigured install must never silently post under
142
+ another person's identity. The old per-platform defaults
143
+ (m13v_/Deep_Ad1959/Matthew Diakonov/m13v/matthew-autoposter) did exactly
144
+ that, stamping every unconfigured install's rows with the repo owner's
145
+ handle and polluting the shared DB across accounts.
141
146
 
142
- Returns empty string when nothing is configured; the caller's
147
+ Returns "" when nothing is configured; the caller's
143
148
  `args.account or _resolve_default_account(...)` chain still lets an
144
- explicit `--account` flag win.
149
+ explicit `--account` flag win, and an empty value surfaces the misconfig
150
+ instead of impersonating someone.
145
151
  """
146
- if platform == "twitter":
147
- return resolve_twitter_handle() or ""
148
- return DEFAULT_ACCOUNTS.get(platform, "")
152
+ try:
153
+ import account_resolver
154
+ return account_resolver.resolve(
155
+ _RESOLVER_PLATFORM.get(platform, platform)
156
+ ) or ""
157
+ except Exception:
158
+ return ""
149
159
 
150
160
 
151
161
  def coerce_engagement_style(args):
@@ -1,21 +1,35 @@
1
1
  #!/usr/bin/env bash
2
- # One-command release pipeline for the social-autoposter .mcpb (Story B double-click install).
2
+ # THE single release flow for social-autoposter. One command does EVERYTHING and
3
+ # keeps the npm path (Story A) and the .mcpb double-click path (Story B) on one
4
+ # version, because both derive from one bumped repo-root package.json.
3
5
  #
4
6
  # What it does, end to end:
5
- # 1. Resolve the release version from the repo-root package.json (or --tag override).
6
- # 2. Build the MCP bundle: vite (panel) + tsc (server) + bundle-pipeline (embeds npm tarball).
7
- # 3. Stamp mcp/dist/version.json so the bundle self-reports the right version.
7
+ # 1. Bump the repo-root package.json (the SINGLE source of truth) and lockfile.
8
+ # Default: patch bump. --bump minor|major, or pin with --version / --tag,
9
+ # or --no-bump to re-release the current version as-is.
10
+ # 2. Build the MCP bundle: vite (panel) + tsc (server) + bundle-pipeline. The
11
+ # embedded pipeline.tgz is `npm pack` of the (now-bumped) repo, so the shell
12
+ # and the bundled Python pipeline CANNOT diverge.
13
+ # 3. Stamp dist/version.json + manifest.json + mcp/package.json(+lock) to match.
8
14
  # 4. Pack mcp/ into mcp/social-autoposter.mcpb via the mcpb CLI.
9
- # 5. Verify the bundle (size cap, embedded pipeline.tgz, stamped version, install tools).
10
- # 6. Create (or update) the GitHub release vX.Y.Z and upload the .mcpb as an asset.
15
+ # 5. Verify: size cap, embedded pipeline.tgz present, version.json + manifest +
16
+ # the pipeline.tgz's OWN internal version all == VERSION (the guard that was
17
+ # missing when 1.6.84 shipped a 1.6.83 pipeline), install tools present.
18
+ # 6. npm publish social-autoposter@VERSION (idempotent; skipped if already live).
19
+ # 7. Create/update GitHub release vX.Y.Z and upload the .mcpb (--clobber).
11
20
  #
12
- # Re-running for the same version is idempotent: the asset is re-uploaded with --clobber.
21
+ # Boxes self-update from the GitHub release: the menu-bar updater polls
22
+ # releases/latest, pulls the new .mcpb, and on next server boot
23
+ # ensurePipelineCurrent() re-extracts pipeline.tgz. No manual box step needed.
13
24
  #
14
25
  # Usage:
15
- # bash scripts/release-mcpb.sh # version from package.json
16
- # bash scripts/release-mcpb.sh --tag v1.6.56
17
- # bash scripts/release-mcpb.sh --no-release # build + pack + verify only, skip GitHub
18
- # bash scripts/release-mcpb.sh --draft # create the GitHub release as a draft
26
+ # bash scripts/release-mcpb.sh # patch bump, npm + .mcpb + GitHub
27
+ # bash scripts/release-mcpb.sh --bump minor
28
+ # bash scripts/release-mcpb.sh --version 1.7.0 # pin an exact version
29
+ # bash scripts/release-mcpb.sh --no-bump # re-release current package.json version
30
+ # bash scripts/release-mcpb.sh --no-npm # skip npm publish (only .mcpb + GitHub)
31
+ # bash scripts/release-mcpb.sh --no-release # build + pack + verify only (no npm, no GitHub)
32
+ # bash scripts/release-mcpb.sh --draft # GitHub release as a draft
19
33
 
20
34
  set -euo pipefail
21
35
 
@@ -29,38 +43,82 @@ GH_REPO="m13v/social-autoposter"
29
43
  SIZE_CAP_MB=180
30
44
 
31
45
  TAG_OVERRIDE=""
46
+ VERSION_OVERRIDE=""
32
47
  DO_RELEASE=1
48
+ DO_NPM=1
49
+ DO_BUMP=1
50
+ BUMP_LEVEL="patch"
33
51
  DRAFT_FLAG=""
34
52
 
35
53
  while [[ $# -gt 0 ]]; do
36
54
  case "$1" in
37
55
  --tag) TAG_OVERRIDE="$2"; shift 2 ;;
38
56
  --tag=*) TAG_OVERRIDE="${1#*=}"; shift ;;
57
+ --version) VERSION_OVERRIDE="$2"; shift 2 ;;
58
+ --version=*) VERSION_OVERRIDE="${1#*=}"; shift ;;
59
+ --bump) BUMP_LEVEL="$2"; shift 2 ;;
60
+ --bump=*) BUMP_LEVEL="${1#*=}"; shift ;;
61
+ --no-bump) DO_BUMP=0; shift ;;
62
+ --no-npm) DO_NPM=0; shift ;;
39
63
  --no-release) DO_RELEASE=0; shift ;;
40
64
  --draft) DRAFT_FLAG="--draft"; shift ;;
41
- -h|--help) sed -n '2,30p' "$0"; exit 0 ;;
65
+ -h|--help) sed -n '2,38p' "$0"; exit 0 ;;
42
66
  *) echo "unknown arg: $1" >&2; exit 2 ;;
43
67
  esac
44
68
  done
45
69
 
70
+ case "$BUMP_LEVEL" in
71
+ patch|minor|major) ;;
72
+ *) echo "invalid --bump level: $BUMP_LEVEL (want patch|minor|major)" >&2; exit 2 ;;
73
+ esac
74
+
46
75
  say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
47
76
  die() { printf '\033[1mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
48
77
 
49
78
  command -v node >/dev/null || die "node not found on PATH"
50
79
  command -v mcpb >/dev/null || die "mcpb CLI not found (npm i -g @anthropic-ai/mcpb)"
51
80
 
52
- # ---- 1. Resolve version -----------------------------------------------------
81
+ # ---- 1. Resolve + WRITE version into the repo-root package.json -------------
82
+ # The repo-root package.json is the SINGLE source of truth: `npm pack` reads it
83
+ # to build the embedded pipeline.tgz, so the bundle shell and the bundled
84
+ # pipeline cannot diverge as long as we bump it BEFORE building (step 2). This
85
+ # closes the 1.6.84-class bug where the shell said X but pipeline.tgz carried
86
+ # the prior version because only the satellites were stamped.
53
87
  PKG_VERSION="$(node -p "require('$REPO_ROOT/package.json').version")"
54
- if [[ -n "$TAG_OVERRIDE" ]]; then
55
- TAG="$TAG_OVERRIDE"
56
- VERSION="${TAG#v}"
88
+ if [[ -n "$VERSION_OVERRIDE" ]]; then
89
+ VERSION="${VERSION_OVERRIDE#v}"
90
+ elif [[ -n "$TAG_OVERRIDE" ]]; then
91
+ VERSION="${TAG_OVERRIDE#v}"
92
+ elif [[ "$DO_BUMP" == "1" ]]; then
93
+ # Compute the next version without writing a git tag; we own the write below.
94
+ VERSION="$(node -e "
95
+ let [a,b,c]='$PKG_VERSION'.split('.').map(Number);
96
+ const lvl='$BUMP_LEVEL';
97
+ if(lvl==='major'){a++;b=0;c=0;} else if(lvl==='minor'){b++;c=0;} else {c++;}
98
+ console.log(a+'.'+b+'.'+c);
99
+ ")"
57
100
  else
58
101
  VERSION="$PKG_VERSION"
59
- TAG="v$VERSION"
60
102
  fi
61
- say "Releasing social-autoposter .mcpb $TAG (package.json=$PKG_VERSION)"
103
+ TAG="v$VERSION"
104
+
105
+ # Write the resolved version into the repo-root package.json + lockfile so the
106
+ # pipeline tarball, npm publish, and all satellites share one number.
62
107
  if [[ "$VERSION" != "$PKG_VERSION" ]]; then
63
- echo " note: tag version ($VERSION) differs from package.json ($PKG_VERSION)"
108
+ say "Bumping repo-root package.json $PKG_VERSION -> $VERSION"
109
+ node -e "
110
+ const fs=require('fs');
111
+ for (const p of ['$REPO_ROOT/package.json','$REPO_ROOT/package-lock.json']) {
112
+ if (!fs.existsSync(p)) continue;
113
+ const j=JSON.parse(fs.readFileSync(p,'utf8'));
114
+ j.version='$VERSION';
115
+ if (j.packages && j.packages['']) j.packages[''].version='$VERSION';
116
+ fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
117
+ console.log(' '+p.replace('$REPO_ROOT/','')+' -> '+j.version);
118
+ }
119
+ "
120
+ else
121
+ say "Releasing $TAG (repo-root package.json already $VERSION)"
64
122
  fi
65
123
 
66
124
  # ---- 2. Build the bundle ----------------------------------------------------
@@ -128,6 +186,14 @@ MANIFEST_VER=$(unzip -p "$BUNDLE" manifest.json 2>/dev/null | node -p "JSON.pars
128
186
  [[ "$MANIFEST_VER" == "$VERSION" ]] || die "bundle manifest.json=$MANIFEST_VER != $VERSION (Desktop Details panel would show the wrong version)"
129
187
  echo " manifest.json: $MANIFEST_VER ok"
130
188
 
189
+ # THE guard that was missing when 1.6.84 shipped a 1.6.83 pipeline: assert the
190
+ # version INSIDE the embedded pipeline.tgz matches the bundle. ensurePipelineCurrent()
191
+ # on the box trusts version.json to decide whether to re-extract; if the tarball's
192
+ # own package.json lags, the box materializes stale Python and never knows.
193
+ PIPELINE_VER=$(unzip -p "$BUNDLE" dist/pipeline.tgz 2>/dev/null | tar -xzO package/package.json 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo "?")
194
+ [[ "$PIPELINE_VER" == "$VERSION" ]] || die "embedded pipeline.tgz version=$PIPELINE_VER != $VERSION (box would materialize a STALE pipeline; bump repo-root package.json BEFORE build)"
195
+ echo " pipeline.tgz internal version: $PIPELINE_VER ok"
196
+
131
197
  for f in "dist/index.js" "dist/runtime.js" "manifest.json"; do
132
198
  # grep -c reads all input (no SIGPIPE); anchor on the time column + 3-space
133
199
  # gutter so node_modules/.../dist/index.js does not false-match the top-level.
@@ -141,7 +207,30 @@ if [[ "$DO_RELEASE" == "0" ]]; then
141
207
  exit 0
142
208
  fi
143
209
 
144
- # ---- 6. GitHub release ------------------------------------------------------
210
+ # ---- 6. npm publish (Story A: `npx social-autoposter@<v> init`) -------------
211
+ # Same VERSION as the bundle, from the SAME bumped repo-root package.json, so the
212
+ # npm install path and the .mcpb path can never disagree. Idempotent: a version
213
+ # already on the registry is skipped, not failed.
214
+ if [[ "$DO_NPM" == "1" ]]; then
215
+ command -v npm >/dev/null || die "npm not found on PATH"
216
+ NPM_HTTP=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/social-autoposter/$VERSION" || echo "000")
217
+ if [[ "$NPM_HTTP" == "200" ]]; then
218
+ say "npm: social-autoposter@$VERSION already published — skipping"
219
+ else
220
+ say "Publishing social-autoposter@$VERSION to npm"
221
+ ( cd "$REPO_ROOT" && npm publish ) || die "npm publish failed"
222
+ # Confirm it actually landed (granular-token whoami lies; a version fetch doesn't).
223
+ for _ in 1 2 3 4 5; do
224
+ sleep 2
225
+ [[ "$(curl -s -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/social-autoposter/$VERSION")" == "200" ]] && break
226
+ done
227
+ echo " npm: social-autoposter@$VERSION live"
228
+ fi
229
+ else
230
+ say "npm publish skipped (--no-npm)"
231
+ fi
232
+
233
+ # ---- 7. GitHub release ------------------------------------------------------
145
234
  command -v gh >/dev/null || die "gh CLI not found"
146
235
  gh auth status >/dev/null 2>&1 || die "gh not authenticated (run: gh auth login)"
147
236
 
@@ -33,6 +33,7 @@ import json
33
33
  import os
34
34
  import random
35
35
  import re
36
+ import signal
36
37
  import subprocess
37
38
  import sys
38
39
  import time
@@ -40,27 +41,51 @@ import time
40
41
 
41
42
  LOCK_FILE = os.path.expanduser("~/.claude/twitter-browser-lock.json")
42
43
  LOCK_EXPIRY = 300 # process-level mutex TTL; refreshed during long ops
44
+ # Posting-specific silence ceiling, DECOUPLED from the fleet-wide LOCK_EXPIRY.
45
+ # A role:"post" holder (an approved batch, or a single reply) is reclaimed by a
46
+ # peer once its lock has gone unrefreshed this long; a role:"scan" holder keeps
47
+ # the 300s LOCK_EXPIRY untouched. Posting refreshes the lock at every candidate
48
+ # boundary (twitter_post_plan holds it across the whole batch), so a healthy
49
+ # poster never goes silent this long -- only a genuinely hung poster (e.g.
50
+ # link_tail's `claude -p` wedged) trips it. Kept as its own knob so tuning the
51
+ # scan TTL never moves the poster's hang ceiling and vice-versa. Must exceed the
52
+ # worst-case single candidate step (one reply + the link_tail AI call), and stay
53
+ # well under any value that would let a hung poster block the browser for long.
54
+ POST_LOCK_EXPIRY = 180 # seconds; applies ONLY to a role:"post" holder
43
55
  LOCK_WAIT_MAX = 45 # seconds to wait for lock to free before giving up
44
56
  LOCK_POLL_INTERVAL = 2
57
+ PREEMPT_KILL_WAIT = 5 # secs to wait for a preempted scan holder to die before SIGKILL
58
+
59
+ # Lock role priority. A "post" holder is user-initiated (an approved reply) and
60
+ # outranks any "scan" holder (the scan/draft cycle, autopilot or plugin). When a
61
+ # poster finds a LIVE lower-priority holder it PREEMPTS it (SIGTERM + reclaim)
62
+ # instead of waiting LOCK_WAIT_MAX and giving up. This is what makes "posting
63
+ # takes priority over scanning" hold CROSS-PROCESS: the old in-process
64
+ # preemptScanForPost only killed the plugin's own scan, never a scan spawned by a
65
+ # separate autopilot agent / launchd cron, so an approved post kept losing the
66
+ # 45s race to a live scan that held the browser. Default "scan" so any unmarked
67
+ # browser op is preemptable; only the poster path sets SAPS_LOCK_ROLE=post.
68
+ LOCK_ROLE = (os.environ.get("SAPS_LOCK_ROLE") or "scan").strip() or "scan"
45
69
  VIEWPORT = {"width": 911, "height": 1016}
46
70
 
47
71
  # Posting handle. Resolved at call time from AUTOPOSTER_TWITTER_HANDLE env
48
72
  # var (set by per-account launchd/systemd units) or config.json
49
- # accounts.twitter.handle. The "m13v_" fallback keeps the Mac default working
50
- # when neither source is set, but on the VMs the env var MUST be set so the
51
- # DOM scrape + CDP URL build target the right profile.
52
- _DEFAULT_HANDLE = "m13v_"
53
-
73
+ # accounts.twitter.handle. Returns None when neither source is set.
74
+ #
75
+ # There is intentionally NO hardcoded fallback handle. The old "m13v_"
76
+ # default meant any install with an unset handle silently posted under the
77
+ # repo owner's identity: it stamped posts.our_account = m13v_ and built reply
78
+ # permalinks as x.com/m13v_/status/<id> for tweets that actually belonged to a
79
+ # different account, corrupting attribution in the shared DB. Callers that
80
+ # build a URL or post under this identity MUST treat None as "account not
81
+ # configured" and refuse, rather than impersonate someone.
54
82
  def our_handle():
55
83
  try:
56
84
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
57
85
  import account_resolver
58
- h = account_resolver.resolve("twitter")
59
- if h:
60
- return h
86
+ return account_resolver.resolve("twitter")
61
87
  except Exception:
62
- pass
63
- return _DEFAULT_HANDLE
88
+ return None
64
89
 
65
90
  # DM encryption passcode from .env
66
91
  DM_PASSCODE = os.environ.get("TWITTER_DM_PASSCODE", "")
@@ -297,13 +322,45 @@ def _try_take_lock() -> bool:
297
322
  return False
298
323
  try:
299
324
  os.write(fd, json.dumps(
300
- {"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}
325
+ {"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time()), "role": LOCK_ROLE}
301
326
  ).encode())
302
327
  finally:
303
328
  os.close(fd)
304
329
  return True
305
330
 
306
331
 
332
+ def _preempt_holder(pid: int) -> bool:
333
+ """Preempt a live lock holder we outrank (a poster taking the browser from a
334
+ scan). SIGTERM it, wait PREEMPT_KILL_WAIT for it to die so its pid frees the
335
+ lock, then escalate to SIGKILL once. Returns True once the holder is gone
336
+ (or was already gone). Best-effort; never raises. The caller then removes the
337
+ stale lockfile and claims it via O_EXCL.
338
+ """
339
+ try:
340
+ os.kill(pid, signal.SIGTERM)
341
+ except ProcessLookupError:
342
+ return True # already gone
343
+ except OSError:
344
+ return False # not ours to signal / ambiguous -> don't claim
345
+ deadline = time.time() + PREEMPT_KILL_WAIT
346
+ while time.time() < deadline:
347
+ try:
348
+ os.kill(pid, 0)
349
+ except OSError:
350
+ return True # ProcessLookupError or perm change -> dead enough
351
+ time.sleep(0.2)
352
+ # Still alive after the SIGTERM grace window -> escalate once.
353
+ try:
354
+ os.kill(pid, signal.SIGKILL)
355
+ except OSError:
356
+ pass
357
+ try:
358
+ os.kill(pid, 0)
359
+ except OSError:
360
+ return True
361
+ return False
362
+
363
+
307
364
  def _acquire_browser_lock():
308
365
  """Acquire the Twitter browser session mutex (~/.claude/twitter-browser-lock.json).
309
366
 
@@ -377,6 +434,7 @@ def _acquire_browser_lock():
377
434
  continue
378
435
  age = time.time() - lock.get("timestamp", 0)
379
436
  holder = lock.get("session_id", "")
437
+ holder_role = lock.get("role", "scan") # legacy locks (no role) = preemptable
380
438
 
381
439
  # 1. Re-entrant: the lock is already ours (same process, or a stale lock
382
440
  # left by a previous process whose PID we have since reused). Refresh the
@@ -385,6 +443,29 @@ def _acquire_browser_lock():
385
443
  _refresh_browser_lock()
386
444
  break
387
445
 
446
+ # 1b. Batch-owner inherit (posting). The poster (twitter_post_plan.py)
447
+ # acquires this lock ONCE and holds it across the WHOLE approved batch,
448
+ # exporting its own session id as SAPS_LOCK_OWNER for the child
449
+ # twitter_browser.py reply subprocesses it spawns. Each child INHERITS the
450
+ # parent's hold instead of contending for it -- two role:"post" peers would
451
+ # otherwise both fall to the case-6 peer-wait and give up after
452
+ # LOCK_WAIT_MAX, breaking the post. The child refreshes the timestamp
453
+ # (proof of progress at this candidate boundary, so the POST_LOCK_EXPIRY
454
+ # failsafe only ever fires on a real hang) and, being _LOCK_INHERITED,
455
+ # leaves the lock in place for the PARENT to release at batch end. A DEAD
456
+ # owner is never inherited: the alive-probe fails here and we fall through
457
+ # to the dead_python reclaim below, so a crashed batch can't wedge the
458
+ # browser. This is what closes the inter-candidate gap (the link_tail
459
+ # claude -p call, ~5-20s) the every-60s autopilot scan used to slip into.
460
+ _batch_owner = os.environ.get("SAPS_LOCK_OWNER") or ""
461
+ if holder and holder == _batch_owner and _is_python_holder_alive(holder):
462
+ _LOCK_SESSION_ID = holder
463
+ _LOCK_INHERITED = True
464
+ _refresh_browser_lock()
465
+ print(f"[browser_lock] inherited batch owner={holder} "
466
+ f"role={holder_role} -> pid={os.getpid()}", file=sys.stderr)
467
+ break
468
+
388
469
  # 2-4. Reclaim a holder we can prove is dead/expired. Remove-then-take so
389
470
  # the O_EXCL claim wins; if a peer reclaims at the same instant exactly
390
471
  # one of us creates the file and the other re-loops (never both).
@@ -393,7 +474,10 @@ def _acquire_browser_lock():
393
474
  reclaim_reason = "dead_uuid"
394
475
  elif holder.startswith("python:") and not _is_python_holder_alive(holder):
395
476
  reclaim_reason = "dead_python"
396
- elif age >= LOCK_EXPIRY:
477
+ elif age >= (POST_LOCK_EXPIRY if holder_role == "post" else LOCK_EXPIRY):
478
+ # Role-aware failsafe: a hung poster self-clears on the posting-only
479
+ # POST_LOCK_EXPIRY, a scan on the fleet-wide LOCK_EXPIRY. Scan
480
+ # behaviour is unchanged; only the post ceiling is decoupled.
397
481
  reclaim_reason = "expired"
398
482
  if reclaim_reason:
399
483
  try:
@@ -415,6 +499,43 @@ def _acquire_browser_lock():
415
499
  _LOCK_INHERITED = True
416
500
  break
417
501
 
502
+ # 5b. POSTING PRIORITY (cross-process). A LIVE python:PID peer running a
503
+ # lower-priority op (role != "post": the scan/draft cycle, whether the
504
+ # plugin's own, a separate autopilot agent's, or the launchd cron's) must
505
+ # YIELD to an approved post. Preempt it by signal and reclaim, so the post
506
+ # takes the browser at once instead of waiting LOCK_WAIT_MAX and giving up
507
+ # while the scan holds it. The aborted scan just re-runs next cron tick;
508
+ # posting is the scarce, user-initiated action. Only a poster
509
+ # (LOCK_ROLE == "post") ever preempts, and only a non-post holder -- two
510
+ # posters fall through to the normal peer-wait below so neither kills the
511
+ # other. UUID holders are handled above (we inherit, never kill those).
512
+ if (
513
+ LOCK_ROLE == "post"
514
+ and holder.startswith("python:")
515
+ and holder_role != "post"
516
+ and _is_python_holder_alive(holder)
517
+ ):
518
+ try:
519
+ victim_pid = int(holder.split(":", 1)[1])
520
+ except (ValueError, IndexError):
521
+ victim_pid = 0
522
+ if victim_pid and _preempt_holder(victim_pid):
523
+ try:
524
+ os.remove(LOCK_FILE)
525
+ except OSError:
526
+ pass
527
+ if _try_take_lock():
528
+ print(
529
+ f"[browser_lock] post preempted holder={holder} "
530
+ f"role={holder_role} age={int(age)}s -> pid={os.getpid()}",
531
+ file=sys.stderr,
532
+ )
533
+ break
534
+ # Preempt didn't land (couldn't kill, or a peer reclaimed first) ->
535
+ # re-loop and re-evaluate rather than busy-spin.
536
+ time.sleep(LOCK_POLL_INTERVAL)
537
+ continue
538
+
418
539
  # 6. Live python:PID peer. Wait, then give up. Reaching the deadline now
419
540
  # means the holder is a genuinely LIVE peer (dead ones were reclaimed
420
541
  # above), i.e. real contention -- NOT the defect-a starvation. The
@@ -433,7 +554,7 @@ def _refresh_browser_lock():
433
554
  """Refresh the lock timestamp to prevent expiry during long operations."""
434
555
  try:
435
556
  with open(LOCK_FILE, "w") as f:
436
- json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}, f)
557
+ json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time()), "role": LOCK_ROLE}, f)
437
558
  except OSError:
438
559
  pass
439
560
 
@@ -820,6 +941,18 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
820
941
  """
821
942
  print(f"[twitter_browser] reply_to_tweet called: {tweet_url}", file=sys.stderr)
822
943
 
944
+ # Identity gate: refuse to post when no account is configured. Without a
945
+ # resolved handle we cannot attribute the post or build a correct reply
946
+ # permalink, and the old behaviour silently impersonated the repo owner
947
+ # (handle "m13v_"). Fail fast and loud so the misconfiguration surfaces
948
+ # instead of polluting the shared DB under someone else's identity.
949
+ _handle = our_handle()
950
+ if not _handle:
951
+ print("[twitter_browser] no twitter account configured "
952
+ "(set AUTOPOSTER_TWITTER_HANDLE or accounts.twitter.handle in "
953
+ "config.json); refusing to post.", file=sys.stderr)
954
+ return {"ok": False, "error": "no_account_configured"}
955
+
823
956
  applied_campaigns = []
824
957
  if apply_campaigns:
825
958
  for cid, suffix, sample_rate in _load_active_twitter_campaigns():
@@ -946,7 +1079,17 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
946
1079
  page.goto(tweet_url, wait_until="domcontentloaded", timeout=60000)
947
1080
  except Exception:
948
1081
  pass
949
- page.wait_for_timeout(15000 if nav_attempt == 1 else 8000)
1082
+ # Was a blind 15s/8s settle here -> pure dead latency. SPA
1083
+ # readiness is ALREADY gated actively below by
1084
+ # wait_for_selector("main") (up to 20s) and
1085
+ # _wait_for_reply_textbox (polls every 500ms up to 45s); both
1086
+ # return the instant the composer mounts, so the blind sleep
1087
+ # only delayed the start of that polling. Keep a short floor so
1088
+ # the initial JS kicks off (and the deleted-tweet text check
1089
+ # below has content to read), then let the active gates do the
1090
+ # real waiting. Cuts ~12s off every happy-path reply.
1091
+ # (optimized 2026-06-22: 15000/8000 -> 2500)
1092
+ page.wait_for_timeout(2500)
950
1093
 
951
1094
  # `wait_until="load"` fires before Twitter's SPA mounts the
952
1095
  # <main> app shell, so "loaded" != "rendered". Explicitly gate
@@ -1021,7 +1164,12 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
1021
1164
  except Exception:
1022
1165
  page.keyboard.press("Meta+Enter")
1023
1166
 
1024
- page.wait_for_timeout(4000)
1167
+ # Post-submit settle: lets the CDP network response (which carries
1168
+ # the new tweet id -> reply_url, captured below) and the success
1169
+ # interstitial arrive. Trimmed from 4000ms 2026-06-22; the DOM-diff
1170
+ # fallback (3x2s, below) still covers a slow CDP response, so the
1171
+ # reply_url is not lost if 2000ms is short on a given run.
1172
+ page.wait_for_timeout(2000)
1025
1173
 
1026
1174
  # Verify: check if the reply box is empty (cleared after posting)
1027
1175
  try:
@@ -1050,7 +1198,7 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
1050
1198
 
1051
1199
  # Method 1: CDP network interception (most reliable)
1052
1200
  if _created_tweet_ids:
1053
- reply_url = f"https://x.com/{our_handle()}/status/{_created_tweet_ids[-1]}"
1201
+ reply_url = f"https://x.com/{_handle}/status/{_created_tweet_ids[-1]}"
1054
1202
  print(f"[reply_url] captured via CDP+response-listener: {reply_url}", file=sys.stderr)
1055
1203
 
1056
1204
  # Method 2: DOM diff (check if new reply links appeared)
@@ -49,6 +49,14 @@ import time
49
49
  from datetime import datetime, timezone
50
50
  from pathlib import Path
51
51
 
52
+ # This pipeline ONLY posts (never scans), so mark every twitter_browser.py reply
53
+ # subprocess it spawns as the high-priority "post" lock role. run_subprocess
54
+ # inherits this process env, so the child twitter_browser.py reads SAPS_LOCK_ROLE
55
+ # at import and will PREEMPT a live scan holding the browser lock instead of
56
+ # losing the 45s wait. Covers BOTH the MCP approve path and the cron post path,
57
+ # since both shell out to this script. Set before any child is spawned.
58
+ os.environ["SAPS_LOCK_ROLE"] = "post"
59
+
52
60
  REPO_DIR = os.path.expanduser("~/social-autoposter")
53
61
  TWITTER_BROWSER = os.path.join(REPO_DIR, "scripts", "twitter_browser.py")
54
62
  LOG_POST = os.path.join(REPO_DIR, "scripts", "log_post.py")
@@ -1009,11 +1017,58 @@ def main() -> int:
1009
1017
  return 3
1010
1018
 
1011
1019
  _total = len(candidates)
1020
+
1021
+ # ---- Batch-level browser-lock hold (cross-process posting priority) --------
1022
+ # Hold the Twitter browser lock for the WHOLE approved batch instead of
1023
+ # re-acquiring it per candidate. Per-candidate acquisition freed the lock in
1024
+ # the gap between replies (dominated by link_tail's `claude -p` call, ~5-20s),
1025
+ # and the autopilot scan fires every 60s, so a scan kept slipping into that gap
1026
+ # and seizing the browser mid-batch -- the exact "posting gets cut off" symptom
1027
+ # on the remote box. Acquiring ONCE here PREEMPTS any live scan (role:"post"
1028
+ # priority) and, by exporting our session id as SAPS_LOCK_OWNER, makes every
1029
+ # child twitter_browser.py reply INHERIT this hold rather than contend for it,
1030
+ # closing the gap. The hold is bounded by the posting-specific POST_LOCK_EXPIRY
1031
+ # failsafe in twitter_browser (a hung poster self-clears in <=180s; a crashed
1032
+ # one frees instantly via dead-pid reclaim), so it can never wedge the browser
1033
+ # indefinitely. Best-effort: if the import or acquire fails we simply fall back
1034
+ # to the legacy per-candidate acquisition (children still preempt scans one by
1035
+ # one), so posting degrades gracefully and is never blocked by this addition.
1036
+ _tb = None
1037
+ _batch_lock_held = False
1038
+ if candidates:
1039
+ try:
1040
+ import twitter_browser as _tb # SAPS_LOCK_ROLE=post already set above
1041
+ _tb._acquire_browser_lock() # preempts a live scan; sys.exit(1) if contended
1042
+ os.environ["SAPS_LOCK_OWNER"] = _tb._LOCK_SESSION_ID
1043
+ _batch_lock_held = True
1044
+ print(f"[post] batch lock held by {_tb._LOCK_SESSION_ID} (role=post); "
1045
+ f"{_total} candidate(s) inherit it", flush=True)
1046
+ except SystemExit:
1047
+ # _acquire_browser_lock exits when a LIVE non-preemptable peer (another
1048
+ # poster) holds the lock past LOCK_WAIT_MAX. Don't abort the whole run:
1049
+ # drop to per-candidate acquisition (each child still preempts scans).
1050
+ print("[post] batch lock contended; per-candidate acquisition in effect",
1051
+ flush=True)
1052
+ except Exception as _e:
1053
+ print(f"[post] batch lock setup skipped ({_e}); per-candidate "
1054
+ "acquisition in effect", flush=True)
1055
+
1012
1056
  try:
1013
1057
  for _idx, c in enumerate(candidates, start=1):
1014
1058
  # Live per-post status for the S4L menu bar: "posting 3/10" while this
1015
1059
  # one is in flight, then "posted 3/10 ✓" once it lands. Cosmetic only.
1016
1060
  _write_activity(f"posting {_idx}/{_total}")
1061
+ # Re-stamp the batch hold at each candidate boundary so the
1062
+ # POST_LOCK_EXPIRY failsafe measures silence from the LAST real
1063
+ # progress, not from batch start. Insurance on top of the child's own
1064
+ # inherit-refresh: keeps the hold fresh even across a candidate that
1065
+ # skips before ever spawning a reply subprocess (empty_reply_text,
1066
+ # pre-post dedup). Cheap; never raises.
1067
+ if _batch_lock_held and _tb is not None:
1068
+ try:
1069
+ _tb._refresh_browser_lock()
1070
+ except Exception:
1071
+ pass
1017
1072
  try:
1018
1073
  outcome, reason = post_one(c, picker_assignment=picker_assignment)
1019
1074
  except Exception as e:
@@ -1039,6 +1094,17 @@ def main() -> int:
1039
1094
  fail_reasons[reason] = fail_reasons.get(reason, 0) + 1
1040
1095
  finally:
1041
1096
  _clear_activity()
1097
+ # Release the batch hold so the next scan/post can take the browser
1098
+ # immediately (don't make peers wait out POST_LOCK_EXPIRY). _tb's atexit
1099
+ # is a backstop if we somehow skip this; clearing SAPS_LOCK_OWNER stops a
1100
+ # late child from re-inheriting a lock we just dropped.
1101
+ if _batch_lock_held and _tb is not None:
1102
+ try:
1103
+ _tb._release_browser_lock()
1104
+ except Exception:
1105
+ pass
1106
+ os.environ.pop("SAPS_LOCK_OWNER", None)
1107
+ print("[post] batch lock released", flush=True)
1042
1108
 
1043
1109
  summary = {
1044
1110
  "posted": posted,