social-autoposter 1.6.81 → 1.6.83

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
@@ -1424,6 +1424,96 @@ async function autopilotLoaded() {
1424
1424
  }
1425
1425
  return { autopilot_on, auto_update_on };
1426
1426
  }
1427
+ // ---- Autopilot prompt: keep the scheduled task's SKILL.md current ------------
1428
+ // The scheduled task's prompt (SKILL.md) is owned by the host and does NOT update
1429
+ // when the plugin updates. So a behavior change (the no-improvise / voice-inline /
1430
+ // stop-cleanly rules) would never reach an already-scheduled task. We close that
1431
+ // gap by REWRITING the task's SKILL.md on server boot when its embedded prompt
1432
+ // version is older than what this build ships. The host reads the file fresh on
1433
+ // each run, so the next fire picks up the new prompt — no host tool, no user
1434
+ // action. Bump AUTOPILOT_PROMPT_VERSION whenever the prompt below changes.
1435
+ const AUTOPILOT_PROMPT_VERSION = 1;
1436
+ const AUTOPILOT_PROMPT_MARKER = "saps_autopilot_prompt_version";
1437
+ function autopilotSkillPath() {
1438
+ const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
1439
+ return path.join(cfg, "scheduled-tasks", AUTOPILOT_TASK_ID, "SKILL.md");
1440
+ }
1441
+ // The canonical draft-only autopilot prompt: uses ONLY scan_candidates +
1442
+ // submit_drafts, drafts from the INLINE project_voices, improvises nothing, and
1443
+ // stops cleanly if its tools aren't available.
1444
+ function autopilotSkillMd(project) {
1445
+ const body = [
1446
+ `You are running the Social Autoposter draft-only autopilot for the project "${project}". ` +
1447
+ `Run ONE cycle, then stop. DRAFT-ONLY: you must NEVER post to X/Twitter — you only queue ` +
1448
+ `drafts for the user's approval. Use ONLY two tools — scan_candidates and submit_drafts — ` +
1449
+ `and IMPROVISE NOTHING ELSE.`,
1450
+ ``,
1451
+ `Steps:`,
1452
+ `1. Call scan_candidates with project "${project}". It long-polls: if it returns a "Scan in ` +
1453
+ `progress" status, call scan_candidates again (same args) and keep re-calling until it ` +
1454
+ `returns candidates. Never sleep or use background waits. If it returns no candidates, stop ` +
1455
+ `cleanly — nothing to do this cycle.`,
1456
+ `2. The result includes a project_voices field — the brand voice + guardrails for each ` +
1457
+ `candidate's project. Draft from project_voices[<matched_project>] (voice, description, ` +
1458
+ `differentiator, content_guardrails). NEVER read config files, call project_config, or run ` +
1459
+ `Bash/Read to find the voice — everything you need is already in the scan result.`,
1460
+ `3. For each candidate you judge genuinely worth engaging, write ONE on-brand reply (<=250 ` +
1461
+ `chars, match the thread's language, genuinely helpful — not spammy, no hard selling). Skip ` +
1462
+ `the rest.`,
1463
+ `4. Call submit_drafts with the batch_id from scan_candidates and a drafts array of ` +
1464
+ `{candidate_id, reply_text}. This queues them for the menu-bar approval UI. Do NOT call ` +
1465
+ `post_drafts — posting is the user's decision.`,
1466
+ ``,
1467
+ `HARD GUARD: if scan_candidates is NOT available (ToolSearch returns no matching tool — can ` +
1468
+ `happen when a run spawns before the extension's MCP server has reconnected), STOP immediately ` +
1469
+ `and report "S4L tools unavailable, skipping this cycle." Do NOT search the connector registry, ` +
1470
+ `do NOT call list_connectors, do NOT improvise any other tool.`,
1471
+ ].join("\n");
1472
+ return (`---\n` +
1473
+ `name: ${AUTOPILOT_TASK_ID}\n` +
1474
+ `description: Social Autoposter draft-only autopilot for project ${project} — scans X, drafts ` +
1475
+ `on-brand replies (voice inline), queues them for approval. Never posts.\n` +
1476
+ `---\n\n` +
1477
+ body +
1478
+ `\n\n<!-- ${AUTOPILOT_PROMPT_MARKER}: ${AUTOPILOT_PROMPT_VERSION} -->\n`);
1479
+ }
1480
+ // Read the project name back from an existing autopilot SKILL.md so a rewrite
1481
+ // keeps the same project. Null if it can't be determined (then leave it alone).
1482
+ function detectAutopilotProject(skillMd) {
1483
+ const m1 = /project[s]?\s+"([^"]+)"/i.exec(skillMd);
1484
+ if (m1)
1485
+ return m1[1];
1486
+ const m2 = /for project\s+([A-Za-z0-9_-]+)/i.exec(skillMd);
1487
+ if (m2)
1488
+ return m2[1];
1489
+ return null;
1490
+ }
1491
+ // Refresh the scheduled task's SKILL.md when this build ships a newer prompt than
1492
+ // what's on disk. Best-effort, synchronous, never throws. Only touches an EXISTING
1493
+ // task (onboarding creates the first one), only when stale, and only when the
1494
+ // project reads back, so we never write a wrong/blank project.
1495
+ function ensureAutopilotPromptCurrent() {
1496
+ try {
1497
+ const skillPath = autopilotSkillPath();
1498
+ if (!fs.existsSync(skillPath))
1499
+ return; // no scheduled task yet
1500
+ const cur = fs.readFileSync(skillPath, "utf-8");
1501
+ const m = new RegExp(`${AUTOPILOT_PROMPT_MARKER}:\\s*(\\d+)`).exec(cur);
1502
+ const curVer = m ? parseInt(m[1], 10) : 0;
1503
+ if (curVer >= AUTOPILOT_PROMPT_VERSION)
1504
+ return; // already current
1505
+ const project = detectAutopilotProject(cur);
1506
+ if (!project) {
1507
+ console.error(`[autopilot] prompt is stale (v${curVer}) but project unreadable from SKILL.md; leaving it`);
1508
+ return;
1509
+ }
1510
+ fs.writeFileSync(skillPath, autopilotSkillMd(project), "utf-8");
1511
+ console.error(`[autopilot] refreshed scheduled-task prompt -> v${AUTOPILOT_PROMPT_VERSION} (was v${curVer}), project=${project}`);
1512
+ }
1513
+ catch (e) {
1514
+ console.error(`[autopilot] ensureAutopilotPromptCurrent error: ${e?.message || e}`);
1515
+ }
1516
+ }
1427
1517
  // Assemble everything the panel needs in one shot (projects + X + autopilot +
1428
1518
  // version). Resilient: any probe that throws degrades to a safe default rather
1429
1519
  // than failing the whole snapshot.
@@ -2261,6 +2351,11 @@ async function main() {
2261
2351
  // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
2262
2352
  // the version first materialized at install). Synchronous + best-effort.
2263
2353
  ensurePipelineCurrent();
2354
+ // Same problem for the scheduled task's prompt: the host owns its SKILL.md and
2355
+ // a plugin update never touches it. Rewrite it on boot when this build ships a
2356
+ // newer prompt version, so behavior changes (no-improvise / voice-inline /
2357
+ // stop-cleanly) reach an already-scheduled task on its next fire. Best-effort.
2358
+ ensureAutopilotPromptCurrent();
2264
2359
  const transport = new StdioServerTransport();
2265
2360
  await server.connect(transport);
2266
2361
  console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
@@ -2274,8 +2369,22 @@ async function main() {
2274
2369
  // and cheap when already present, so existing installs pick it up on the next
2275
2370
  // Claude restart without re-provisioning. Best-effort: never blocks boot.
2276
2371
  void ensureMenubar()
2277
- .then((r) => console.error(`[social-autoposter-mcp] menubar: ${r.skipped ? "skip" : r.ok ? "ok" : "fail"} (${r.detail})`))
2278
- .catch((e) => console.error("[social-autoposter-mcp] menubar ensure failed:", e?.message || e));
2372
+ .then((r) => {
2373
+ console.error(`[social-autoposter-mcp] menubar: ${r.skipped ? "skip" : r.ok ? "ok" : "fail"} (${r.detail})`);
2374
+ // A non-skipped failure here is the boot-time "menu bar didn't come up"
2375
+ // path (e.g. uv missing, rumps reinstall failed on an existing install).
2376
+ // Report it; a skip (non-macOS / runtime not ready) is expected, not an error.
2377
+ if (!r.ok && !r.skipped) {
2378
+ captureError(new Error(`menubar ensure failed: ${r.detail}`), {
2379
+ component: "menubar",
2380
+ phase: "ensure",
2381
+ });
2382
+ }
2383
+ })
2384
+ .catch((e) => {
2385
+ console.error("[social-autoposter-mcp] menubar ensure failed:", e?.message || e);
2386
+ captureError(e, { component: "menubar", phase: "ensure" });
2387
+ });
2279
2388
  // Phone home so this .mcpb install is visible in the install-lane digest
2280
2389
  // (parity with the npx launchd heartbeat). Once on startup, then every 15m
2281
2390
  // while the desktop app keeps the server alive. unref() so it never holds the
@@ -22,6 +22,7 @@ import fs from "node:fs";
22
22
  import os from "node:os";
23
23
  import path from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
+ import { captureError } from "./telemetry.js";
25
26
  // Pin the standalone CPython series the venv is built from. Bump deliberately.
26
27
  const PYTHON_VERSION = "3.12";
27
28
  // The CDP scan engine the twitter cycle shells out to (~/.local/bin/browser-harness).
@@ -378,6 +379,15 @@ async function provision(progress) {
378
379
  progress.ok = false;
379
380
  progress.error = msg;
380
381
  writeProgress(progress);
382
+ // Every fatal install-step failure (repo unpack, uv, python, venv, deps,
383
+ // chromium, harness, chrome) was previously only written to the local
384
+ // install-progress.json, invisible to us. Report it so a failed runtime
385
+ // install becomes a real Sentry event, tagged with the step that failed.
386
+ const failedStep = progress.steps.find((s) => s.status === "running");
387
+ captureError(new Error(msg), {
388
+ component: "install",
389
+ ...(failedStep ? { step: failedStep.id } : {}),
390
+ });
381
391
  return progress;
382
392
  };
383
393
  fs.mkdirSync(RUNTIME_DIR, { recursive: true });
@@ -499,6 +509,19 @@ async function provision(progress) {
499
509
  if (r.code !== 0) {
500
510
  return fail(`playwright install chromium failed (exit ${r.code}). ${r.out.slice(-400)}`);
501
511
  }
512
+ // Smoke-test the EXACT gate the pipeline's post path runs at use time
513
+ // (twitter_post_plan.py preflight): the owned interpreter must import
514
+ // playwright. The reply step is the only Playwright importer, so a deps
515
+ // sync that left it unimportable was invisible until the first real post
516
+ // died with no_reply_json in production (Karol, 2026-06-22). Fail the
517
+ // install LOUDLY here instead.
518
+ const smoke = await sh(VENV_PYTHON, ["-c", "import playwright"], {
519
+ timeoutMs: 60000,
520
+ });
521
+ if (smoke.code !== 0) {
522
+ return fail(`runtime smoke test failed: ${VENV_PYTHON} cannot import playwright ` +
523
+ `(exit ${smoke.code}). ${smoke.out.slice(-400)}`);
524
+ }
502
525
  }
503
526
  setStep("chromium", "done");
504
527
  // --- Step 6: browser-harness CLI -----------------------------------------
@@ -607,6 +630,15 @@ async function provision(progress) {
607
630
  if (process.platform === "darwin") {
608
631
  const mb = await installMenubar(uv, uvEnv, VENV_PYTHON);
609
632
  setStep("menubar", mb.ok ? "done" : "error", mb.detail);
633
+ // Non-fatal step, so the only prior signal of a menu bar install failure was
634
+ // a local install-progress.json entry (invisible to us). Report it so "menu
635
+ // bar didn't start" becomes a real Sentry event with the failing detail.
636
+ if (!mb.ok) {
637
+ captureError(new Error(`menubar install failed: ${mb.detail}`), {
638
+ component: "menubar",
639
+ phase: "install",
640
+ });
641
+ }
610
642
  }
611
643
  else {
612
644
  setStep("menubar", "done", "skipped (macOS only)");
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.81",
3
- "installedAt": "2026-06-22T19:29:05.014Z"
2
+ "version": "1.6.83",
3
+ "installedAt": "2026-06-23T02:17:06.705Z"
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.81",
5
+ "version": "1.6.83",
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": {
@@ -24,9 +24,58 @@ import tempfile
24
24
  import threading
25
25
  import time
26
26
 
27
- import rumps
28
-
29
- import s4l_state as st
27
+ # --- Sentry bootstrap --------------------------------------------------------
28
+ # The menu bar runs as a standalone KeepAlive LaunchAgent off the owned venv,
29
+ # a separate process from the MCP server, so it was a Sentry blind spot: a crash
30
+ # (most often rumps missing/broken in the venv -> "menu bar didn't start") only
31
+ # ever landed in the local menubar.err.log. Wire it in BEFORE importing rumps so
32
+ # even an import-time failure of the menu bar's heaviest dependency is reported.
33
+ # sentry_init lives in the pipeline's scripts/ dir (SAPS_REPO_DIR is exported by
34
+ # the launchd plist) and sentry-sdk is in the owned venv (requirements.txt). All
35
+ # best-effort: a missing repo path or SDK degrades to a silent no-op.
36
+ _sentry = None
37
+ try:
38
+ _repo = os.environ.get("SAPS_REPO_DIR")
39
+ if _repo:
40
+ _scripts = os.path.join(_repo, "scripts")
41
+ if _scripts not in sys.path:
42
+ sys.path.insert(0, _scripts)
43
+ import sentry_init as _sentry # noqa: E402
44
+
45
+ _sentry.init()
46
+ except Exception:
47
+ _sentry = None
48
+
49
+
50
+ def _capture(err, **tags):
51
+ """Report a handled menu-bar error to Sentry (component=menubar) without ever
52
+ raising into the caller. No-op if the Sentry bootstrap above failed."""
53
+ try:
54
+ if _sentry is not None:
55
+ tags.setdefault("component", "menubar")
56
+ _sentry.capture_exception(err, tags=tags)
57
+ except Exception:
58
+ pass
59
+
60
+
61
+ def _flush():
62
+ try:
63
+ if _sentry is not None:
64
+ _sentry.flush()
65
+ except Exception:
66
+ pass
67
+
68
+
69
+ try:
70
+ import rumps # noqa: E402
71
+ except Exception as _import_err:
72
+ # rumps missing/broken in the owned venv is THE "menu bar didn't start" case.
73
+ # Report it explicitly, flush, then re-raise so launchd records the crash too.
74
+ _capture(_import_err, phase="import_rumps")
75
+ _flush()
76
+ raise
77
+
78
+ import s4l_state as st # noqa: E402
30
79
 
31
80
  CLAUDE_APP = "Claude"
32
81
  POLL_SECONDS = 5
@@ -47,7 +96,6 @@ SETUP_PROMPT = (
47
96
  "must interactively sign in or no product can be identified."
48
97
  )
49
98
  DRAFT_PROMPT = "Run a social-autoposter draft cycle and show me the drafts to review."
50
- POST_PROMPT = "Post my approved social-autoposter drafts."
51
99
  UPDATE_PROMPT = "Update social-autoposter to the latest version."
52
100
 
53
101
 
@@ -183,9 +231,6 @@ class S4LMenuBar(rumps.App):
183
231
  def _draft(self, _=None):
184
232
  self._send_to_claude(DRAFT_PROMPT)
185
233
 
186
- def _post(self, _=None):
187
- self._send_to_claude(POST_PROMPT)
188
-
189
234
  def _update(self, _=None):
190
235
  self._send_to_claude(UPDATE_PROMPT)
191
236
 
@@ -319,8 +364,13 @@ class S4LMenuBar(rumps.App):
319
364
  act = st.read_activity()
320
365
  label = act.get("label") if act else None
321
366
  if label:
322
- self._spin_i = (self._spin_i + 1) % len(SPINNER)
323
- self.title = f"S4L {label} {SPINNER[self._spin_i]}"
367
+ # A "✓" label (e.g. "posted 3/10 ✓") is a momentary confirmation, not
368
+ # ongoing work show it without the spinner glyph so it reads as done.
369
+ if "✓" in label:
370
+ self.title = f"S4L {label}"
371
+ else:
372
+ self._spin_i = (self._spin_i + 1) % len(SPINNER)
373
+ self.title = f"S4L {label} {SPINNER[self._spin_i]}"
324
374
  return
325
375
  try:
326
376
  if self._spinner is not None:
@@ -431,6 +481,7 @@ class S4LMenuBar(rumps.App):
431
481
  self._review_active = False
432
482
  sys.stderr.write(f"[s4l-menubar] review cards failed: {e}\n")
433
483
  sys.stderr.flush()
484
+ _capture(e, phase="review_cards")
434
485
 
435
486
  def _on_review_done(self, batch, decisions):
436
487
  # Runs on the main thread (from the card controller). Translate decisions
@@ -589,11 +640,19 @@ class S4LMenuBar(rumps.App):
589
640
  out.append(
590
641
  rumps.MenuItem("Run draft cycle in Claude", callback=self._draft)
591
642
  )
592
- out.append(
593
- rumps.MenuItem("Post approved drafts in Claude", callback=self._post)
594
- )
643
+ # 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.
595
647
  return out
596
648
 
597
649
 
598
650
  if __name__ == "__main__":
599
- S4LMenuBar().run()
651
+ try:
652
+ S4LMenuBar().run()
653
+ except Exception as _run_err:
654
+ # The run loop dying is the other "menu bar didn't start / vanished" case.
655
+ # Report + flush before the KeepAlive relaunch so it isn't lost on teardown.
656
+ _capture(_run_err, phase="run")
657
+ _flush()
658
+ raise
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.81",
3
+ "version": "1.6.83",
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.81",
3
+ "version": "1.6.83",
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"
@@ -36,6 +36,17 @@ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
36
36
  REDDIT_BROWSER = os.path.join(REPO_DIR, "scripts", "reddit_browser.py")
37
37
  REDDIT_BROWSER_LOCK = os.path.join(REPO_DIR, "scripts", "reddit_browser_lock.py")
38
38
  REDDIT_TOOLS = os.path.join(REPO_DIR, "scripts", "reddit_tools.py")
39
+
40
+ # Interpreter every child subprocess must run under. A bare PYTHON resolved
41
+ # to the user's system python, which lacks the pipeline deps (Playwright and
42
+ # friends) that live only in the owned uv runtime — so on a fresh box every
43
+ # reddit_browser.py reply died (the same class as the Karol/Twitter bug,
44
+ # 2026-06-22). Honor the authoritative SAPS_PYTHON pin (set by the launchd
45
+ # plist), else sys.executable (the owned interpreter the MCP launches us under).
46
+ # Never the literal PYTHON: that re-rolls the PATH dice. Re-exported so
47
+ # grandchildren inherit it.
48
+ PYTHON = os.environ.get("SAPS_PYTHON") or sys.executable
49
+ os.environ["SAPS_PYTHON"] = PYTHON
39
50
  RATELIMIT_FILE = "/tmp/reddit_ratelimit.json"
40
51
  PREFLIGHT_WAIT_BUDGET_SECONDS = 180
41
52
 
@@ -619,7 +630,7 @@ def load_config():
619
630
 
620
631
  def pick_project(platform="reddit", exclude=None):
621
632
  try:
622
- cmd = ["python3", os.path.join(REPO_DIR, "scripts", "pick_project.py"),
633
+ cmd = [PYTHON, os.path.join(REPO_DIR, "scripts", "pick_project.py"),
623
634
  "--platform", platform, "--json"]
624
635
  if exclude:
625
636
  cmd.extend(["--exclude", ",".join(exclude)])
@@ -641,7 +652,7 @@ def get_top_performers(project_name, platform="reddit", style=None):
641
652
  callers that have not flipped to the picker yet).
642
653
  """
643
654
  try:
644
- cmd = ["python3", os.path.join(REPO_DIR, "scripts", "top_performers.py"),
655
+ cmd = [PYTHON, os.path.join(REPO_DIR, "scripts", "top_performers.py"),
645
656
  "--platform", platform, "--project", project_name]
646
657
  if style:
647
658
  cmd.extend(["--style", style])
@@ -660,7 +671,7 @@ def get_top_search_topics(project_name, platform="reddit", limit=8, window_days=
660
671
  project on this platform, or '' if no data yet. See top_search_topics.py."""
661
672
  try:
662
673
  result = subprocess.run(
663
- ["python3", os.path.join(REPO_DIR, "scripts", "top_search_topics.py"),
674
+ [PYTHON, os.path.join(REPO_DIR, "scripts", "top_search_topics.py"),
664
675
  "--project", project_name, "--platform", platform,
665
676
  "--window-days", str(window_days), "--limit", str(limit)],
666
677
  capture_output=True, text=True, timeout=15,
@@ -684,7 +695,7 @@ def get_omitted_reddit_topics(project_name, limit=10, window_hours=168, min_omit
684
695
  """
685
696
  try:
686
697
  result = subprocess.run(
687
- ["python3", os.path.join(REPO_DIR, "scripts", "top_omitted_reddit_topics.py"),
698
+ [PYTHON, os.path.join(REPO_DIR, "scripts", "top_omitted_reddit_topics.py"),
688
699
  "--project", project_name,
689
700
  "--window-hours", str(window_hours),
690
701
  "--limit", str(limit),
@@ -709,7 +720,7 @@ def get_dud_reddit_queries(project_name, limit=15, window_hours=168):
709
720
  """
710
721
  try:
711
722
  result = subprocess.run(
712
- ["python3", os.path.join(REPO_DIR, "scripts", "top_dud_reddit_queries.py"),
723
+ [PYTHON, os.path.join(REPO_DIR, "scripts", "top_dud_reddit_queries.py"),
713
724
  "--project", project_name,
714
725
  "--window-hours", str(window_hours),
715
726
  "--limit", str(limit)],
@@ -1451,7 +1462,7 @@ def run_claude(prompt, timeout=600):
1451
1462
  text_output = "\n".join(all_text_parts) if all_text_parts else "".join(collected)
1452
1463
  stderr_out = proc.stderr.read() if proc.stderr else ""
1453
1464
  try:
1454
- log_args = ["python3", os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
1465
+ log_args = [PYTHON, os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
1455
1466
  "--session-id", session_id, "--script", "post_reddit"]
1456
1467
  orch_cost = usage.get("cost_usd")
1457
1468
  if isinstance(orch_cost, (int, float)) and orch_cost > 0:
@@ -1487,7 +1498,7 @@ def _acquire_browser_lease(timeout: int = 600, ttl: int = 90):
1487
1498
  """
1488
1499
  try:
1489
1500
  r = subprocess.run(
1490
- ["python3", REDDIT_BROWSER_LOCK, "acquire",
1501
+ [PYTHON, REDDIT_BROWSER_LOCK, "acquire",
1491
1502
  "--timeout", str(timeout), "--ttl", str(ttl)],
1492
1503
  capture_output=True, text=True, timeout=timeout + 30,
1493
1504
  )
@@ -1512,7 +1523,7 @@ def _release_browser_lease() -> None:
1512
1523
  """
1513
1524
  try:
1514
1525
  subprocess.run(
1515
- ["python3", REDDIT_BROWSER_LOCK, "release"],
1526
+ [PYTHON, REDDIT_BROWSER_LOCK, "release"],
1516
1527
  capture_output=True, text=True, timeout=10,
1517
1528
  )
1518
1529
  except Exception:
@@ -1529,7 +1540,7 @@ def post_via_cdp(thread_url, reply_to_url, text):
1529
1540
  for attempt in range(MAX_ATTEMPTS):
1530
1541
  try:
1531
1542
  target = reply_to_url or thread_url
1532
- cmd = ["python3", REDDIT_BROWSER, "reply" if reply_to_url else "post-comment", target, text]
1543
+ cmd = [PYTHON, REDDIT_BROWSER, "reply" if reply_to_url else "post-comment", target, text]
1533
1544
  proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
1534
1545
  cdp_out = proc.stdout.strip()
1535
1546
  if not cdp_out:
@@ -1585,7 +1596,7 @@ def log_post(thread_url, permalink, text, project_name, thread_author, thread_ti
1585
1596
  (if any) Claude baked into the reply text.
1586
1597
  """
1587
1598
  try:
1588
- cmd = ["python3", REDDIT_TOOLS, "log-post",
1599
+ cmd = [PYTHON, REDDIT_TOOLS, "log-post",
1589
1600
  thread_url, permalink or "", text, project_name,
1590
1601
  thread_author, thread_title,
1591
1602
  "--account", reddit_username]
@@ -1616,7 +1627,7 @@ def bump_campaigns(table, row_id, campaign_ids):
1616
1627
  for cid in campaign_ids:
1617
1628
  try:
1618
1629
  subprocess.run(
1619
- ["python3", bump,
1630
+ [PYTHON, bump,
1620
1631
  "--table", table, "--id", str(row_id), "--campaign-id", str(cid)],
1621
1632
  capture_output=True, text=True, timeout=15,
1622
1633
  )
@@ -2623,6 +2634,22 @@ def main():
2623
2634
  sys.exit(2)
2624
2635
  with open(args.in_path) as f:
2625
2636
  plan = json.load(f)
2637
+ # Hard preflight: _post_iteration shells to reddit_browser.py, the only
2638
+ # Playwright importer on this rail. If the resolved interpreter can't
2639
+ # import it the owned runtime is missing/half-provisioned and every post
2640
+ # would die with CDP_ERROR. Fail LOUD with a distinct signal instead.
2641
+ # Gated on real decisions so an empty plan still exits clean.
2642
+ if plan.get("decisions"):
2643
+ _chk = subprocess.run(
2644
+ [PYTHON, "-c", "import playwright"],
2645
+ capture_output=True, text=True,
2646
+ )
2647
+ if _chk.returncode != 0:
2648
+ print(f"[post_reddit] FATAL runtime_incomplete: interpreter {PYTHON!r} "
2649
+ f"cannot import playwright — the owned Python runtime is missing or "
2650
+ f"unprovisioned. Run the `runtime` install (action:'install') before "
2651
+ f"posting. stderr: {(_chk.stderr or '').strip()[:300]}", file=sys.stderr)
2652
+ sys.exit(3)
2626
2653
  try:
2627
2654
  posted, failed = _post_iteration(plan, reddit_username)
2628
2655
  print(f"[post_reddit] phase=post project={plan.get('project_name')} posted={posted} failed={failed}")
@@ -433,4 +433,21 @@ def main() -> int:
433
433
 
434
434
 
435
435
  if __name__ == "__main__":
436
- raise SystemExit(main())
436
+ try:
437
+ _rc = main()
438
+ except BrokenPipeError:
439
+ # The MCP setup hook (our parent) closes stdout once it has read the
440
+ # sentinel ===QUERIES_JSON=== block; the trailing summary prints then hit
441
+ # a dead pipe and raise BrokenPipeError. All persistence already happened
442
+ # earlier in main(), so this is BENIGN. Previously it propagated as an
443
+ # uncaught exception and Sentry logged it as a "seeding failed" event
444
+ # (Karol, 2026-06-22) — a false positive that buried the real signal.
445
+ # Redirect stdout to devnull so interpreter shutdown doesn't re-raise on
446
+ # the final flush, then exit clean.
447
+ try:
448
+ _devnull = os.open(os.devnull, os.O_WRONLY)
449
+ os.dup2(_devnull, sys.stdout.fileno())
450
+ except Exception:
451
+ pass
452
+ _rc = 0
453
+ raise SystemExit(_rc)
@@ -65,3 +65,40 @@ def _tag_install(sentry_sdk) -> None:
65
65
  sentry_sdk.set_tag("hostname", str(host))
66
66
  except Exception:
67
67
  pass
68
+
69
+
70
+ def capture_exception(err, tags=None) -> None:
71
+ """Explicitly report an exception to Sentry with optional tags. Safe to call
72
+ even if init() was never run or sentry-sdk is missing (silent no-op). Use for
73
+ swallowed/handled errors that would otherwise never reach Sentry (the global
74
+ excepthook only catches UNHANDLED ones)."""
75
+ if not _initialized:
76
+ return
77
+ try:
78
+ import sentry_sdk
79
+ except Exception:
80
+ return
81
+ try:
82
+ if tags:
83
+ with sentry_sdk.push_scope() as scope:
84
+ for k, v in tags.items():
85
+ scope.set_tag(str(k), str(v))
86
+ sentry_sdk.capture_exception(err)
87
+ else:
88
+ sentry_sdk.capture_exception(err)
89
+ except Exception:
90
+ return
91
+
92
+
93
+ def flush(timeout: float = 2.0) -> None:
94
+ """Block until queued events are sent (best-effort). Call before a short-lived
95
+ or about-to-crash process exits so a just-captured event isn't dropped on
96
+ teardown."""
97
+ if not _initialized:
98
+ return
99
+ try:
100
+ import sentry_sdk
101
+
102
+ sentry_sdk.flush(timeout)
103
+ except Exception:
104
+ return
@@ -45,6 +45,7 @@ import random
45
45
  import re
46
46
  import subprocess
47
47
  import sys
48
+ import time
48
49
  from datetime import datetime, timezone
49
50
  from pathlib import Path
50
51
 
@@ -54,6 +55,18 @@ LOG_POST = os.path.join(REPO_DIR, "scripts", "log_post.py")
54
55
  CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
55
56
  LINK_TAIL = os.path.join(REPO_DIR, "scripts", "link_tail.py")
56
57
 
58
+ # Interpreter every child subprocess (twitter_browser.py reply, log_post.py,
59
+ # campaign_bump.py, link_tail.py) must run under. The reply path is the only
60
+ # Playwright importer in the pipeline, so a bare "python3" here silently
61
+ # resolved to the user's system python (no Playwright) and every post died
62
+ # with no_reply_json (Karol, 2026-06-22). Honor the authoritative pin the rest
63
+ # of the runtime uses — SAPS_PYTHON (set by the launchd plist) — then fall back
64
+ # to sys.executable (the interpreter THIS process already runs under, which the
65
+ # MCP's runPython resolves to the owned uv runtime). Never the literal
66
+ # "python3": that re-rolls the PATH dice. Re-exported so grandchildren inherit.
67
+ PYTHON = os.environ.get("SAPS_PYTHON") or sys.executable
68
+ os.environ["SAPS_PYTHON"] = PYTHON
69
+
57
70
  # DATABASE_URL was previously used to issue ad-hoc `psql -c "..."` calls for
58
71
  # the pre-post dedup probe and the candidate status updates. As of the
59
72
  # 2026-05-18 routes migration both lanes go through the s4l.ai HTTP API
@@ -526,7 +539,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
526
539
  link_tail_outcome = "skipped_no_link"
527
540
  if _add_tail_link:
528
541
  rc, out, err = run_subprocess(
529
- ["python3", LINK_TAIL,
542
+ [PYTHON, LINK_TAIL,
530
543
  "--reply-text", reply_text,
531
544
  "--link-url", link_url,
532
545
  "--thread-text", thread_text or "",
@@ -590,7 +603,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
590
603
 
591
604
  print(f"[post] candidate {cid} -> posting (link={link_url!r})", flush=True)
592
605
  rc, out, err = run_subprocess(
593
- ["python3", TWITTER_BROWSER, "reply", candidate_url, full_text],
606
+ [PYTHON, TWITTER_BROWSER, "reply", candidate_url, full_text],
594
607
  timeout_sec=600,
595
608
  )
596
609
  if err:
@@ -667,7 +680,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
667
680
  from twitter_account import resolve_handle as _resolve_twitter_handle
668
681
 
669
682
  log_args = [
670
- "python3", LOG_POST,
683
+ PYTHON, LOG_POST,
671
684
  "--platform", "twitter",
672
685
  "--thread-url", candidate_url,
673
686
  "--our-url", reply_url,
@@ -765,7 +778,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
765
778
  # Campaign attribution.
766
779
  for ccid in applied_campaigns:
767
780
  rc, out, err = run_subprocess(
768
- ["python3", CAMPAIGN_BUMP, "--table", "posts",
781
+ [PYTHON, CAMPAIGN_BUMP, "--table", "posts",
769
782
  "--id", str(post_id), "--campaign-id", str(ccid)],
770
783
  timeout_sec=30,
771
784
  )
@@ -777,7 +790,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
777
790
  # Mark link_edited_at: link is embedded in primary reply, no self-reply
778
791
  # will follow. Prevents link-edit-twitter sweep from re-attempting.
779
792
  rc, out, err = run_subprocess(
780
- ["python3", LOG_POST,
793
+ [PYTHON, LOG_POST,
781
794
  "--mark-self-reply",
782
795
  "--post-id", str(post_id),
783
796
  "--self-reply-url", reply_url,
@@ -845,6 +858,39 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
845
858
  return ("posted", "")
846
859
 
847
860
 
861
+ def _saps_state_dir() -> str:
862
+ return os.environ.get("SAPS_STATE_DIR") or os.path.join(
863
+ os.path.expanduser("~"), ".social-autoposter-mcp")
864
+
865
+
866
+ def _write_activity(label: str) -> None:
867
+ """Best-effort live status for the S4L menu bar, which polls
868
+ <state_dir>/activity.json. Mirrors the Node server's writeActivity shape so
869
+ the menu-bar spinner renders our per-post progress ("posting 3/10", then
870
+ "posted 3/10 ✓"). Purely cosmetic: a failure here never affects posting."""
871
+ try:
872
+ sd = _saps_state_dir()
873
+ os.makedirs(sd, exist_ok=True)
874
+ payload = {"state": "working", "label": label,
875
+ "since": datetime.now(timezone.utc).isoformat()}
876
+ with open(os.path.join(sd, "activity.json"), "w", encoding="utf-8") as f:
877
+ f.write(json.dumps(payload) + "\n")
878
+ except Exception:
879
+ pass
880
+
881
+
882
+ def _clear_activity() -> None:
883
+ """Remove our status so neither an early exit nor the cron path (which does
884
+ NOT go through the MCP runTool's clear) leaves a stale 'posting/posted' stuck
885
+ in the menu bar. Double-clearing with runTool is harmless."""
886
+ try:
887
+ p = os.path.join(_saps_state_dir(), "activity.json")
888
+ if os.path.exists(p):
889
+ os.remove(p)
890
+ except Exception:
891
+ pass
892
+
893
+
848
894
  def main() -> int:
849
895
  ap = argparse.ArgumentParser()
850
896
  ap.add_argument("--plan", required=True,
@@ -924,26 +970,63 @@ def main() -> int:
924
970
  f"approved in plan (pass --post-unapproved to override)", flush=True)
925
971
  candidates = _kept
926
972
 
927
- for c in candidates:
928
- try:
929
- outcome, reason = post_one(c, picker_assignment=picker_assignment)
930
- except Exception as e:
931
- print(f"[post] candidate {c.get('candidate_id')} crashed: {e}",
932
- flush=True)
933
- outcome, reason = ("failed", "exception")
934
- cid = c.get("candidate_id")
935
- if isinstance(cid, int):
936
- update_candidate(cid, "skipped", "exception")
937
- if outcome == "posted":
938
- posted += 1
939
- elif outcome == "skipped":
940
- skipped += 1
941
- if reason:
942
- skip_reasons[reason] = skip_reasons.get(reason, 0) + 1
943
- else:
944
- failed += 1
945
- if reason:
946
- fail_reasons[reason] = fail_reasons.get(reason, 0) + 1
973
+ # Hard preflight: the reply path (twitter_browser.py) imports Playwright,
974
+ # the only such importer in the pipeline. If the resolved interpreter can't
975
+ # import it, EVERY post dies with no_reply_json because the owned runtime is
976
+ # missing or half-provisioned (Karol, 2026-06-22). Fail LOUD here with a
977
+ # distinct signal instead of attempting posts that silently no-op. Gated on
978
+ # there being real work, so a no-op / all-skipped plan still exits clean.
979
+ if candidates:
980
+ _chk = subprocess.run(
981
+ [PYTHON, "-c", "import playwright"],
982
+ capture_output=True, text=True,
983
+ )
984
+ if _chk.returncode != 0:
985
+ print(f"[post] FATAL runtime_incomplete: interpreter {PYTHON!r} cannot "
986
+ f"import playwright — the owned Python runtime is missing or "
987
+ f"unprovisioned. Run the `runtime` install (action:'install') "
988
+ f"before posting. stderr: {(_chk.stderr or '').strip()[:300]}",
989
+ file=sys.stderr, flush=True)
990
+ print(json.dumps({
991
+ "posted": 0,
992
+ "skipped": 0,
993
+ "failed": len(candidates),
994
+ "failure_reasons": "runtime_incomplete",
995
+ "skip_reasons": "",
996
+ }), flush=True)
997
+ return 3
998
+
999
+ _total = len(candidates)
1000
+ try:
1001
+ for _idx, c in enumerate(candidates, start=1):
1002
+ # Live per-post status for the S4L menu bar: "posting 3/10" while this
1003
+ # one is in flight, then "posted 3/10 ✓" once it lands. Cosmetic only.
1004
+ _write_activity(f"posting {_idx}/{_total}")
1005
+ try:
1006
+ outcome, reason = post_one(c, picker_assignment=picker_assignment)
1007
+ except Exception as e:
1008
+ print(f"[post] candidate {c.get('candidate_id')} crashed: {e}",
1009
+ flush=True)
1010
+ outcome, reason = ("failed", "exception")
1011
+ cid = c.get("candidate_id")
1012
+ if isinstance(cid, int):
1013
+ update_candidate(cid, "skipped", "exception")
1014
+ if outcome == "posted":
1015
+ posted += 1
1016
+ # Flash the confirmation with a short dwell so the menu bar shows
1017
+ # it before the next iteration's "posting" overwrites the label.
1018
+ _write_activity(f"posted {_idx}/{_total} ✓")
1019
+ time.sleep(0.6)
1020
+ elif outcome == "skipped":
1021
+ skipped += 1
1022
+ if reason:
1023
+ skip_reasons[reason] = skip_reasons.get(reason, 0) + 1
1024
+ else:
1025
+ failed += 1
1026
+ if reason:
1027
+ fail_reasons[reason] = fail_reasons.get(reason, 0) + 1
1028
+ finally:
1029
+ _clear_activity()
947
1030
 
948
1031
  summary = {
949
1032
  "posted": posted,
@@ -2070,7 +2070,7 @@ log "twitter-browser lock held (pid=$$) Phase 2b-post"
2070
2070
  ensure_twitter_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
2071
2071
 
2072
2072
  log "Phase 2b-post: posting $PLAN_COUNT candidate(s)..."
2073
- POST_OUTPUT=$(python3 "$REPO_DIR/scripts/twitter_post_plan.py" --plan "$PLAN_FILE" 2>&1)
2073
+ POST_OUTPUT=$("${SAPS_PYTHON:-python3}" "$REPO_DIR/scripts/twitter_post_plan.py" --plan "$PLAN_FILE" 2>&1)
2074
2074
  echo "$POST_OUTPUT" >> "$LOG_FILE"
2075
2075
 
2076
2076
  # The post helper prints a JSON summary on its last stdout line.