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

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.
@@ -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.3",
3
+ "installedAt": "2026-07-07T20:42:48.680Z"
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.3",
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,20 @@ 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
+ self._icon_check_due_at = 0.0
483
+ self._icon_hidden_since = None
484
+ self._icon_last_on_screen = "unprobed"
485
+ self._icon_watch_started = time.monotonic()
472
486
  self._timer = rumps.Timer(self._tick, POLL_SECONDS)
473
487
  self._timer.start()
474
488
  # Light 1s poll for server activity (scanning/drafting/posting/…); it
@@ -2020,6 +2034,98 @@ class S4LMenuBar(rumps.App):
2020
2034
  self._notify("S4L", f"Resuming {resumed} approved draft(s) after restart…")
2021
2035
 
2022
2036
  # ---- tick: read state, set title, (re)build menu ----------------------
2037
+ def _probe_statusitem(self):
2038
+ """Actual on-screen state of our NSStatusItem: (visible_pref, on_screen).
2039
+
2040
+ visible_pref False = the item was removed from the bar (e.g. the user
2041
+ Cmd-dragged it off). on_screen False with visible_pref True = the item
2042
+ exists but macOS is not drawing it: menu bar full / notch clipping, a
2043
+ status-item render glitch after a login storm, OR a benign fullscreen
2044
+ app / locked screen (why this feeds telemetry, never an automatic
2045
+ action). Two independent signals are recorded because we don't yet know
2046
+ which one is reliable in the field: the window's occlusion state and
2047
+ whether its frame intersects any screen (clipped items get parked
2048
+ off-screen). Either value may be None when the probe can't answer (old
2049
+ macOS, rumps internals moved). Never raises."""
2050
+ try:
2051
+ item = getattr(getattr(self, "_nsapp", None), "nsstatusitem", None)
2052
+ if item is None:
2053
+ return None, None
2054
+ visible_pref = None
2055
+ try:
2056
+ visible_pref = bool(item.isVisible())
2057
+ except Exception:
2058
+ pass
2059
+ on_screen = None
2060
+ try:
2061
+ win = item.button().window()
2062
+ if win is not None:
2063
+ from AppKit import NSScreen, NSWindowOcclusionStateVisible
2064
+ occluded_visible = bool(
2065
+ win.occlusionState() & NSWindowOcclusionStateVisible
2066
+ )
2067
+ f = win.frame()
2068
+ intersects = False
2069
+ for s in NSScreen.screens():
2070
+ sf = s.frame()
2071
+ if (
2072
+ f.origin.x < sf.origin.x + sf.size.width
2073
+ and f.origin.x + f.size.width > sf.origin.x
2074
+ and f.origin.y < sf.origin.y + sf.size.height
2075
+ and f.origin.y + f.size.height > sf.origin.y
2076
+ ):
2077
+ intersects = True
2078
+ break
2079
+ on_screen = occluded_visible and intersects
2080
+ except Exception:
2081
+ pass
2082
+ return visible_pref, on_screen
2083
+ except Exception:
2084
+ return None, None
2085
+
2086
+ def _icon_watch_tick(self, now):
2087
+ """60s-throttled visibility probe + menubar-state.json write. See the
2088
+ __init__ comment for why this exists and why it never self-heals."""
2089
+ if now < self._icon_check_due_at:
2090
+ return
2091
+ self._icon_check_due_at = now + 60.0
2092
+ try:
2093
+ visible_pref, on_screen = self._probe_statusitem()
2094
+ if on_screen is False:
2095
+ if self._icon_hidden_since is None:
2096
+ self._icon_hidden_since = now
2097
+ else:
2098
+ self._icon_hidden_since = None
2099
+ hidden_for = (
2100
+ int(now - self._icon_hidden_since)
2101
+ if self._icon_hidden_since is not None
2102
+ else 0
2103
+ )
2104
+ if on_screen != self._icon_last_on_screen:
2105
+ sys.stderr.write(
2106
+ f"[s4l-menubar] statusitem on_screen={on_screen} "
2107
+ f"visible_pref={visible_pref} hidden_for_sec={hidden_for}\n"
2108
+ )
2109
+ sys.stderr.flush()
2110
+ self._icon_last_on_screen = on_screen
2111
+ dest = os.path.join(st.state_dir(), "menubar-state.json")
2112
+ tmp = dest + ".tmp"
2113
+ with open(tmp, "w") as f:
2114
+ json.dump(
2115
+ {
2116
+ "ts": time.time(),
2117
+ "pid": os.getpid(),
2118
+ "uptime_sec": int(now - self._icon_watch_started),
2119
+ "statusitem_on_screen": on_screen,
2120
+ "statusitem_visible_pref": visible_pref,
2121
+ "hidden_for_sec": hidden_for,
2122
+ },
2123
+ f,
2124
+ )
2125
+ os.replace(tmp, dest)
2126
+ except Exception:
2127
+ pass
2128
+
2023
2129
  def _tick(self, _):
2024
2130
  # Post-update verdict: cheap (a single stat when no update is pending).
2025
2131
  try:
@@ -2053,6 +2159,8 @@ class S4LMenuBar(rumps.App):
2053
2159
  pass
2054
2160
  else:
2055
2161
  self._resumed = False
2162
+ # Status-item visibility telemetry (60s-throttled inside; see __init__).
2163
+ self._icon_watch_tick(now)
2056
2164
  # The activity spinner owns the TITLE while a tool runs (we don't fight it at
2057
2165
  # 0.12s), but the menu + update indicator must still refresh mid-run —
2058
2166
  # 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.3",
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.3",
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