@m13v/s4l 1.7.6-rc.8 → 1.7.6

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 (85) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +33 -9
  3. package/mcp/dist/index.js +290 -177
  4. package/mcp/dist/panel.html +31 -17
  5. package/mcp/dist/setup.js +27 -7
  6. package/mcp/dist/telemetry.js +15 -6
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +10 -2
  9. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  10. package/mcp/menubar/s4l_card.py +63 -4
  11. package/mcp/menubar/s4l_card_canvas.py +62 -11
  12. package/mcp/menubar/s4l_menubar.py +380 -95
  13. package/mcp/menubar/s4l_state.py +119 -41
  14. package/mcp/package.json +1 -1
  15. package/package.json +1 -1
  16. package/scripts/account_resolver.py +58 -14
  17. package/scripts/amplitude_24h_signups.py +7 -4
  18. package/scripts/amplitude_signups.py +7 -4
  19. package/scripts/autopilot_stall_watch.py +8 -1
  20. package/scripts/browser_lifecycle.py +90 -0
  21. package/scripts/cdp_ready_check.py +81 -50
  22. package/scripts/claude_job.py +70 -10
  23. package/scripts/dm_conversation.py +15 -12
  24. package/scripts/draft_prompt_core.py +132 -64
  25. package/scripts/engage_github.py +11 -5
  26. package/scripts/engage_reddit.py +12 -5
  27. package/scripts/engagement_signal_feedback.py +1 -4
  28. package/scripts/engagement_styles.py +144 -105
  29. package/scripts/feedback_digest.py +54 -11
  30. package/scripts/find_threads.py +9 -7
  31. package/scripts/github_tools.py +23 -10
  32. package/scripts/learned_preferences.py +112 -78
  33. package/scripts/li_discovery.py +4 -1
  34. package/scripts/link_tail.py +1 -1
  35. package/scripts/live_status.py +151 -0
  36. package/scripts/merge_review_queue.py +15 -2
  37. package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
  38. package/scripts/pick_project.py +7 -4
  39. package/scripts/pick_thread_target.py +64 -13
  40. package/scripts/pick_twitter_thread_target.py +7 -4
  41. package/scripts/platform_strike_events.py +139 -7
  42. package/scripts/post_github.py +376 -35
  43. package/scripts/post_reddit.py +202 -192
  44. package/scripts/preflight.sh +23 -3
  45. package/scripts/preview_reddit_card.py +38 -0
  46. package/scripts/project_stats.py +7 -4
  47. package/scripts/reddit_ban_check.py +324 -0
  48. package/scripts/reddit_browser.py +56 -19
  49. package/scripts/reddit_chat_sync.py +10 -11
  50. package/scripts/reddit_tools.py +20 -4
  51. package/scripts/release-mcpb.sh +47 -7
  52. package/scripts/run_moltbook_cycle.py +9 -5
  53. package/scripts/s4l_ctl.sh +2 -2
  54. package/scripts/s4l_posting_mode.py +67 -6
  55. package/scripts/scan_dm_candidates.py +15 -9
  56. package/scripts/scan_github_replies.py +9 -7
  57. package/scripts/scan_moltbook_replies.py +7 -6
  58. package/scripts/scan_reddit_replies.py +11 -7
  59. package/scripts/scan_twitter_mentions_browser.py +7 -6
  60. package/scripts/scan_twitter_thread_followups.py +7 -6
  61. package/scripts/schedule_state.py +8 -4
  62. package/scripts/score_twitter_candidates.py +8 -3
  63. package/scripts/setup_twitter_auth.py +20 -96
  64. package/scripts/snapshot.py +19 -0
  65. package/scripts/stats.py +12 -10
  66. package/scripts/strike_alert.py +165 -12
  67. package/scripts/sub_moderation_history.py +129 -0
  68. package/scripts/subreddit_bans_client.py +157 -0
  69. package/scripts/test_cross_language_parity.py +185 -0
  70. package/scripts/test_no_silent_fallbacks.py +84 -0
  71. package/scripts/test_percard_posting.py +3 -3
  72. package/scripts/test_stall_watch_batch_progression.py +99 -0
  73. package/scripts/top_performers.py +24 -0
  74. package/scripts/twitter_browser.py +22 -52
  75. package/scripts/twitter_gen_links.py +3 -3
  76. package/scripts/twitter_post_plan.py +4 -4
  77. package/scripts/virality.py +151 -0
  78. package/setup/SKILL.md +1 -1
  79. package/skill/engage-dm-replies.sh +11 -0
  80. package/skill/lib/reddit-backend.sh +1 -0
  81. package/skill/run-draft-and-publish.sh +16 -0
  82. package/skill/run-reddit-search-launchd.sh +1 -0
  83. package/skill/run-reddit-search.sh +10 -1
  84. package/skill/run-reddit-threads.sh +12 -0
  85. package/skill/run-twitter-cycle.sh +104 -88
@@ -291,6 +291,12 @@ STOP_FLAG = os.path.join(st.state_dir(), "stopped.flag")
291
291
  # pauseFlagPath() in mcp/src/index.ts.
292
292
  PAUSE_FLAG = os.path.join(st.state_dir(), "paused.flag")
293
293
 
294
+ # The launchd kicker label for the drafting pipeline (the twitter cycle: scan,
295
+ # then draft). "Run drafting now" kickstarts it directly — Claude-independent, so
296
+ # it works even when the MCP server / Desktop is closed. Keep in sync with
297
+ # TWITTER_AUTOPILOT_LABEL in mcp/src/index.ts.
298
+ TWITTER_CYCLE_LABEL = "com.m13v.social-twitter-cycle"
299
+
294
300
  # Autopilot scheduled tasks. Queue workers must RUN in a dedicated folder
295
301
  # (~/.s4l-worker) so their once-a-minute sessions don't flood the user's
296
302
  # interactive Claude Code history (Claude buckets sessions by cwd). s4l-worker
@@ -426,6 +432,43 @@ except Exception:
426
432
  AUTOPILOT_RUNNING_STALL_SECONDS = 1200
427
433
  DRAFT_STUCK_SECONDS = 1200
428
434
 
435
+ # Scheduler-wedge escalation (2026-07-17): tick-freshness 'stalled' stays a quiet
436
+ # diagnostic while drafts drain, but escalates to the ⚠ once the wedge is proven
437
+ # real by THREE independent signals, none of which needs a job to already be
438
+ # stuck (the wedge must be caught even on an idle queue, before any job is
439
+ # harmed):
440
+ # 1. PERSISTENT: no successful worker run for SCHED_WEDGE_ESCALATION_SECONDS.
441
+ # The per-minute task fires regardless of whether jobs exist, so a healthy
442
+ # scheduler never lets this age past ~1-2 min — demand-independent.
443
+ # 2. ACTIVE: at least SCHED_WEDGE_MIN_SKIPS recordedSkips in the last hour —
444
+ # proof Claude Desktop is awake and refusing to fire. A closed lid or a
445
+ # sleeping box records no skips, so sleep can never trip it.
446
+ # 3. NOT A LEGIT LONG RUN: no fresh claimed job in the queue's running/ dir.
447
+ # A real 20-40 min draft run holds the overlap guard closed and racks up
448
+ # per_task_limit skips exactly like the wedge does — but it leaves its
449
+ # claimed job file in running/. Exempting that case is what lets the
450
+ # window sit at 30 min instead of 1h without false positives. (A STALE
451
+ # running/ file past RUNNING_STALL_SECONDS is its own ⚠ via
452
+ # _autopilot_stalled, which takes priority anyway.)
453
+ # Self-resets: the first successful run drops last_run_age below the bar within
454
+ # one tick_stats refresh (≤60s). The 2026-07-09 flapping regime (83% skips with
455
+ # runs still landing every few minutes) stays quiet forever: signal 1 never
456
+ # accumulates.
457
+ #
458
+ # Thresholds are BACKTESTED, not guessed (2026-07-17, 12.2 days of operator-box
459
+ # history: 9,576 worker sessions + 27,192 recordedSkips replayed per-minute).
460
+ # Real no-fire outages split cleanly into two bands: transient ones (36-41 min,
461
+ # self-recovered before a human could act) and true wedges (62 min to 6.4 h).
462
+ # 45 min sits in the empty gap between the bands: the backtest alarms on all
463
+ # four true wedges, zero transient blips, zero alarms across every healthy
464
+ # hour (worst healthy fire-gap observed: 17 min), and both machine-asleep
465
+ # multi-hour gaps stayed quiet on the zero-skips guard. At 30 min the two
466
+ # transient blips produced 5-6 min nuisance alarms. Retune only with a fresh
467
+ # replay (scripts in the 2026-07-17 session; data = worker-transcript mtimes
468
+ # + registry recordedSkips).
469
+ SCHED_WEDGE_ESCALATION_SECONDS = 2700
470
+ SCHED_WEDGE_MIN_SKIPS = 20
471
+
429
472
  # A poll can read a transient "not stuck" moment (e.g. the activity file
430
473
  # between one dying worker attempt and the next claim on the same aged job)
431
474
  # even though the underlying episode never really cleared. Without this, each
@@ -537,7 +580,7 @@ class S4LMenuBar(rumps.App):
537
580
  self._review_unattended_notified = False
538
581
  self._review_unattended_captured = False
539
582
  # This session's card decisions, for store reconciliation: the MCP
540
- # server's post_drafts rewrites the whole review store from a copy read
583
+ # server's approve_drafts rewrites the whole review store from a copy read
541
584
  # before a minutes-long posting run, so a decision stamped mid-drain can
542
585
  # be clobbered; the tick re-stamps anything missing (_reconcile_store).
543
586
  self._session_decisions = []
@@ -776,6 +819,51 @@ class S4LMenuBar(rumps.App):
776
819
  "that schedules the draft tasks for this account",
777
820
  )
778
821
 
822
+ def _restart_claude_for_wedge(self, _=None):
823
+ """One-click fix for the scheduler_wedged ⚠: fully quit and reopen
824
+ Claude Desktop. That clears the warm-session pileup behind the
825
+ scheduler's overlap guard (the per_task_limit skip ledger), which is
826
+ the known root cause — the task registration itself is fine, so no
827
+ scheduled-task changes here, unlike _rearm/_finish_schedule_setup.
828
+ Confirmed with a modal first (the restart closes the user's Claude
829
+ window), captured so fleet forensics can tie a recovery to the click."""
830
+ _activate_front()
831
+ choice = _show_alert(
832
+ title="Restart Claude Desktop?",
833
+ message=(
834
+ "Claude Desktop has stopped running the scheduled draft tasks "
835
+ "(a known Claude bug where finished sessions pile up and block "
836
+ "the scheduler). Restarting Claude clears it.\n\n"
837
+ "Claude's window will close and reopen in a moment; any chat "
838
+ "in progress will be interrupted."
839
+ ),
840
+ ok="Quit & restart Claude", cancel="Cancel",
841
+ )
842
+ if choice != 1: # only default button (OK) proceeds
843
+ return
844
+ _capture_msg(
845
+ "S4L scheduler-wedge restart clicked",
846
+ phase="scheduler_wedge",
847
+ surface="menubar",
848
+ _extra={
849
+ "detail": (self._stall_reason_info or ("", ""))[1],
850
+ "registry": _registry_summary_for_capture(),
851
+ },
852
+ )
853
+ self._notify("S4L", "Restarting Claude to un-wedge the draft scheduler…")
854
+
855
+ def work():
856
+ # Capture custom --user-data-dir profiles while Claude is still
857
+ # alive (unreadable after the kill), same as the update flow.
858
+ try:
859
+ user_data_dirs = self._claude_user_data_dirs()
860
+ except Exception:
861
+ user_data_dirs = None
862
+ self._quit_claude_and_wait()
863
+ self._relaunch_claude(user_data_dirs)
864
+
865
+ threading.Thread(target=work, daemon=True).start()
866
+
779
867
  def _finish_schedule_setup(self, _=None):
780
868
  """One-click fix for schedule_state == 'missing' when
781
869
  scheduled_task_selfheal.can_create_for_active_account() confirms an
@@ -882,8 +970,10 @@ class S4LMenuBar(rumps.App):
882
970
  queue has no pending job, running/ is empty, and the drain latch zeroes
883
971
  on every successful consume (claude_job._mark_drain_success), so none of
884
972
  the three signals can hold a stale True after recovery. Tick-freshness
885
- 'stalled' from schedule_state no longer flips the ⚠ at all — it renders
886
- as a plain diagnostic line (see _build_menu).
973
+ 'stalled' from schedule_state no longer flips the ⚠ at onset — it
974
+ renders as a plain diagnostic line (see _build_menu) and only escalates
975
+ once the wedge is persistent AND active (see the SCHED_WEDGE_* block
976
+ in _tick).
887
977
  """
888
978
  qroot = os.path.join(st.state_dir(), "claude-queue")
889
979
  # (1) latched producer drain-status
@@ -1158,7 +1248,6 @@ class S4LMenuBar(rumps.App):
1158
1248
  if st.read_review_layout() == layout:
1159
1249
  return
1160
1250
  st.write_review_layout(layout)
1161
- swapped = False
1162
1251
  if self._panel_open:
1163
1252
  try:
1164
1253
  # _review_mod() is still pinned to the module that owns the
@@ -1176,23 +1265,26 @@ class S4LMenuBar(rumps.App):
1176
1265
  if self._posts_outstanding <= 0:
1177
1266
  self._review_active = False
1178
1267
  self._reset_posting_progress_locked()
1179
- # Drop the dedup signature so the same pending set presents
1180
- # fresh on the new surface instead of being suppressed as
1181
- # "already shown".
1182
- self._last_review_sig = None
1183
- swapped = True
1184
- if swapped:
1185
- # The user was mid-review and explicitly asked for the other
1186
- # surface: reopen right away (focus=True also bypasses the
1187
- # reveal-cadence hold, same as "Review N pending drafts"). If
1188
- # posting is still draining, _maybe_start_review defers and the
1189
- # backlog presents on the new surface once the drain completes.
1190
- try:
1191
- self._set_snooze(0.0)
1192
- self._maybe_start_review(focus=True)
1193
- except Exception as e:
1194
- sys.stderr.write(f"[s4l-menubar] view mode swap reopen failed: {e}\n")
1195
- sys.stderr.flush()
1268
+ # Changing the View Mode is a deliberate ask to see the pending drafts in
1269
+ # the newly chosen surface, whether or not a panel was open at the time.
1270
+ # When one was open we just dismissed it above; when none was (the user
1271
+ # had closed/snoozed the stack, THEN switched mode) the old behavior left
1272
+ # nothing on screen and the new surface never appeared until a separate
1273
+ # "Review N pending drafts" click -- the 2026-07-17 Nhat report where a
1274
+ # flip to Canvas seemed to do nothing. Present in the new surface either
1275
+ # way, same posture as that menu click: focus=True clears the snooze and
1276
+ # bypasses the reveal-cadence hold. Drop the dedup signature first so the
1277
+ # same pending set presents fresh on the new surface. With no pending
1278
+ # drafts _maybe_start_review no-ops, so setting the mode ahead of time
1279
+ # still doesn't pop anything; if posting is draining it defers and the
1280
+ # backlog presents on the new surface once the drain completes.
1281
+ self._last_review_sig = None
1282
+ try:
1283
+ self._set_snooze(0.0)
1284
+ self._maybe_start_review(focus=True)
1285
+ except Exception as e:
1286
+ sys.stderr.write(f"[s4l-menubar] view mode present failed: {e}\n")
1287
+ sys.stderr.flush()
1196
1288
  self._sig = None
1197
1289
  try:
1198
1290
  self._tick(None)
@@ -1287,7 +1379,9 @@ class S4LMenuBar(rumps.App):
1287
1379
  "S4L posting volume",
1288
1380
  f"Posting volume: {self._posting_mode_label(stored)}. Applies from the next cycle.",
1289
1381
  )
1290
- except Exception as e:
1382
+ except (Exception, SystemExit) as e:
1383
+ # http_api raises SystemExit on terminal network failure; it must
1384
+ # not escape a rumps menu callback.
1291
1385
  sys.stderr.write(f"[s4l-menubar] posting-mode write failed: {e}\n")
1292
1386
  sys.stderr.flush()
1293
1387
  self._notify(
@@ -2530,13 +2624,18 @@ class S4LMenuBar(rumps.App):
2530
2624
  # a draft job sat unclaimed, wedged mid-run, or the producer's drain
2531
2625
  # latch tripped -> ⚠, because THAT is user-visible harm.
2532
2626
  # Tick freshness ('stalled': task present + enabled but lastRunAt stale,
2533
- # the Desktop warm-session wedge) is DIAGNOSTIC ONLY, never the ⚠: under
2534
- # the wedge the per-minute tick can skip 70-90% of fires while the queue
2535
- # still drains every job (jobs arrive ~every 8 min vs 60 fires/hr), so
2536
- # lastRunAt staleness flip-flopped the icon against a healthy pipeline
2537
- # (observed on the operator box 2026-07-09: 83% ticks skipped, ~14
2538
- # posts/hr flowing, icon oscillating). _build_menu renders 'stalled' as
2539
- # a plain "scheduler degraded" detail line with tick_stats() numbers.
2627
+ # the Desktop warm-session wedge) is diagnostic at onset, never an
2628
+ # instant ⚠: under the wedge the per-minute tick can skip 70-90% of
2629
+ # fires while the queue still drains every job (jobs arrive ~every 8
2630
+ # min vs 60 fires/hr), so lastRunAt staleness flip-flopped the icon
2631
+ # against a healthy pipeline (observed on the operator box 2026-07-09:
2632
+ # 83% ticks skipped, ~14 posts/hr flowing, icon oscillating).
2633
+ # _build_menu renders young/intermittent 'stalled' as a plain detail
2634
+ # line with tick_stats() numbers. It DOES escalate to the ⚠ once the
2635
+ # wedge is proven persistent AND active (2026-07-17, see the
2636
+ # SCHED_WEDGE_* constants + the escalation block below) — that regime
2637
+ # (zero successful fires for an hour while awake) is disjoint from the
2638
+ # flapping one, so the 07-09 fix is preserved.
2540
2639
  # The drain latch IS safe to alarm on now: claude_job._mark_drain_success
2541
2640
  # zeroes it on every successful consume, so it self-clears (the old
2542
2641
  # stayed-stale-after-recovery latch this comment used to warn about
@@ -2634,6 +2733,56 @@ class S4LMenuBar(rumps.App):
2634
2733
  self._tick_stats = None
2635
2734
  else:
2636
2735
  self._tick_stats = None
2736
+ # Scheduler-wedge escalation (2026-07-17): 'stalled' is quiet while the
2737
+ # wedge is young or intermittent, but once all three wedge signals hold
2738
+ # (persistent + awake + no legit run in flight — see the SCHED_WEDGE_*
2739
+ # constants' comment), the degradation is real -> ⚠ with its own reason
2740
+ # and a one-click, modal-confirmed Claude Desktop restart (the known
2741
+ # cure for the warm-session overlap-guard wedge; the registration
2742
+ # itself is fine, so re-arm is the wrong fix). Deliberately does NOT
2743
+ # wait for a job to be stuck: an idle queue with a wedged scheduler
2744
+ # must alarm too. Every more specific reason keeps priority;
2745
+ # self-clears as soon as a run lands (tick_stats refresh ≤60s behind).
2746
+ if setup_complete and schedule_state == "stalled" and not attention:
2747
+ _ws = self._tick_stats or {}
2748
+ _wage = _ws.get("last_run_age_s")
2749
+ _wskips = _ws.get("skips_in_window") or 0
2750
+ # Signal 3: a FRESH claimed job in running/ means a real (long)
2751
+ # draft run legitimately holds the overlap guard closed — not a
2752
+ # wedge. Stale running/ files past the running-stall bar are the
2753
+ # queue layer's ⚠, which already took priority above.
2754
+ _live_drain = False
2755
+ try:
2756
+ _wq = os.path.join(st.state_dir(), "claude-queue", "running")
2757
+ for _jf in glob.glob(os.path.join(_wq, "*.json")):
2758
+ if _jf.endswith(".tmp"):
2759
+ continue
2760
+ try:
2761
+ if (time.time() - os.path.getmtime(_jf)) < AUTOPILOT_RUNNING_STALL_SECONDS:
2762
+ _live_drain = True
2763
+ break
2764
+ except OSError:
2765
+ continue
2766
+ except Exception:
2767
+ pass
2768
+ if (
2769
+ not _live_drain
2770
+ and _wage is not None
2771
+ and _wage >= SCHED_WEDGE_ESCALATION_SECONDS
2772
+ and _wskips >= SCHED_WEDGE_MIN_SKIPS
2773
+ ):
2774
+ attention = True
2775
+ self._stall_reason_info = (
2776
+ "scheduler_wedged",
2777
+ f"no run in {int(_wage) // 60}m, "
2778
+ f"{_wskips} of ~60 ticks skipped last hour",
2779
+ )
2780
+ elif self._stall_reason_info[0] == "scheduler_wedged":
2781
+ # Recovered (a run landed) — clear immediately instead of
2782
+ # waiting for the 30s rate-limit rescan to overwrite it.
2783
+ self._stall_reason_info = ("", "")
2784
+ elif self._stall_reason_info[0] == "scheduler_wedged":
2785
+ self._stall_reason_info = ("", "")
2637
2786
  # Drop the stale "drafting" spinner while we need attention so the ⚠ shows.
2638
2787
  self._stalled = attention
2639
2788
 
@@ -2764,6 +2913,19 @@ class S4LMenuBar(rumps.App):
2764
2913
  self._channel = snap.get("channel") or "stable"
2765
2914
  self._latest_tag = snap.get("latest_tag")
2766
2915
 
2916
+ # Live drafting-pipeline status + next-run countdown — ONE shared module
2917
+ # (scripts/live_status.py) the dashboard widget reads via snapshot.py too,
2918
+ # so the tray and the widget can't drift. Cheap (two small file reads),
2919
+ # computed fresh here rather than off the 4s-cached snap so the countdown
2920
+ # stays honest. Best-effort: any failure leaves an empty dict and the menu
2921
+ # simply omits the drafting status/countdown lines.
2922
+ try:
2923
+ import live_status as _live_status
2924
+
2925
+ self._live = _live_status.compute()
2926
+ except Exception:
2927
+ self._live = {}
2928
+
2767
2929
  # Only rebuild the menu when something user-visible changed, so an open
2768
2930
  # menu isn't torn down under the user's cursor every poll.
2769
2931
  done = (
@@ -2801,20 +2963,22 @@ class S4LMenuBar(rumps.App):
2801
2963
  else None,
2802
2964
  os.path.exists(PAUSE_FLAG),
2803
2965
  pending_count,
2804
- # Snooze end (0 = not snoozed): rebuilds the menu when a snooze is
2805
- # set, cleared, or lapses, so the "Snoozed until HH:MM" label and
2806
- # the review item stay honest.
2807
- int(self._review_snooze_until)
2808
- if time.time() < self._review_snooze_until
2809
- else 0,
2810
2966
  # Reveal cadence: rebuilds when the preset changes (checkmark +
2811
- # submenu title) and when a hold starts or lapses (the "Next cards
2812
- # around HH:MM" line in _state_c).
2967
+ # submenu title). The snooze-end and reveal-hold rebuild triggers
2968
+ # were dropped with the _state_c courtesy sublines they drove
2969
+ # (2026-07-17) — nothing visible depends on them anymore.
2813
2970
  round(st.read_reveal_cadence()),
2814
- int(self._reveal_hold_until(pending_count)),
2815
2971
  # View mode: rebuilds when the review layout changes (checkmark
2816
2972
  # in the "View Mode" submenu + whether the cadence item shows).
2817
2973
  st.read_review_layout(),
2974
+ # Drafting status + countdown (State C): rebuild when the live
2975
+ # pipeline verb changes (idle <-> scanning/drafting/posting) and
2976
+ # when the next-run countdown crosses a 15s bucket — bucketed so an
2977
+ # open menu isn't torn down every second for a -1s tick.
2978
+ self._live.get("activity_state", "idle"),
2979
+ (self._live.get("next_run_secs") // 15)
2980
+ if isinstance(self._live.get("next_run_secs"), int)
2981
+ else None,
2818
2982
  )
2819
2983
  if sig != self._sig:
2820
2984
  self._sig = sig
@@ -2850,7 +3014,7 @@ class S4LMenuBar(rumps.App):
2850
3014
  def _posting_activity_label_locked(self):
2851
3015
  """Progress for the current menu-bar approval burst.
2852
3016
 
2853
- The server receives one post_drafts call per approved card, so its native
3017
+ The server receives one approve_drafts call per approved card, so its native
2854
3018
  view is always 1/1. The menu bar owns the burst queue and can show the
2855
3019
  useful progress: current approved post / total approved so far.
2856
3020
  """
@@ -3327,7 +3491,7 @@ class S4LMenuBar(rumps.App):
3327
3491
 
3328
3492
  def _persist_reject():
3329
3493
  try:
3330
- st.post_drafts(batch, reject=[n], timeout=30)
3494
+ st.approve_drafts(batch, reject=[n], timeout=30)
3331
3495
  except Exception:
3332
3496
  pass
3333
3497
 
@@ -3447,7 +3611,7 @@ class S4LMenuBar(rumps.App):
3447
3611
 
3448
3612
  def _persist_discard():
3449
3613
  try:
3450
- st.post_drafts(batch, reject=ns, timeout=60)
3614
+ st.approve_drafts(batch, reject=ns, timeout=60)
3451
3615
  except Exception:
3452
3616
  pass
3453
3617
  try:
@@ -3582,7 +3746,7 @@ class S4LMenuBar(rumps.App):
3582
3746
  def _post_worker_loop(self):
3583
3747
  # Serialized poster: one approved card at a time so two posts never drive
3584
3748
  # the shared harness Chrome simultaneously. The menu bar passes a burst
3585
- # progress label into post_drafts, so the spinner shows e.g. "posting 17/95"
3749
+ # progress label into approve_drafts, so the spinner shows e.g. "posting 17/95"
3586
3750
  # even though each server call is still one approved draft.
3587
3751
  while True:
3588
3752
  batch, decision = self._post_q.get() # blocks until a card is approved
@@ -3599,7 +3763,7 @@ class S4LMenuBar(rumps.App):
3599
3763
  # through edits=[...] even with no hand-typed change, so the
3600
3764
  # candidate's style/assigned_style/assigned_mode switch to
3601
3765
  # match whichever draft is actually posting (mcp/src/index.ts
3602
- # post_drafts applies both from the same entry). Without
3766
+ # approve_drafts applies both from the same entry). Without
3603
3767
  # this, a card where the human accepted the recommended
3604
3768
  # draft as-is (edited=False) would still need its variant
3605
3769
  # tag carried through for consistency with a later switch.
@@ -3608,19 +3772,19 @@ class S4LMenuBar(rumps.App):
3608
3772
  edit = {"n": n, "text": decision.get("text") or ""}
3609
3773
  if draft_variant:
3610
3774
  edit["variant"] = draft_variant
3611
- res = st.post_drafts(
3775
+ res = st.approve_drafts(
3612
3776
  batch,
3613
3777
  edits=[edit],
3614
3778
  clear_link=cl,
3615
3779
  activity_label=activity_label,
3616
3780
  )
3617
3781
  else:
3618
- res = st.post_drafts(batch, post=[n], clear_link=cl, activity_label=activity_label)
3782
+ res = st.approve_drafts(batch, post=[n], clear_link=cl, activity_label=activity_label)
3619
3783
  if res is None:
3620
3784
  # Loopback unreachable (Claude closed, mid-restart, or an
3621
3785
  # account switch tore down the old MCP server before a new
3622
3786
  # one registered). Unlike a real posting failure, nothing
3623
- # was ever attempted here — post_drafts never reached a
3787
+ # was ever attempted here — approve_drafts never reached a
3624
3788
  # server — so there's no double-post risk in retrying.
3625
3789
  # Deliberately do NOT call store_mark_post_failed: it would
3626
3790
  # exclude this card from store_pending_posts(), which is
@@ -3707,20 +3871,79 @@ class S4LMenuBar(rumps.App):
3707
3871
  pass
3708
3872
 
3709
3873
  def _render_title(self, setup_complete, ob, blocker, attention=False, pending_count=0):
3710
- if blocker or attention:
3711
- base = "S4L ⚠" # warning (setup blocked OR autopilot needs attention)
3712
- elif not setup_complete and ob and not ob.get("complete"):
3874
+ # Base label: onboarding progress ("S4L 2/5") while setup is still
3875
+ # running, plain "S4L" once complete.
3876
+ if not setup_complete and ob and not ob.get("complete"):
3713
3877
  done = sum(1 for m in ob["milestones"] if m.get("status") == "complete")
3714
3878
  base = f"S4L {done}/{len(ob['milestones'])}"
3715
- elif self._update_available:
3716
- base = "S4L ⬆" # update available — open the menu to update
3717
3879
  else:
3718
3880
  base = "S4L"
3881
+ # Status markers are INDEPENDENT — each appends its own glyph when active
3882
+ # so a warning and an available update (and anything added later) surface
3883
+ # at the same time (user request 2026-07-17). They used to be mutually
3884
+ # exclusive if/elif branches, where a warning hid a pending update and an
3885
+ # update hid the onboarding count. Order is fixed (warning before update)
3886
+ # so the bar doesn't reshuffle its glyphs as states toggle.
3887
+ if blocker or attention:
3888
+ base += " ⚠" # warning (setup blocked OR autopilot needs attention)
3889
+ if self._update_available:
3890
+ base += " ⬆" # update available — open the menu to update
3719
3891
  # Concise inline backlog count, right on the bar (not just the dropdown's
3720
3892
  # "Review N pending drafts") — per user request 2026-07-15, most useful
3721
3893
  # when the reveal cadence is "Never" and this count is the only signal
3722
3894
  # that drafts are waiting.
3895
+ # Setting self.title first lays down the PLAIN title (rumps clears any
3896
+ # prior attributed title via setTitle_), so a backlog that drops back
3897
+ # under the threshold automatically loses its highlight without extra
3898
+ # bookkeeping. _highlight_title_count then re-decorates just the number
3899
+ # when the backlog is large enough to warrant a colored pill.
3723
3900
  self.title = f"{base} · {pending_count}" if pending_count > 0 else base
3901
+ if pending_count > 5:
3902
+ self._highlight_title_count(base, pending_count)
3903
+
3904
+ def _highlight_title_count(self, base, pending_count):
3905
+ """Draw a colored background pill behind the pending-draft NUMBER in the
3906
+ menu-bar title once the backlog gets large: yellow for >5 (6-10), red
3907
+ for >10 (11+). Per user request 2026-07-17 — a plain count is easy to
3908
+ miss, a colored one pulls the eye when drafts are piling up. Applied as
3909
+ an NSAttributedString on the status item's button; on any failure we
3910
+ leave the plain title (already set by the caller) untouched."""
3911
+ try:
3912
+ from AppKit import (
3913
+ NSColor,
3914
+ NSMutableAttributedString,
3915
+ NSForegroundColorAttributeName,
3916
+ NSBackgroundColorAttributeName,
3917
+ )
3918
+ from Foundation import NSMakeRange
3919
+
3920
+ item = getattr(self._nsapp, "nsstatusitem", None)
3921
+ if item is None:
3922
+ return
3923
+ button = item.button() if hasattr(item, "button") else None
3924
+ if button is None:
3925
+ return
3926
+
3927
+ if pending_count > 10:
3928
+ bg = NSColor.systemRedColor()
3929
+ fg = NSColor.whiteColor()
3930
+ else:
3931
+ bg = NSColor.systemYellowColor()
3932
+ fg = NSColor.blackColor()
3933
+
3934
+ num = str(pending_count)
3935
+ # Pad the number with hair spaces so the colored pill has a little
3936
+ # breathing room around the digits instead of hugging them tight.
3937
+ pill = f" {num} "
3938
+ full = f"{base} · {pill}"
3939
+ attr = NSMutableAttributedString.alloc().initWithString_(full)
3940
+ start = full.rfind(pill)
3941
+ rng = NSMakeRange(start, len(pill))
3942
+ attr.addAttribute_value_range_(NSBackgroundColorAttributeName, bg, rng)
3943
+ attr.addAttribute_value_range_(NSForegroundColorAttributeName, fg, rng)
3944
+ button.setAttributedTitle_(attr)
3945
+ except Exception:
3946
+ pass
3724
3947
 
3725
3948
  # ---- menu construction ------------------------------------------------
3726
3949
  def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok", pending_count=0):
@@ -3795,6 +4018,20 @@ class S4LMenuBar(rumps.App):
3795
4018
  # die before draining. Re-arm can't fix that; Diagnose can.
3796
4019
  items.append(self._label("⚠ Draft jobs stuck, runs start but don't finish"))
3797
4020
  items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
4021
+ elif self._stall_reason_info[0] == "scheduler_wedged":
4022
+ # Persistent Desktop scheduler wedge: the task is registered +
4023
+ # enabled but the host has refused every per-minute fire for an
4024
+ # hour+ (warm-session overlap guard, recordedSkips
4025
+ # per_task_limit). The registration is fine, so re-arm is the
4026
+ # wrong fix; the one mechanical cure is a full Claude Desktop
4027
+ # restart, which clears the warm-session pileup. Label is
4028
+ # honest about the disruption before the click (house rule).
4029
+ items.append(self._label("⚠ Claude has stopped running draft tasks"))
4030
+ items.append(self._label(" " + (self._stall_reason_info[1] or "scheduler wedged")))
4031
+ items.append(rumps.MenuItem(
4032
+ "Fix by restarting Claude Desktop",
4033
+ callback=self._restart_claude_for_wedge,
4034
+ ))
3798
4035
  elif schedule_state == "disabled":
3799
4036
  items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
3800
4037
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
@@ -3830,10 +4067,12 @@ class S4LMenuBar(rumps.App):
3830
4067
  # NON-ALARM diagnostic (2026-07-09): the task is registered + enabled
3831
4068
  # for the active account but the host's per-minute tick is stale or
3832
4069
  # mostly skipped (Desktop warm-session wedge). The queue checks above
3833
- # would have flipped ⚠ if jobs were actually stuck, so reaching here
3834
- # means drafts still drain; say so with numbers instead of alarming.
3835
- # tick_stats is cached by _tick (refreshed ≤ once/min, None outside
3836
- # 'stalled').
4070
+ # would have flipped ⚠ if jobs were actually stuck, and _tick's
4071
+ # scheduler-wedge escalation owns the persistent-wedge ⚠, so reaching
4072
+ # here means drafts still drain; the label says exactly that (the old
4073
+ # "Scheduler degraded" wording read as broken while everything worked,
4074
+ # reworded 2026-07-17). tick_stats is cached by _tick (refreshed ≤
4075
+ # once/min, None outside 'stalled').
3837
4076
  _ts = self._tick_stats or {}
3838
4077
  _skips = _ts.get("skips_in_window")
3839
4078
  _age = _ts.get("last_run_age_s")
@@ -3842,7 +4081,7 @@ class S4LMenuBar(rumps.App):
3842
4081
  _bits.append(f"{_skips} of ~60 ticks skipped last hour")
3843
4082
  if _age is not None:
3844
4083
  _bits.append(f"last run {max(0, int(_age)) // 60}m ago")
3845
- items.append(self._label("Scheduler degraded (drafts still running)"))
4084
+ items.append(self._label("Scheduler skipping runs (drafts unaffected)"))
3846
4085
  if _bits:
3847
4086
  items.append(self._label(" " + ", ".join(_bits)))
3848
4087
  # Keep the one-click remedy reachable without dressing it as urgent:
@@ -3856,6 +4095,38 @@ class S4LMenuBar(rumps.App):
3856
4095
  items += self._state_b(ob, blocker)
3857
4096
  else:
3858
4097
  items += self._state_c(snap, pending_count)
4098
+ # Drafting-pipeline control (post-setup, every State-C menu regardless
4099
+ # of pending drafts): ONE line — the action is the phrase, the live
4100
+ # state is in brackets. The pipeline scans first, then hands off to the
4101
+ # drafter; this mirrors the dashboard widget, both reading
4102
+ # scripts/live_status.py so the surfaces can't drift.
4103
+ # idle -> "Run drafting now (next run in 42s)" [clickable]
4104
+ # running -> "Run drafting now (drafting…)" [greyed]
4105
+ # paused -> "Run drafting now (paused)" [greyed]
4106
+ # Clickable only when idle & not paused: while a run is in flight
4107
+ # launchd would just no-op the single instance, and while paused the
4108
+ # kicker is unloaded so nothing would run.
4109
+ live = getattr(self, "_live", {}) or {}
4110
+ act_state = live.get("activity_state", "idle")
4111
+ running = act_state in ("scanning", "drafting", "posting")
4112
+ paused = os.path.exists(PAUSE_FLAG)
4113
+ if paused:
4114
+ _bracket = "paused"
4115
+ elif running:
4116
+ _bracket = f"{act_state}…"
4117
+ else:
4118
+ _secs = live.get("next_run_secs")
4119
+ _bracket = (
4120
+ f"next run in {self._fmt_countdown(_secs)}"
4121
+ if isinstance(_secs, int)
4122
+ else "next run within a minute"
4123
+ )
4124
+ _text = f"Run drafting now ({_bracket})"
4125
+ items.append(rumps.separator)
4126
+ if paused or running:
4127
+ items.append(self._label(_text)) # greyed: can't run right now
4128
+ else:
4129
+ items.append(rumps.MenuItem(_text, callback=self._run_drafting))
3859
4130
 
3860
4131
  # Engagement lanes — ALWAYS visible (every state), not just post-setup, so
3861
4132
  # the user can see + flip either lane any time. Two INDEPENDENT checkmarks
@@ -3917,9 +4188,10 @@ class S4LMenuBar(rumps.App):
3917
4188
  items.append(cadence_menu)
3918
4189
 
3919
4190
  # Posting volume: server-side per-install throttle (virality bar).
3920
- # read_posting_mode() is cached and non-blocking; unknown/unset shows
3921
- # as Steady (the server resolves unset the same way), corrected on the
3922
- # rebuild after the first refresh lands.
4191
+ # read_posting_mode() is cached and non-blocking; the cache is seeded
4192
+ # from the on-disk last-known copy at boot, so a restart keeps showing
4193
+ # the user's chosen mode. Genuinely unset shows as Steady (the server
4194
+ # resolves unset the same way), corrected after the first refresh.
3923
4195
  pv_mode = st.read_posting_mode() or "medium"
3924
4196
  pv_menu = rumps.MenuItem(
3925
4197
  f"Posting volume: {self._posting_mode_label(pv_mode).split(' (')[0].lower()}"
@@ -3995,6 +4267,45 @@ class S4LMenuBar(rumps.App):
3995
4267
  item.set_callback(None)
3996
4268
  return item
3997
4269
 
4270
+ @staticmethod
4271
+ def _fmt_countdown(secs):
4272
+ """Compact 'Ns' / 'm:ss' for the next-scan countdown line."""
4273
+ try:
4274
+ s = max(0, int(secs))
4275
+ except Exception:
4276
+ return "under a minute"
4277
+ if s < 60:
4278
+ return f"{s}s"
4279
+ return f"{s // 60}:{s % 60:02d}"
4280
+
4281
+ def _run_drafting(self, _=None):
4282
+ """Run the drafting pipeline ONE time immediately by kickstarting the
4283
+ launchd kicker (com.m13v.social-twitter-cycle). Same mechanism the MCP's
4284
+ run_drafting tool and the first-run onboarding kick use — the job runs
4285
+ THROUGH launchd with its full baked env + singleton lock, so it is NOT a
4286
+ bare hand-run of the cycle. Done directly here (not via the loopback tool)
4287
+ so it works even when Claude Desktop / the MCP server is closed.
4288
+ Best-effort + off the UI thread so a slow launchctl never freezes the
4289
+ tray."""
4290
+ def work():
4291
+ try:
4292
+ r = subprocess.run(
4293
+ ["launchctl", "kickstart", f"gui/{os.getuid()}/{TWITTER_CYCLE_LABEL}"],
4294
+ capture_output=True,
4295
+ timeout=15,
4296
+ )
4297
+ if r.returncode == 0:
4298
+ self._notify("S4L", "Drafting started — new drafts land in a few minutes.")
4299
+ else:
4300
+ self._notify("S4L", "Couldn’t start drafting. Try again in a moment.")
4301
+ except Exception:
4302
+ self._notify("S4L", "Couldn’t start drafting. Try again in a moment.")
4303
+ # Repaint promptly so the status line flips to "Scanning now…" without
4304
+ # waiting for the next natural rebuild.
4305
+ self._sig = None
4306
+
4307
+ threading.Thread(target=work, daemon=True).start()
4308
+
3998
4309
  # State A — runtime not installed yet.
3999
4310
  def _state_a(self):
4000
4311
  return [
@@ -4056,47 +4367,21 @@ class S4LMenuBar(rumps.App):
4056
4367
  if pending_count <= 0:
4057
4368
  return []
4058
4369
  plural = "s" if pending_count != 1 else ""
4059
- items = [
4370
+ # Just the clickable review item — no secondary grey subline. The old
4371
+ # snooze / reveal-cadence courtesy lines ("Snoozed until…", "Next cards
4372
+ # around…", "Cards hidden…", "Canvas opens from the review item above")
4373
+ # were removed per user request (2026-07-17): "Review N pending drafts"
4374
+ # opens them right now regardless of the pop-up pacing, so the extra line
4375
+ # only restated the click behavior. The reveal-cadence + snooze controls
4376
+ # still live in _build_menu; this just drops the redundant readout.
4377
+ # No bulk-discard item here either: "Discard all…" lives in the review
4378
+ # card's title bar (2026-07-10).
4379
+ return [
4060
4380
  rumps.MenuItem(
4061
4381
  f"Review {pending_count} pending draft{plural}",
4062
4382
  callback=self._review_now,
4063
4383
  )
4064
4384
  ]
4065
- # The review-surface picker ("View Mode") lives in _build_menu now
4066
- # (2026-07-16): always visible, not just while something is pending.
4067
- if time.time() < self._review_snooze_until:
4068
- items.append(
4069
- self._label(
4070
- "Snoozed until "
4071
- + time.strftime(
4072
- "%H:%M", time.localtime(self._review_snooze_until)
4073
- )
4074
- )
4075
- )
4076
- else:
4077
- # Reveal-cadence hold: the backlog exists but the pop-up is being
4078
- # paced, or suppressed entirely ("Never"). Same courtesy line as
4079
- # the snooze; "Review N pending drafts" above shows them right
4080
- # now regardless.
4081
- hold_until = self._reveal_hold_until(pending_count)
4082
- if hold_until < 0:
4083
- if st.read_review_layout() == "cards":
4084
- items.append(self._label("Cards hidden — see count in the menu bar"))
4085
- else:
4086
- # Canvas mode: it never auto-opens; the review item above
4087
- # is the one way in.
4088
- items.append(self._label("Canvas opens from the review item above"))
4089
- elif hold_until:
4090
- items.append(
4091
- self._label(
4092
- "Next cards around "
4093
- + time.strftime("%H:%M", time.localtime(hold_until))
4094
- )
4095
- )
4096
- # No bulk-discard item here anymore: "Discard all…" lives in the
4097
- # review card's title bar (2026-07-10). Reaching it with no card open
4098
- # is "Review N pending drafts" -> Discard all, two clicks.
4099
- return items
4100
4385
 
4101
4386
 
4102
4387
  if __name__ == "__main__":