@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.
Files changed (45) hide show
  1. package/bin/cli.js +23 -0
  2. package/mcp/dist/index.js +332 -24
  3. package/mcp/dist/onboarding.js +3 -10
  4. package/mcp/dist/panel.html +42 -31
  5. package/mcp/dist/product-link.html +1 -1
  6. package/mcp/dist/runtime.js +101 -1
  7. package/mcp/dist/telemetry.js +80 -3
  8. package/mcp/dist/version.json +2 -2
  9. package/mcp/manifest.json +9 -1
  10. package/mcp/menubar/dashboard_server.py +12 -0
  11. package/mcp/menubar/s4l_card.py +472 -96
  12. package/mcp/menubar/s4l_menubar.py +269 -26
  13. package/mcp/menubar/s4l_state.py +169 -23
  14. package/mcp/package.json +1 -1
  15. package/mcp/shared/doctor.cjs +8 -0
  16. package/mcp/shared/onboarding-ledger.cjs +1 -0
  17. package/package.json +3 -1
  18. package/scripts/active_users.py +3 -6
  19. package/scripts/autopilot_stall_watch.py +200 -16
  20. package/scripts/claude_job.py +6 -0
  21. package/scripts/feedback_digest.py +34 -6
  22. package/scripts/get-latest-staging-mcpb.sh +50 -0
  23. package/scripts/harness_overlay.py +45 -0
  24. package/scripts/identity.py +26 -0
  25. package/scripts/learned_preferences.py +241 -32
  26. package/scripts/link_tail.py +167 -91
  27. package/scripts/mark_event.py +128 -0
  28. package/scripts/memory_snapshot.py +62 -0
  29. package/scripts/pick_twitter_thread_target.py +1 -1
  30. package/scripts/release-mcpb.sh +48 -0
  31. package/scripts/reset-test-machine.sh +85 -3
  32. package/scripts/s4l_mode.py +7 -5
  33. package/scripts/scheduled_tasks_snapshot.py +107 -0
  34. package/scripts/send_gmail_report.py +76 -0
  35. package/scripts/sentry_digest.py +81 -189
  36. package/scripts/sentry_init.py +43 -15
  37. package/scripts/setup_twitter_auth.py +76 -38
  38. package/scripts/snapshot.py +20 -0
  39. package/scripts/twitter_post_plan.py +39 -13
  40. package/skill/dm-outreach-twitter.sh +19 -0
  41. package/skill/engage-twitter.sh +27 -0
  42. package/skill/run-draft-and-publish.sh +13 -12
  43. package/skill/run-linkedin.sh +9 -1
  44. package/skill/run-twitter-cycle.sh +199 -27
  45. 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
- # only to pull the accessory (LSUIElement) app to the front before showing an
136
- # NSAlert: an agent app that isn't the active app has its rumps.alert appear
137
- # BEHIND the frontmost window ("modal doesn't show on top"), because runModal
138
- # doesn't activate the app for us. Guarded so a missing AppKit never breaks the
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 NSAlert (rumps.alert)
148
- opens on top of whatever was frontmost, instead of behind it. Best-effort."""
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.\n"
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
- rumps.alert(title=title, message=msg, ok="OK")
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 = rumps.alert(
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 = rumps.alert(
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
- rumps.alert(title=title, message=message, ok="OK")
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 = rumps.alert(
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 = rumps.alert(
1744
- title="Tidy the S4L background tasks?",
1868
+ choice = _show_alert(
1869
+ title="Tidy S4L background tasks?",
1745
1870
  message=(
1746
- "S4L can tidy its background tasks: merge the old draft + query "
1747
- "tasks into ONE universal worker (s4l-worker) and make sure "
1748
- "their once-a-minute runs stay in a dedicated folder instead of "
1749
- "cluttering your `claude --resume` history.\n\n"
1750
- "Claude Desktop will restart once to apply — its window will "
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
- if decision.get("edited"):
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=[{"n": n, "text": decision.get("text") or ""}],
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