@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.
- package/README.md +1 -1
- package/bin/cli.js +33 -9
- package/mcp/dist/index.js +290 -177
- package/mcp/dist/panel.html +31 -17
- package/mcp/dist/setup.js +27 -7
- package/mcp/dist/telemetry.js +15 -6
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +10 -2
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +63 -4
- package/mcp/menubar/s4l_card_canvas.py +62 -11
- package/mcp/menubar/s4l_menubar.py +380 -95
- package/mcp/menubar/s4l_state.py +119 -41
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +58 -14
- package/scripts/amplitude_24h_signups.py +7 -4
- package/scripts/amplitude_signups.py +7 -4
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/claude_job.py +70 -10
- package/scripts/dm_conversation.py +15 -12
- package/scripts/draft_prompt_core.py +132 -64
- package/scripts/engage_github.py +11 -5
- package/scripts/engage_reddit.py +12 -5
- package/scripts/engagement_signal_feedback.py +1 -4
- package/scripts/engagement_styles.py +144 -105
- package/scripts/feedback_digest.py +54 -11
- package/scripts/find_threads.py +9 -7
- package/scripts/github_tools.py +23 -10
- package/scripts/learned_preferences.py +112 -78
- package/scripts/li_discovery.py +4 -1
- package/scripts/link_tail.py +1 -1
- package/scripts/live_status.py +151 -0
- package/scripts/merge_review_queue.py +15 -2
- package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
- package/scripts/pick_project.py +7 -4
- package/scripts/pick_thread_target.py +64 -13
- package/scripts/pick_twitter_thread_target.py +7 -4
- package/scripts/platform_strike_events.py +139 -7
- package/scripts/post_github.py +376 -35
- package/scripts/post_reddit.py +202 -192
- package/scripts/preflight.sh +23 -3
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/project_stats.py +7 -4
- package/scripts/reddit_ban_check.py +324 -0
- package/scripts/reddit_browser.py +56 -19
- package/scripts/reddit_chat_sync.py +10 -11
- package/scripts/reddit_tools.py +20 -4
- package/scripts/release-mcpb.sh +47 -7
- package/scripts/run_moltbook_cycle.py +9 -5
- package/scripts/s4l_ctl.sh +2 -2
- package/scripts/s4l_posting_mode.py +67 -6
- package/scripts/scan_dm_candidates.py +15 -9
- package/scripts/scan_github_replies.py +9 -7
- package/scripts/scan_moltbook_replies.py +7 -6
- package/scripts/scan_reddit_replies.py +11 -7
- package/scripts/scan_twitter_mentions_browser.py +7 -6
- package/scripts/scan_twitter_thread_followups.py +7 -6
- package/scripts/schedule_state.py +8 -4
- package/scripts/score_twitter_candidates.py +8 -3
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/snapshot.py +19 -0
- package/scripts/stats.py +12 -10
- package/scripts/strike_alert.py +165 -12
- package/scripts/sub_moderation_history.py +129 -0
- package/scripts/subreddit_bans_client.py +157 -0
- package/scripts/test_cross_language_parity.py +185 -0
- package/scripts/test_no_silent_fallbacks.py +84 -0
- package/scripts/test_percard_posting.py +3 -3
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/scripts/twitter_gen_links.py +3 -3
- package/scripts/twitter_post_plan.py +4 -4
- package/scripts/virality.py +151 -0
- package/setup/SKILL.md +1 -1
- package/skill/engage-dm-replies.sh +11 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-draft-and-publish.sh +16 -0
- package/skill/run-reddit-search-launchd.sh +1 -0
- package/skill/run-reddit-search.sh +10 -1
- package/skill/run-reddit-threads.sh +12 -0
- 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
|
|
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
|
|
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
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
|
2534
|
-
# the wedge the per-minute tick can skip 70-90% of
|
|
2535
|
-
# still drains every job (jobs arrive ~every 8
|
|
2536
|
-
# lastRunAt staleness flip-flopped the icon
|
|
2537
|
-
# (observed on the operator box 2026-07-09:
|
|
2538
|
-
# posts/hr flowing, icon oscillating).
|
|
2539
|
-
#
|
|
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)
|
|
2812
|
-
#
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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.
|
|
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 —
|
|
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
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
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,
|
|
3834
|
-
#
|
|
3835
|
-
#
|
|
3836
|
-
#
|
|
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
|
|
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;
|
|
3921
|
-
#
|
|
3922
|
-
#
|
|
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
|
-
|
|
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__":
|