@m13v/s4l 1.7.1-rc.2 → 1.7.1-rc.21
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/bin/cli.js +23 -0
- package/mcp/dist/index.js +332 -24
- package/mcp/dist/onboarding.js +3 -10
- package/mcp/dist/panel.html +42 -31
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/runtime.js +101 -1
- package/mcp/dist/telemetry.js +80 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +9 -1
- package/mcp/menubar/dashboard_server.py +12 -0
- package/mcp/menubar/s4l_card.py +472 -96
- package/mcp/menubar/s4l_menubar.py +269 -26
- package/mcp/menubar/s4l_state.py +169 -23
- package/mcp/package.json +1 -1
- package/mcp/shared/doctor.cjs +8 -0
- package/mcp/shared/onboarding-ledger.cjs +1 -0
- package/package.json +3 -1
- package/scripts/active_users.py +3 -6
- package/scripts/autopilot_stall_watch.py +200 -16
- package/scripts/claude_job.py +6 -0
- package/scripts/feedback_digest.py +34 -6
- package/scripts/get-latest-staging-mcpb.sh +50 -0
- package/scripts/harness_overlay.py +45 -0
- package/scripts/identity.py +26 -0
- package/scripts/learned_preferences.py +241 -32
- package/scripts/link_tail.py +167 -91
- package/scripts/mark_event.py +128 -0
- package/scripts/memory_snapshot.py +62 -0
- package/scripts/pick_twitter_thread_target.py +1 -1
- package/scripts/release-mcpb.sh +48 -0
- package/scripts/reset-test-machine.sh +85 -3
- package/scripts/s4l_mode.py +7 -5
- package/scripts/scheduled_tasks_snapshot.py +107 -0
- package/scripts/send_gmail_report.py +76 -0
- package/scripts/sentry_digest.py +81 -189
- package/scripts/sentry_init.py +43 -15
- package/scripts/setup_twitter_auth.py +76 -38
- package/scripts/snapshot.py +20 -0
- package/scripts/twitter_post_plan.py +39 -13
- package/skill/dm-outreach-twitter.sh +19 -0
- package/skill/engage-twitter.sh +27 -0
- package/skill/run-draft-and-publish.sh +13 -12
- package/skill/run-linkedin.sh +9 -1
- package/skill/run-twitter-cycle.sh +199 -27
- package/skill/sentry-digest.sh +125 -12
|
@@ -132,20 +132,24 @@ except Exception as _import_err:
|
|
|
132
132
|
import s4l_state as st # noqa: E402
|
|
133
133
|
|
|
134
134
|
# AppKit is available in the owned venv (PyObjC is a rumps dependency). We use it
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
# menu bar — the alert still shows, just possibly not front-most.
|
|
135
|
+
# to pull the accessory (LSUIElement) app to the front AND to force the alert
|
|
136
|
+
# window itself above whatever else is on screen. Guarded so a missing AppKit
|
|
137
|
+
# never breaks the menu bar — the alert still shows, just possibly not
|
|
138
|
+
# front-most.
|
|
140
139
|
try:
|
|
141
|
-
from AppKit import NSApplication # noqa: E402
|
|
140
|
+
from AppKit import NSApplication, NSAlert, NSFloatingWindowLevel # noqa: E402
|
|
142
141
|
except Exception:
|
|
143
142
|
NSApplication = None
|
|
143
|
+
NSAlert = None
|
|
144
|
+
NSFloatingWindowLevel = None
|
|
144
145
|
|
|
145
146
|
|
|
146
147
|
def _activate_front():
|
|
147
|
-
"""Bring this accessory app to the front so the next
|
|
148
|
-
|
|
148
|
+
"""Bring this accessory app to the front so the next alert opens on top of
|
|
149
|
+
whatever was frontmost, instead of behind it. Best-effort; kept as a
|
|
150
|
+
belt-and-suspenders call alongside _show_alert's own window-level fix
|
|
151
|
+
below, since activation also affects which app is "key" for keyboard
|
|
152
|
+
input on the alert's buttons."""
|
|
149
153
|
try:
|
|
150
154
|
if NSApplication is not None:
|
|
151
155
|
NSApplication.sharedApplication().activateIgnoringOtherApps_(True)
|
|
@@ -153,6 +157,44 @@ def _activate_front():
|
|
|
153
157
|
pass
|
|
154
158
|
|
|
155
159
|
|
|
160
|
+
def _show_alert(title=None, message='', ok=None, cancel=None, other=None):
|
|
161
|
+
"""Like rumps.alert(), but actually shows up on top.
|
|
162
|
+
|
|
163
|
+
rumps.alert() builds an NSAlert and immediately calls runModal() with no
|
|
164
|
+
window-level or ordering call at all — it just assumes the app is already
|
|
165
|
+
active. activateIgnoringOtherApps_ (_activate_front, above) is async, so
|
|
166
|
+
it can lose the race against runModal()'s own (assumed-active) window
|
|
167
|
+
presentation: the alert window is created at the default level and never
|
|
168
|
+
explicitly raised, so when this accessory app ISN'T active (e.g. Claude
|
|
169
|
+
Desktop is frontmost), it renders BEHIND Claude's window instead of on
|
|
170
|
+
top of it. Confirmed recurring 2026-07-07 (uninstall modal hidden behind
|
|
171
|
+
Claude Desktop) despite the activation call landing 2026-06-30.
|
|
172
|
+
|
|
173
|
+
Fix: build the NSAlert ourselves (same call rumps.alert uses) and force
|
|
174
|
+
its window to NSFloatingWindowLevel + orderFrontRegardless() BEFORE
|
|
175
|
+
runModal(), mirroring the pattern s4l_card.py's review panel already uses
|
|
176
|
+
successfully (see _ReviewPanel._build: setLevel_(NSFloatingWindowLevel) +
|
|
177
|
+
orderFrontRegardless()). Floating level sits above normal app windows in
|
|
178
|
+
the window server's global z-order regardless of app-activation state,
|
|
179
|
+
so it doesn't depend on the activation race resolving in time."""
|
|
180
|
+
_activate_front()
|
|
181
|
+
if NSAlert is None:
|
|
182
|
+
# AppKit unavailable in this venv: fall back to rumps' plain alert.
|
|
183
|
+
return rumps.alert(title=title, message=message, ok=ok, cancel=cancel, other=other)
|
|
184
|
+
msg = str(message).replace('%', '%%')
|
|
185
|
+
if not isinstance(cancel, str):
|
|
186
|
+
cancel = 'Cancel' if cancel else None
|
|
187
|
+
alert = NSAlert.alertWithMessageText_defaultButton_alternateButton_otherButton_informativeTextWithFormat_(
|
|
188
|
+
title, ok, cancel, other, msg)
|
|
189
|
+
alert.setAlertStyle_(0) # same style rumps.alert() uses
|
|
190
|
+
try:
|
|
191
|
+
alert.window().setLevel_(NSFloatingWindowLevel)
|
|
192
|
+
alert.window().orderFrontRegardless()
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
return alert.runModal()
|
|
196
|
+
|
|
197
|
+
|
|
156
198
|
CLAUDE_APP = "Claude"
|
|
157
199
|
|
|
158
200
|
|
|
@@ -182,6 +224,14 @@ MENUBAR_PLIST = os.path.join(
|
|
|
182
224
|
# start actions (restart_menubar tool, queue_setup re-arm). Keep the filename in
|
|
183
225
|
# sync with MENUBAR_STOP_FLAG in mcp/src/runtime.ts.
|
|
184
226
|
STOP_FLAG = os.path.join(st.state_dir(), "stopped.flag")
|
|
227
|
+
# Pause sentinel (lighter than STOP_FLAG): while present, the pipeline daemons
|
|
228
|
+
# (kicker/reaper/stall-watch/memory-snapshot) stay unloaded and claude_job.py's
|
|
229
|
+
# `next` reports no work — but Claude Desktop, this tray, and the draft
|
|
230
|
+
# schedule registration are untouched. Written/cleared ONLY by the MCP's
|
|
231
|
+
# pause_s4l tool (see _pause_work below); this constant is read-only
|
|
232
|
+
# here, purely to render the menu label. Keep the filename in sync with
|
|
233
|
+
# pauseFlagPath() in mcp/src/index.ts.
|
|
234
|
+
PAUSE_FLAG = os.path.join(st.state_dir(), "paused.flag")
|
|
185
235
|
|
|
186
236
|
# Autopilot scheduled tasks. Queue workers must RUN in a dedicated folder
|
|
187
237
|
# (~/.s4l-worker) so their once-a-minute sessions don't flood the user's
|
|
@@ -242,6 +292,11 @@ REARM_PROMPT = (
|
|
|
242
292
|
"If the task already exists, call update_scheduled_task with taskId s4l-worker and "
|
|
243
293
|
"notifyOnCompletion false instead. "
|
|
244
294
|
"Do not redo my X connection or project setup — only register the scheduled task. "
|
|
295
|
+
"Before calling create_scheduled_task/list_scheduled_tasks/update_scheduled_task, run "
|
|
296
|
+
"(via Bash, best-effort, ignore any error and continue if the path doesn't exist): "
|
|
297
|
+
"python3 ~/.social-autoposter-mcp/repo/package/scripts/mark_event.py start <that tool's name>; "
|
|
298
|
+
"immediately after it returns, run the same with 'end' instead of 'start'. This makes a hang "
|
|
299
|
+
"in that host tool directly observable instead of invisible. "
|
|
245
300
|
"Keep replies short."
|
|
246
301
|
)
|
|
247
302
|
# Universal diagnose-and-heal prompt behind the ⚠ "Diagnose & fix" menu item. One
|
|
@@ -270,7 +325,11 @@ DIAGNOSE_PROMPT_TEMPLATE = (
|
|
|
270
325
|
"to fix, say so in the report. "
|
|
271
326
|
"(3) schedule missing/disabled — re-register via queue_setup or the host "
|
|
272
327
|
"create_scheduled_task with notifyOnCompletion false and the prompt from "
|
|
273
|
-
"~/.claude/scheduled-tasks/s4l-worker/SKILL.md
|
|
328
|
+
"~/.claude/scheduled-tasks/s4l-worker/SKILL.md. Before calling create_scheduled_task/"
|
|
329
|
+
"list_scheduled_tasks/update_scheduled_task, bracket it (Bash, best-effort, ignore if "
|
|
330
|
+
"missing): python3 ~/.social-autoposter-mcp/repo/package/scripts/mark_event.py start "
|
|
331
|
+
"<tool name>, then ...end <tool name> right after it returns — makes a hang in that "
|
|
332
|
+
"host tool directly observable.\n"
|
|
274
333
|
"HARD RULE: fix state files only. Never edit, copy, or patch code in "
|
|
275
334
|
"~/.social-autoposter-mcp or any extension bundle (hot patches are auto-reverted "
|
|
276
335
|
"and mask the real bug).\n"
|
|
@@ -469,6 +528,24 @@ class S4LMenuBar(rumps.App):
|
|
|
469
528
|
f"[s4l-menubar] accessibility_trusted={st.accessibility_trusted()}\n"
|
|
470
529
|
)
|
|
471
530
|
sys.stderr.flush()
|
|
531
|
+
# Status-item visibility watchdog (telemetry only). A live process says
|
|
532
|
+
# nothing about whether macOS is actually DRAWING our icon: on 2026-07-07
|
|
533
|
+
# a customer's menu bar process ran for ~25 min with no visible icon
|
|
534
|
+
# (post-reboot login storm) and support had no signal to tell "process
|
|
535
|
+
# dead" from "icon hidden". _tick probes the real NSStatusItem every 60s,
|
|
536
|
+
# persists menubar-state.json (memory_snapshot ships it on the heartbeat)
|
|
537
|
+
# and logs transitions to stderr. Deliberately NO auto-restart: the
|
|
538
|
+
# occlusion probe also reads not-visible during fullscreen apps and the
|
|
539
|
+
# locked screen, so acting on it would false-fire; the remote fix is the
|
|
540
|
+
# restart_menubar MCP tool.
|
|
541
|
+
# First probe at +60s, not immediately: the window server takes a few
|
|
542
|
+
# seconds to place a fresh status item (occlusion reads not-visible and
|
|
543
|
+
# the frame sits at x=0 until then — measured 2026-07-07), so an eager
|
|
544
|
+
# probe would log a false "hidden" on every single process start.
|
|
545
|
+
self._icon_check_due_at = time.monotonic() + 60.0
|
|
546
|
+
self._icon_hidden_since = None
|
|
547
|
+
self._icon_last_on_screen = "unprobed"
|
|
548
|
+
self._icon_watch_started = time.monotonic()
|
|
472
549
|
self._timer = rumps.Timer(self._tick, POLL_SECONDS)
|
|
473
550
|
self._timer.start()
|
|
474
551
|
# Light 1s poll for server activity (scanning/drafting/posting/…); it
|
|
@@ -577,7 +654,7 @@ class S4LMenuBar(rumps.App):
|
|
|
577
654
|
msg = "Click OK, then open Claude and ask it to " + action_desc + "."
|
|
578
655
|
try:
|
|
579
656
|
_activate_front()
|
|
580
|
-
|
|
657
|
+
_show_alert(title=title, message=msg, ok="OK")
|
|
581
658
|
except Exception:
|
|
582
659
|
self._notify("S4L · prompt copied" if copied else "S4L",
|
|
583
660
|
"Paste the prompt into Claude (Cmd+V) and press Enter.")
|
|
@@ -633,7 +710,21 @@ class S4LMenuBar(rumps.App):
|
|
|
633
710
|
clipboard, open Claude, and tell the user to paste it. (The dashboard
|
|
634
711
|
widget's button does this in one click via app.sendMessage — no paste.) We
|
|
635
712
|
do NOT auto-type (focus/timing flaky) and do NOT write the registry directly
|
|
636
|
-
(can't reliably target a just-switched-into account).
|
|
713
|
+
(can't reliably target a just-switched-into account).
|
|
714
|
+
|
|
715
|
+
The click itself is captured (mirroring _diagnose_fix) — previously this
|
|
716
|
+
button had NO telemetry at all, so a past incident (Karol, 2026-07-07)
|
|
717
|
+
could only establish "a human must have clicked SOMETHING" from the code
|
|
718
|
+
path, never which button or exactly when. The registry snapshot rides as
|
|
719
|
+
an extra so the pre-rearm state is on record without on-box forensics."""
|
|
720
|
+
sched = getattr(self, "_schedule_state_cache", "") or ""
|
|
721
|
+
_capture_msg(
|
|
722
|
+
"S4L rearm clicked",
|
|
723
|
+
phase="rearm",
|
|
724
|
+
surface="menubar",
|
|
725
|
+
schedule_state=sched or "unknown",
|
|
726
|
+
_extra={"registry": _registry_summary_for_capture()},
|
|
727
|
+
)
|
|
637
728
|
self._clipboard_prompt(
|
|
638
729
|
REARM_PROMPT,
|
|
639
730
|
"Set up the draft schedule",
|
|
@@ -648,7 +739,7 @@ class S4LMenuBar(rumps.App):
|
|
|
648
739
|
also run the registry self-heal, which additionally repairs the
|
|
649
740
|
account-switch orphan case, so this one click covers both known causes."""
|
|
650
741
|
_activate_front()
|
|
651
|
-
choice =
|
|
742
|
+
choice = _show_alert(
|
|
652
743
|
title="Restart Claude Desktop?",
|
|
653
744
|
message=(
|
|
654
745
|
"Claude’s scheduler stopped running the draft tasks (a known "
|
|
@@ -976,7 +1067,7 @@ class S4LMenuBar(rumps.App):
|
|
|
976
1067
|
# ok=1 (plugin reset, keeps X login + browser layer), other=-1 (deep wipe),
|
|
977
1068
|
# cancel=0. See rumps.alert: default=1, alternate=0, other=-1.
|
|
978
1069
|
_activate_front()
|
|
979
|
-
choice =
|
|
1070
|
+
choice = _show_alert(
|
|
980
1071
|
title="Uninstall S4L?",
|
|
981
1072
|
message=(
|
|
982
1073
|
"This quits Claude Desktop, removes the S4L extension + its "
|
|
@@ -1019,7 +1110,7 @@ class S4LMenuBar(rumps.App):
|
|
|
1019
1110
|
def _alert(self, title, message):
|
|
1020
1111
|
try:
|
|
1021
1112
|
_activate_front()
|
|
1022
|
-
|
|
1113
|
+
_show_alert(title=title, message=message, ok="OK")
|
|
1023
1114
|
except Exception:
|
|
1024
1115
|
pass
|
|
1025
1116
|
|
|
@@ -1043,6 +1134,40 @@ class S4LMenuBar(rumps.App):
|
|
|
1043
1134
|
pass
|
|
1044
1135
|
return False
|
|
1045
1136
|
|
|
1137
|
+
def _pause_toggle(self, _=None):
|
|
1138
|
+
"""Pause/Resume: the lighter, fully reversible alternative to Quit. Stops
|
|
1139
|
+
S4L's own scan/draft/post pipeline (the launchd kicker + its support
|
|
1140
|
+
daemons) while leaving Claude Desktop, this tray, and the draft
|
|
1141
|
+
schedule registration alone — nothing is deleted, so it survives a
|
|
1142
|
+
Claude restart and Resume brings it right back. Runs the real
|
|
1143
|
+
launchctl work directly via st.pause_s4l()/resume_s4l() (this process
|
|
1144
|
+
already does its own launchctl calls elsewhere, e.g. _quit_work) so it
|
|
1145
|
+
works even when Claude Desktop is closed — no MCP/loopback round trip."""
|
|
1146
|
+
action = "resume" if os.path.exists(PAUSE_FLAG) else "pause"
|
|
1147
|
+
self._notify("S4L", "Resuming…" if action == "resume" else "Pausing…")
|
|
1148
|
+
threading.Thread(target=self._pause_work, args=(action,), daemon=True).start()
|
|
1149
|
+
|
|
1150
|
+
def _pause_work(self, action):
|
|
1151
|
+
res = st.pause_s4l() if action == "pause" else st.resume_s4l()
|
|
1152
|
+
ok = bool(res.get("ok"))
|
|
1153
|
+
if action == "pause":
|
|
1154
|
+
self._notify(
|
|
1155
|
+
"S4L",
|
|
1156
|
+
"S4L paused. Drafting and posting are stopped; Claude and the tray "
|
|
1157
|
+
"stay up. Click Resume S4L to start again."
|
|
1158
|
+
if ok else "Couldn't fully pause S4L — see logs.",
|
|
1159
|
+
)
|
|
1160
|
+
else:
|
|
1161
|
+
self._notify(
|
|
1162
|
+
"S4L", "S4L resumed." if ok else "Couldn't fully resume S4L — see logs."
|
|
1163
|
+
)
|
|
1164
|
+
self._sig = None
|
|
1165
|
+
try:
|
|
1166
|
+
self._tick(None)
|
|
1167
|
+
except Exception as e:
|
|
1168
|
+
sys.stderr.write(f"[s4l-menubar] pause toggle rebuild failed: {e}\n")
|
|
1169
|
+
sys.stderr.flush()
|
|
1170
|
+
|
|
1046
1171
|
def _quit_app(self, _=None):
|
|
1047
1172
|
"""The single Quit path. Quitting stops the autopilot completely: the
|
|
1048
1173
|
draft/query scheduled tasks are removed so they no longer fire, AND the
|
|
@@ -1054,7 +1179,7 @@ class S4LMenuBar(rumps.App):
|
|
|
1054
1179
|
modal FIRST that Claude Desktop will restart, since the app window will
|
|
1055
1180
|
close and reopen under them."""
|
|
1056
1181
|
_activate_front()
|
|
1057
|
-
choice =
|
|
1182
|
+
choice = _show_alert(
|
|
1058
1183
|
title="Quit the S4L autoposter?",
|
|
1059
1184
|
message=(
|
|
1060
1185
|
"Quitting stops the autoposter completely: the draft + query "
|
|
@@ -1740,16 +1865,14 @@ class S4LMenuBar(rumps.App):
|
|
|
1740
1865
|
self._cwd_healed = True
|
|
1741
1866
|
return
|
|
1742
1867
|
_activate_front()
|
|
1743
|
-
choice =
|
|
1744
|
-
title="Tidy
|
|
1868
|
+
choice = _show_alert(
|
|
1869
|
+
title="Tidy S4L background tasks?",
|
|
1745
1870
|
message=(
|
|
1746
|
-
"
|
|
1747
|
-
"
|
|
1748
|
-
"
|
|
1749
|
-
"
|
|
1750
|
-
"
|
|
1751
|
-
"close and reopen in a moment. Your X login, drafts, and config "
|
|
1752
|
-
"all stay."
|
|
1871
|
+
"Move the background worker to a dedicated folder so its "
|
|
1872
|
+
"once-a-minute runs stop cluttering your `claude --resume` "
|
|
1873
|
+
"history.\n\n"
|
|
1874
|
+
"Claude Desktop will restart once to apply. Your X login, "
|
|
1875
|
+
"drafts, and config all stay."
|
|
1753
1876
|
),
|
|
1754
1877
|
ok="Tidy & restart Claude", cancel="Later",
|
|
1755
1878
|
)
|
|
@@ -2020,6 +2143,98 @@ class S4LMenuBar(rumps.App):
|
|
|
2020
2143
|
self._notify("S4L", f"Resuming {resumed} approved draft(s) after restart…")
|
|
2021
2144
|
|
|
2022
2145
|
# ---- tick: read state, set title, (re)build menu ----------------------
|
|
2146
|
+
def _probe_statusitem(self):
|
|
2147
|
+
"""Actual on-screen state of our NSStatusItem: (visible_pref, on_screen).
|
|
2148
|
+
|
|
2149
|
+
visible_pref False = the item was removed from the bar (e.g. the user
|
|
2150
|
+
Cmd-dragged it off). on_screen False with visible_pref True = the item
|
|
2151
|
+
exists but macOS is not drawing it: menu bar full / notch clipping, a
|
|
2152
|
+
status-item render glitch after a login storm, OR a benign fullscreen
|
|
2153
|
+
app / locked screen (why this feeds telemetry, never an automatic
|
|
2154
|
+
action). Two independent signals are recorded because we don't yet know
|
|
2155
|
+
which one is reliable in the field: the window's occlusion state and
|
|
2156
|
+
whether its frame intersects any screen (clipped items get parked
|
|
2157
|
+
off-screen). Either value may be None when the probe can't answer (old
|
|
2158
|
+
macOS, rumps internals moved). Never raises."""
|
|
2159
|
+
try:
|
|
2160
|
+
item = getattr(getattr(self, "_nsapp", None), "nsstatusitem", None)
|
|
2161
|
+
if item is None:
|
|
2162
|
+
return None, None
|
|
2163
|
+
visible_pref = None
|
|
2164
|
+
try:
|
|
2165
|
+
visible_pref = bool(item.isVisible())
|
|
2166
|
+
except Exception:
|
|
2167
|
+
pass
|
|
2168
|
+
on_screen = None
|
|
2169
|
+
try:
|
|
2170
|
+
win = item.button().window()
|
|
2171
|
+
if win is not None:
|
|
2172
|
+
from AppKit import NSScreen, NSWindowOcclusionStateVisible
|
|
2173
|
+
occluded_visible = bool(
|
|
2174
|
+
win.occlusionState() & NSWindowOcclusionStateVisible
|
|
2175
|
+
)
|
|
2176
|
+
f = win.frame()
|
|
2177
|
+
intersects = False
|
|
2178
|
+
for s in NSScreen.screens():
|
|
2179
|
+
sf = s.frame()
|
|
2180
|
+
if (
|
|
2181
|
+
f.origin.x < sf.origin.x + sf.size.width
|
|
2182
|
+
and f.origin.x + f.size.width > sf.origin.x
|
|
2183
|
+
and f.origin.y < sf.origin.y + sf.size.height
|
|
2184
|
+
and f.origin.y + f.size.height > sf.origin.y
|
|
2185
|
+
):
|
|
2186
|
+
intersects = True
|
|
2187
|
+
break
|
|
2188
|
+
on_screen = occluded_visible and intersects
|
|
2189
|
+
except Exception:
|
|
2190
|
+
pass
|
|
2191
|
+
return visible_pref, on_screen
|
|
2192
|
+
except Exception:
|
|
2193
|
+
return None, None
|
|
2194
|
+
|
|
2195
|
+
def _icon_watch_tick(self, now):
|
|
2196
|
+
"""60s-throttled visibility probe + menubar-state.json write. See the
|
|
2197
|
+
__init__ comment for why this exists and why it never self-heals."""
|
|
2198
|
+
if now < self._icon_check_due_at:
|
|
2199
|
+
return
|
|
2200
|
+
self._icon_check_due_at = now + 60.0
|
|
2201
|
+
try:
|
|
2202
|
+
visible_pref, on_screen = self._probe_statusitem()
|
|
2203
|
+
if on_screen is False:
|
|
2204
|
+
if self._icon_hidden_since is None:
|
|
2205
|
+
self._icon_hidden_since = now
|
|
2206
|
+
else:
|
|
2207
|
+
self._icon_hidden_since = None
|
|
2208
|
+
hidden_for = (
|
|
2209
|
+
int(now - self._icon_hidden_since)
|
|
2210
|
+
if self._icon_hidden_since is not None
|
|
2211
|
+
else 0
|
|
2212
|
+
)
|
|
2213
|
+
if on_screen != self._icon_last_on_screen:
|
|
2214
|
+
sys.stderr.write(
|
|
2215
|
+
f"[s4l-menubar] statusitem on_screen={on_screen} "
|
|
2216
|
+
f"visible_pref={visible_pref} hidden_for_sec={hidden_for}\n"
|
|
2217
|
+
)
|
|
2218
|
+
sys.stderr.flush()
|
|
2219
|
+
self._icon_last_on_screen = on_screen
|
|
2220
|
+
dest = os.path.join(st.state_dir(), "menubar-state.json")
|
|
2221
|
+
tmp = dest + ".tmp"
|
|
2222
|
+
with open(tmp, "w") as f:
|
|
2223
|
+
json.dump(
|
|
2224
|
+
{
|
|
2225
|
+
"ts": time.time(),
|
|
2226
|
+
"pid": os.getpid(),
|
|
2227
|
+
"uptime_sec": int(now - self._icon_watch_started),
|
|
2228
|
+
"statusitem_on_screen": on_screen,
|
|
2229
|
+
"statusitem_visible_pref": visible_pref,
|
|
2230
|
+
"hidden_for_sec": hidden_for,
|
|
2231
|
+
},
|
|
2232
|
+
f,
|
|
2233
|
+
)
|
|
2234
|
+
os.replace(tmp, dest)
|
|
2235
|
+
except Exception:
|
|
2236
|
+
pass
|
|
2237
|
+
|
|
2023
2238
|
def _tick(self, _):
|
|
2024
2239
|
# Post-update verdict: cheap (a single stat when no update is pending).
|
|
2025
2240
|
try:
|
|
@@ -2053,6 +2268,8 @@ class S4LMenuBar(rumps.App):
|
|
|
2053
2268
|
pass
|
|
2054
2269
|
else:
|
|
2055
2270
|
self._resumed = False
|
|
2271
|
+
# Status-item visibility telemetry (60s-throttled inside; see __init__).
|
|
2272
|
+
self._icon_watch_tick(now)
|
|
2056
2273
|
# The activity spinner owns the TITLE while a tool runs (we don't fight it at
|
|
2057
2274
|
# 0.12s), but the menu + update indicator must still refresh mid-run —
|
|
2058
2275
|
# otherwise the "Update now & restart Claude Desktop" item never appears on a box that's always
|
|
@@ -2243,6 +2460,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2243
2460
|
attention,
|
|
2244
2461
|
schedule_state,
|
|
2245
2462
|
self._stall_reason_info,
|
|
2463
|
+
os.path.exists(PAUSE_FLAG),
|
|
2246
2464
|
)
|
|
2247
2465
|
if sig != self._sig:
|
|
2248
2466
|
self._sig = sig
|
|
@@ -2618,10 +2836,23 @@ class S4LMenuBar(rumps.App):
|
|
|
2618
2836
|
with self._review_lock:
|
|
2619
2837
|
activity_label = self._posting_activity_label_locked()
|
|
2620
2838
|
cl = [n] if decision.get("drop_link") else None
|
|
2621
|
-
|
|
2839
|
+
# Two-draft cards: a draft_variant means the human picked
|
|
2840
|
+
# (or kept) a specific slot on a dual-draft card, route
|
|
2841
|
+
# through edits=[...] even with no hand-typed change, so the
|
|
2842
|
+
# candidate's style/assigned_style/assigned_mode switch to
|
|
2843
|
+
# match whichever draft is actually posting (mcp/src/index.ts
|
|
2844
|
+
# post_drafts applies both from the same entry). Without
|
|
2845
|
+
# this, a card where the human accepted the recommended
|
|
2846
|
+
# draft as-is (edited=False) would still need its variant
|
|
2847
|
+
# tag carried through for consistency with a later switch.
|
|
2848
|
+
draft_variant = decision.get("draft_variant")
|
|
2849
|
+
if decision.get("edited") or draft_variant:
|
|
2850
|
+
edit = {"n": n, "text": decision.get("text") or ""}
|
|
2851
|
+
if draft_variant:
|
|
2852
|
+
edit["variant"] = draft_variant
|
|
2622
2853
|
res = st.post_drafts(
|
|
2623
2854
|
batch,
|
|
2624
|
-
edits=[
|
|
2855
|
+
edits=[edit],
|
|
2625
2856
|
clear_link=cl,
|
|
2626
2857
|
activity_label=activity_label,
|
|
2627
2858
|
)
|
|
@@ -2689,6 +2920,18 @@ class S4LMenuBar(rumps.App):
|
|
|
2689
2920
|
items.append(header)
|
|
2690
2921
|
items.append(rumps.separator)
|
|
2691
2922
|
|
|
2923
|
+
# Pause: the lighter, fully reversible alternative to Quit — stops
|
|
2924
|
+
# drafting/posting but leaves Claude Desktop, this tray, and the draft
|
|
2925
|
+
# schedule alone. Top of the menu: it's the primary control. See
|
|
2926
|
+
# _pause_toggle.
|
|
2927
|
+
if os.path.exists(PAUSE_FLAG):
|
|
2928
|
+
items.append(self._label("⏸ S4L is paused — drafting & posting stopped"))
|
|
2929
|
+
items.append(rumps.MenuItem("Resume S4L", callback=self._pause_toggle))
|
|
2930
|
+
else:
|
|
2931
|
+
items.append(rumps.MenuItem("Pause S4L", callback=self._pause_toggle))
|
|
2932
|
+
items.append(self._label(" stop drafting/posting, keep Claude + tray running"))
|
|
2933
|
+
items.append(rumps.separator)
|
|
2934
|
+
|
|
2692
2935
|
# Attention = the draft schedule isn't running for THIS account (missing or
|
|
2693
2936
|
# disabled). "Set up draft schedule" fixes it via host create_scheduled_task.
|
|
2694
2937
|
# When the schedule IS firing (ok), attention is False and nothing shows here
|