@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.
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +18 -21
- package/mcp/menubar/s4l_menubar.py +108 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/memory_snapshot.py +35 -0
package/mcp/dist/version.json
CHANGED
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.
|
|
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": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -856,12 +856,13 @@ class _ReviewController(NSObject):
|
|
|
856
856
|
content.addSubview_(deye)
|
|
857
857
|
self._details_btn = deye
|
|
858
858
|
|
|
859
|
-
# Editable reply
|
|
860
|
-
#
|
|
861
|
-
#
|
|
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}"
|
|
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
|
-
#
|
|
1086
|
-
#
|
|
1087
|
-
#
|
|
1088
|
-
#
|
|
1089
|
-
#
|
|
1090
|
-
#
|
|
1091
|
-
#
|
|
1092
|
-
#
|
|
1093
|
-
#
|
|
1094
|
-
#
|
|
1095
|
-
if link:
|
|
1096
|
-
|
|
1097
|
-
|
|
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.
|
|
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
|
@@ -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
|
|