@m13v/s4l 1.7.1-rc.2 → 1.7.1-rc.4

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
@@ -1835,6 +1835,18 @@ tool("project_config", {
1835
1835
  // as expected. Once connect_x succeeds, run the full phase immediately
1836
1836
  // to verify persistence, CDP, and the durable cookie mirror.
1837
1837
  doctorReport = await runDoctorPhase("full");
1838
+ // Only mark x_verified once doctor confirms all X checks pass (session valid,
1839
+ // cookies persisted, CDP responding, keychain accessible). This prevents the
1840
+ // autopilot from starting if X connection didn't actually persist.
1841
+ if (doctorReport?.ok) {
1842
+ completeOnboardingMilestone("x_verified", {
1843
+ x_session: doctorReport.checks?.find(c => c.id === "x_session")?.status,
1844
+ x_cookies: doctorReport.checks?.find(c => c.id === "x_cookie_sqlite")?.status,
1845
+ });
1846
+ }
1847
+ else {
1848
+ recordOnboardingAttempt("x_verified", { doctor_ok: false });
1849
+ }
1838
1850
  }
1839
1851
  else {
1840
1852
  blockOnboardingMilestone("x_connected", `x_${r.state || "not_connected"}`, r.error || r.note || summarizeXAuth(r), { state: r.state || "not_connected" });
@@ -3225,6 +3237,18 @@ async function ensureQueueKickerInstalled() {
3225
3237
  detail: "no ready project or active persona yet",
3226
3238
  };
3227
3239
  }
3240
+ // Additional gate: X must be connected and verified before autopilot starts.
3241
+ // This prevents wasted cycles against a logged-out x.com if X connection didn't
3242
+ // persist after import/login. x_verified milestone only completes after doctor
3243
+ // confirms X session and cookies are valid.
3244
+ const onboardingState = onboardingSnapshot();
3245
+ const xVerified = onboardingState?.milestones?.some((m) => m.id === "x_verified" && m.status === "complete");
3246
+ if (!xVerified) {
3247
+ return {
3248
+ ok: false,
3249
+ detail: "X not yet verified (awaiting explicit pre-flight confirmation)",
3250
+ };
3251
+ }
3228
3252
  const logDir = path.join(repoDir(), "skill", "logs");
3229
3253
  try {
3230
3254
  fs.mkdirSync(logDir, { recursive: true });
@@ -14,6 +14,7 @@ export const ONBOARDING_MILESTONES = [
14
14
  "environment_checked",
15
15
  "runtime_ready",
16
16
  "x_connected",
17
+ "x_verified",
17
18
  "profile_scanned",
18
19
  "mode_chosen",
19
20
  "project_ready",
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.1-rc.2",
3
- "installedAt": "2026-07-07T19:26:54.404Z"
2
+ "version": "1.7.1-rc.4",
3
+ "installedAt": "2026-07-07T21:09:29.839Z"
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.7.1-rc.2",
5
+ "version": "1.7.1-rc.4",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
@@ -856,12 +856,13 @@ class _ReviewController(NSObject):
856
856
  content.addSubview_(deye)
857
857
  self._details_btn = deye
858
858
 
859
- # Editable reply, with the attached link folded in as it'll post (no
860
- # separate link field). The trailing link is stripped on send so the
861
- # pipeline still mints the tracked /r/<code> short link (no double link).
859
+ # Editable reply. Since 2026-07-06 the tail link is folded into
860
+ # reply_text at DRAFT time (scripts/twitter_gen_links.py::apply_tail_link),
861
+ # so it's normally already there — only append link_url when reply_text
862
+ # is genuinely link-free (older/fallback plans), else it shows twice.
862
863
  reply = d.get("reply_text") or ""
863
864
  link = d.get("link_url")
864
- composed = f"{reply} {link}" if link else reply
865
+ composed = reply if (not link or link in reply) else f"{reply} {link}"
865
866
  # Non-English draft with a stamped translation: a muted read-only
866
867
  # "EN:" block sits between the heading and the editable field so the
867
868
  # reviewer reads the draft in English while still editing (and
@@ -1082,23 +1083,19 @@ class _ReviewController(NSObject):
1082
1083
  drop_link = False
1083
1084
  if approved:
1084
1085
  text = self._current_text()
1085
- # The card folds link_url into the editable field (see _render). On
1086
- # send we must reconcile what the user did with that link:
1087
- # - link still present anywhere -> remove ALL occurrences so the
1088
- # poster mints the single tracked /r/<code> short link (no double
1089
- # link, no bare URL). Generalizes the old endswith() strip, which
1090
- # missed the link whenever the user typed anything after it.
1091
- # - link gone (user deleted it while editing) -> drop_link=True so
1092
- # the poster does NOT re-append it. Without this signal the post
1093
- # pipeline's forced TWITTER_TAIL_LINK_RATE=1.0 silently revived a
1094
- # link the user intentionally removed.
1095
- if link:
1096
- if link in text:
1097
- text = text.replace(link, "")
1098
- else:
1099
- drop_link = True
1100
- # Collapse only horizontal whitespace left by the removal; preserve
1101
- # any newlines the user intended.
1086
+ # reply_text already carries the link embedded at draft time (see
1087
+ # _render) — post it as-is, don't strip it back out. tail_link_variant
1088
+ # is stamped before the card is ever shown, so the post pipeline now
1089
+ # trusts reply_text verbatim and never re-adds a missing link; the old
1090
+ # strip-then-hope-the-poster-revives-it logic predates that move and
1091
+ # was mislabeling every approval as "edited" (body != orig once the
1092
+ # link was stripped) and occasionally shipping a tweet with no link
1093
+ # at all. Only signal drop_link when the user actually deleted the
1094
+ # link while editing, so the poster clears link_url instead of
1095
+ # reviving it.
1096
+ if link and link not in text:
1097
+ drop_link = True
1098
+ # Collapse only horizontal whitespace runs; preserve intended newlines.
1102
1099
  body = re.sub(r"[ \t]{2,}", " ", text).strip()
1103
1100
  else:
1104
1101
  body = orig
@@ -469,6 +469,24 @@ class S4LMenuBar(rumps.App):
469
469
  f"[s4l-menubar] accessibility_trusted={st.accessibility_trusted()}\n"
470
470
  )
471
471
  sys.stderr.flush()
472
+ # Status-item visibility watchdog (telemetry only). A live process says
473
+ # nothing about whether macOS is actually DRAWING our icon: on 2026-07-07
474
+ # a customer's menu bar process ran for ~25 min with no visible icon
475
+ # (post-reboot login storm) and support had no signal to tell "process
476
+ # dead" from "icon hidden". _tick probes the real NSStatusItem every 60s,
477
+ # persists menubar-state.json (memory_snapshot ships it on the heartbeat)
478
+ # and logs transitions to stderr. Deliberately NO auto-restart: the
479
+ # occlusion probe also reads not-visible during fullscreen apps and the
480
+ # locked screen, so acting on it would false-fire; the remote fix is the
481
+ # restart_menubar MCP tool.
482
+ # First probe at +60s, not immediately: the window server takes a few
483
+ # seconds to place a fresh status item (occlusion reads not-visible and
484
+ # the frame sits at x=0 until then — measured 2026-07-07), so an eager
485
+ # probe would log a false "hidden" on every single process start.
486
+ self._icon_check_due_at = time.monotonic() + 60.0
487
+ self._icon_hidden_since = None
488
+ self._icon_last_on_screen = "unprobed"
489
+ self._icon_watch_started = time.monotonic()
472
490
  self._timer = rumps.Timer(self._tick, POLL_SECONDS)
473
491
  self._timer.start()
474
492
  # Light 1s poll for server activity (scanning/drafting/posting/…); it
@@ -2020,6 +2038,98 @@ class S4LMenuBar(rumps.App):
2020
2038
  self._notify("S4L", f"Resuming {resumed} approved draft(s) after restart…")
2021
2039
 
2022
2040
  # ---- tick: read state, set title, (re)build menu ----------------------
2041
+ def _probe_statusitem(self):
2042
+ """Actual on-screen state of our NSStatusItem: (visible_pref, on_screen).
2043
+
2044
+ visible_pref False = the item was removed from the bar (e.g. the user
2045
+ Cmd-dragged it off). on_screen False with visible_pref True = the item
2046
+ exists but macOS is not drawing it: menu bar full / notch clipping, a
2047
+ status-item render glitch after a login storm, OR a benign fullscreen
2048
+ app / locked screen (why this feeds telemetry, never an automatic
2049
+ action). Two independent signals are recorded because we don't yet know
2050
+ which one is reliable in the field: the window's occlusion state and
2051
+ whether its frame intersects any screen (clipped items get parked
2052
+ off-screen). Either value may be None when the probe can't answer (old
2053
+ macOS, rumps internals moved). Never raises."""
2054
+ try:
2055
+ item = getattr(getattr(self, "_nsapp", None), "nsstatusitem", None)
2056
+ if item is None:
2057
+ return None, None
2058
+ visible_pref = None
2059
+ try:
2060
+ visible_pref = bool(item.isVisible())
2061
+ except Exception:
2062
+ pass
2063
+ on_screen = None
2064
+ try:
2065
+ win = item.button().window()
2066
+ if win is not None:
2067
+ from AppKit import NSScreen, NSWindowOcclusionStateVisible
2068
+ occluded_visible = bool(
2069
+ win.occlusionState() & NSWindowOcclusionStateVisible
2070
+ )
2071
+ f = win.frame()
2072
+ intersects = False
2073
+ for s in NSScreen.screens():
2074
+ sf = s.frame()
2075
+ if (
2076
+ f.origin.x < sf.origin.x + sf.size.width
2077
+ and f.origin.x + f.size.width > sf.origin.x
2078
+ and f.origin.y < sf.origin.y + sf.size.height
2079
+ and f.origin.y + f.size.height > sf.origin.y
2080
+ ):
2081
+ intersects = True
2082
+ break
2083
+ on_screen = occluded_visible and intersects
2084
+ except Exception:
2085
+ pass
2086
+ return visible_pref, on_screen
2087
+ except Exception:
2088
+ return None, None
2089
+
2090
+ def _icon_watch_tick(self, now):
2091
+ """60s-throttled visibility probe + menubar-state.json write. See the
2092
+ __init__ comment for why this exists and why it never self-heals."""
2093
+ if now < self._icon_check_due_at:
2094
+ return
2095
+ self._icon_check_due_at = now + 60.0
2096
+ try:
2097
+ visible_pref, on_screen = self._probe_statusitem()
2098
+ if on_screen is False:
2099
+ if self._icon_hidden_since is None:
2100
+ self._icon_hidden_since = now
2101
+ else:
2102
+ self._icon_hidden_since = None
2103
+ hidden_for = (
2104
+ int(now - self._icon_hidden_since)
2105
+ if self._icon_hidden_since is not None
2106
+ else 0
2107
+ )
2108
+ if on_screen != self._icon_last_on_screen:
2109
+ sys.stderr.write(
2110
+ f"[s4l-menubar] statusitem on_screen={on_screen} "
2111
+ f"visible_pref={visible_pref} hidden_for_sec={hidden_for}\n"
2112
+ )
2113
+ sys.stderr.flush()
2114
+ self._icon_last_on_screen = on_screen
2115
+ dest = os.path.join(st.state_dir(), "menubar-state.json")
2116
+ tmp = dest + ".tmp"
2117
+ with open(tmp, "w") as f:
2118
+ json.dump(
2119
+ {
2120
+ "ts": time.time(),
2121
+ "pid": os.getpid(),
2122
+ "uptime_sec": int(now - self._icon_watch_started),
2123
+ "statusitem_on_screen": on_screen,
2124
+ "statusitem_visible_pref": visible_pref,
2125
+ "hidden_for_sec": hidden_for,
2126
+ },
2127
+ f,
2128
+ )
2129
+ os.replace(tmp, dest)
2130
+ except Exception:
2131
+ pass
2132
+
2023
2133
  def _tick(self, _):
2024
2134
  # Post-update verdict: cheap (a single stat when no update is pending).
2025
2135
  try:
@@ -2053,6 +2163,8 @@ class S4LMenuBar(rumps.App):
2053
2163
  pass
2054
2164
  else:
2055
2165
  self._resumed = False
2166
+ # Status-item visibility telemetry (60s-throttled inside; see __init__).
2167
+ self._icon_watch_tick(now)
2056
2168
  # The activity spinner owns the TITLE while a tool runs (we don't fight it at
2057
2169
  # 0.12s), but the menu + update indicator must still refresh mid-run —
2058
2170
  # otherwise the "Update now & restart Claude Desktop" item never appears on a box that's always
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.1-rc.2",
3
+ "version": "1.7.1-rc.4",
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": "@m13v/s4l",
3
- "version": "1.7.1-rc.2",
3
+ "version": "1.7.1-rc.4",
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",
@@ -604,6 +604,7 @@ def build_snapshot(top_n: int) -> dict[str, Any]:
604
604
  "repo_dir": str(REPO_DIR),
605
605
  "claude_desktop_version": claude_desktop_version(),
606
606
  "reaper": reaper_status(),
607
+ "menubar": menubar_status(),
607
608
  "memory": parse_vm_stat(),
608
609
  "process_count": len(rows),
609
610
  "top_rss": [
@@ -696,6 +697,7 @@ def build_summary() -> dict[str, Any]:
696
697
  "app_version": _app_version(),
697
698
  "claude_desktop_version": claude_desktop_version(),
698
699
  "reaper": reaper_status(),
700
+ "menubar": menubar_status(),
699
701
  "twitter_cycle": twitter_cycle_status(),
700
702
  "process_count": len(rows),
701
703
  "mem": {
@@ -765,6 +767,39 @@ def claude_desktop_version() -> str | None:
765
767
  return None
766
768
 
767
769
 
770
+ def menubar_status() -> dict[str, Any] | None:
771
+ """Last status-item visibility probe written by s4l_menubar._icon_watch_tick(),
772
+ or None. A live menubar PROCESS says nothing about whether macOS is drawing
773
+ its icon (2026-07-07: a customer's menubar ran ~25 min icon-less after a
774
+ reboot and support had no remote signal). Carrying this on the heartbeat
775
+ makes "process up, icon hidden" diagnosable centrally. age_sec doubles as a
776
+ process-liveness hint: the menubar rewrites the file every ~60s, so a stale
777
+ file means the menubar itself is down."""
778
+ path = (
779
+ Path(os.environ.get("S4L_STATE_DIR", str(Path.home() / ".social-autoposter-mcp")))
780
+ / "menubar-state.json"
781
+ )
782
+ try:
783
+ if not path.exists():
784
+ return None
785
+ ds = json.loads(path.read_text())
786
+ age = None
787
+ try:
788
+ age = round(time.time() - path.stat().st_mtime, 1)
789
+ except OSError:
790
+ pass
791
+ return {
792
+ "age_sec": age, # >150s means the menubar process itself is likely dead
793
+ "pid": ds.get("pid"),
794
+ "uptime_sec": ds.get("uptime_sec"),
795
+ "statusitem_on_screen": ds.get("statusitem_on_screen"),
796
+ "statusitem_visible_pref": ds.get("statusitem_visible_pref"),
797
+ "hidden_for_sec": ds.get("hidden_for_sec"),
798
+ }
799
+ except Exception:
800
+ return None
801
+
802
+
768
803
  def reaper_status() -> dict[str, Any] | None:
769
804
  """Last cycle written by reap_stale_claude_sessions.py::write_status(), or None.
770
805