@m13v/s4l 1.7.2-rc.9 → 1.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/dist/index.js +61 -9
- package/mcp/dist/screencast.js +27 -0
- package/mcp/dist/setup.js +8 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +188 -0
- package/mcp/menubar/s4l_card.py +312 -29
- package/mcp/menubar/s4l_log_relay.py +32 -0
- package/mcp/menubar/s4l_menubar.py +278 -62
- package/mcp/menubar/s4l_state.py +37 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/_process_li_notifs.py +91 -0
- package/scripts/feedback_digest.py +1 -1
- package/scripts/merge_review_queue.py +87 -21
- package/scripts/pick_project.py +12 -3
- package/scripts/release-mcpb.sh +15 -1
- package/scripts/s4l_mode.py +9 -8
- package/scripts/schedule_state.py +53 -0
- package/scripts/twitter_post_plan.py +67 -0
- package/skill/run-draft-and-publish.sh +4 -3
|
@@ -117,6 +117,19 @@ try:
|
|
|
117
117
|
except Exception:
|
|
118
118
|
pass
|
|
119
119
|
|
|
120
|
+
# Ground-truth browser-foreground telemetry: whenever a MANAGED harness Chrome
|
|
121
|
+
# becomes the frontmost app (fresh-launch activation, screencast bringToFront,
|
|
122
|
+
# macOS clamping the off-screen window back on-screen, anything), ship one
|
|
123
|
+
# structured line with context "browser-foreground" through the relay above.
|
|
124
|
+
# Cause-agnostic OS-level observation; the causing sites (screencast.ts) log
|
|
125
|
+
# their own attribution lines under the same context. Best-effort.
|
|
126
|
+
try:
|
|
127
|
+
import s4l_browser_foreground # noqa: E402
|
|
128
|
+
|
|
129
|
+
s4l_browser_foreground.install()
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
120
133
|
|
|
121
134
|
def _capture(err, **tags):
|
|
122
135
|
"""Report a handled menu-bar error to Sentry (component=menubar) without ever
|
|
@@ -439,6 +452,17 @@ REVIEW_HEAL_EVERY_SECONDS = float(
|
|
|
439
452
|
)
|
|
440
453
|
REVIEW_UNATTENDED_SENTRY_SECONDS = 3600.0
|
|
441
454
|
|
|
455
|
+
# Review snooze (2026-07-09 customer feedback: cards interrupt focused work
|
|
456
|
+
# with no way to defer them). Closing the card with undecided drafts, via the
|
|
457
|
+
# cross or the title-bar "Snooze 1h" button, parks the WHOLE pending backlog
|
|
458
|
+
# for REVIEW_SNOOZE_SECONDS: no auto-present, no watchdog heal (nothing is on
|
|
459
|
+
# screen). Drafts stay in the store untouched; the menu's "Review N pending
|
|
460
|
+
# drafts" clears the snooze early. Persisted so a menubar restart mid-snooze
|
|
461
|
+
# doesn't re-pop the card. s4l_card._snooze_secs reads the same env var for
|
|
462
|
+
# the button label; keep them in sync.
|
|
463
|
+
REVIEW_SNOOZE_SECONDS = float(os.environ.get("S4L_REVIEW_SNOOZE_S", "3600"))
|
|
464
|
+
SNOOZE_FILE = os.path.join(st.state_dir(), "review-snooze.json")
|
|
465
|
+
|
|
442
466
|
|
|
443
467
|
def _label_elapsed_secs(label):
|
|
444
468
|
"""Parse the trailing duration the producer encodes in a drafting activity
|
|
@@ -519,6 +543,10 @@ class S4LMenuBar(rumps.App):
|
|
|
519
543
|
self._post_worker = None
|
|
520
544
|
self._review_lock = threading.Lock()
|
|
521
545
|
self._panel_open = False
|
|
546
|
+
# Review snooze: epoch until which draft cards must not auto-present
|
|
547
|
+
# (0 = not snoozed). Loaded from disk so a menubar restart mid-snooze
|
|
548
|
+
# doesn't re-pop the card the user just put away.
|
|
549
|
+
self._review_snooze_until = self._read_snooze_until()
|
|
522
550
|
# Unattended-review watchdog state (_maybe_heal_review).
|
|
523
551
|
self._review_heal_at = 0.0
|
|
524
552
|
self._review_unattended_notified = False
|
|
@@ -645,6 +673,11 @@ class S4LMenuBar(rumps.App):
|
|
|
645
673
|
# Cached schedule state for the current account: 'missing'/'disabled'/'ok'/
|
|
646
674
|
# 'unknown'. PRIMARY driver of the menu's attention section.
|
|
647
675
|
self._schedule_state_cache = "ok"
|
|
676
|
+
# Tick-freshness diagnostics (schedule_state.tick_stats()) shown as the
|
|
677
|
+
# non-alarm "scheduler degraded" menu line while state == 'stalled'.
|
|
678
|
+
# None outside that state; refreshed at most once a minute (_tick).
|
|
679
|
+
self._tick_stats = None
|
|
680
|
+
self._tick_stats_at = 0.0
|
|
648
681
|
self._reloc_timer = rumps.Timer(self._maybe_relocate_tasks, 90)
|
|
649
682
|
self._reloc_timer.start()
|
|
650
683
|
self._tick(None)
|
|
@@ -874,11 +907,15 @@ class S4LMenuBar(rumps.App):
|
|
|
874
907
|
does NOT stay stale after recovery.
|
|
875
908
|
|
|
876
909
|
NOTE: kept in sync with scripts/autopilot_stall_watch.py (the fleet Sentry
|
|
877
|
-
backstop).
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
910
|
+
backstop). Since 2026-07-09 this method IS an attention/⚠ driver: _tick
|
|
911
|
+
ORs it in (with the reason from _stall_reason) alongside the structural
|
|
912
|
+
schedule states (missing/disabled) and the activity-label draft_stuck
|
|
913
|
+
check. A firing-but-momentarily-empty queue still stays green: an idle
|
|
914
|
+
queue has no pending job, running/ is empty, and the drain latch zeroes
|
|
915
|
+
on every successful consume (claude_job._mark_drain_success), so none of
|
|
916
|
+
the three signals can hold a stale True after recovery. Tick-freshness
|
|
917
|
+
'stalled' from schedule_state no longer flips the ⚠ at all — it renders
|
|
918
|
+
as a plain diagnostic line (see _build_menu).
|
|
882
919
|
"""
|
|
883
920
|
qroot = os.path.join(st.state_dir(), "claude-queue")
|
|
884
921
|
# (1) latched producer drain-status
|
|
@@ -2320,34 +2357,38 @@ class S4LMenuBar(rumps.App):
|
|
|
2320
2357
|
blocker = (ob or {}).get("current_blocker")
|
|
2321
2358
|
blocker_code = (blocker or {}).get("code")
|
|
2322
2359
|
# --- Autopilot health (only meaningful once setup is complete) --------
|
|
2323
|
-
#
|
|
2324
|
-
#
|
|
2325
|
-
#
|
|
2326
|
-
#
|
|
2327
|
-
#
|
|
2328
|
-
#
|
|
2329
|
-
#
|
|
2360
|
+
# TWO layers (2026-07-09 redesign):
|
|
2361
|
+
# STRUCTURAL (schedule_state): 'missing'/'disabled' = the schedule is
|
|
2362
|
+
# genuinely not registered/enabled for this account -> ⚠ + re-arm.
|
|
2363
|
+
# JOB LATENCY (_autopilot_stalled + the draft_stuck label check below):
|
|
2364
|
+
# a draft job sat unclaimed, wedged mid-run, or the producer's drain
|
|
2365
|
+
# latch tripped -> ⚠, because THAT is user-visible harm.
|
|
2366
|
+
# Tick freshness ('stalled': task present + enabled but lastRunAt stale,
|
|
2367
|
+
# the Desktop warm-session wedge) is DIAGNOSTIC ONLY, never the ⚠: under
|
|
2368
|
+
# the wedge the per-minute tick can skip 70-90% of fires while the queue
|
|
2369
|
+
# still drains every job (jobs arrive ~every 8 min vs 60 fires/hr), so
|
|
2370
|
+
# lastRunAt staleness flip-flopped the icon against a healthy pipeline
|
|
2371
|
+
# (observed on the operator box 2026-07-09: 83% ticks skipped, ~14
|
|
2372
|
+
# posts/hr flowing, icon oscillating). _build_menu renders 'stalled' as
|
|
2373
|
+
# a plain "scheduler degraded" detail line with tick_stats() numbers.
|
|
2374
|
+
# The drain latch IS safe to alarm on now: claude_job._mark_drain_success
|
|
2375
|
+
# zeroes it on every successful consume, so it self-clears (the old
|
|
2376
|
+
# stayed-stale-after-recovery latch this comment used to warn about
|
|
2377
|
+
# predates that reset).
|
|
2330
2378
|
# Always read the REAL schedule state (no setup-gated "ok" fallback that
|
|
2331
2379
|
# lied). The re-arm WARNING still only fires once setup is complete, so we
|
|
2332
2380
|
# never nag the user mid-onboarding — only the value is now always honest.
|
|
2333
2381
|
schedule_state = self._schedule_state()
|
|
2334
2382
|
self._schedule_state_cache = schedule_state
|
|
2335
|
-
|
|
2336
|
-
# scheduler stopped launching it: the Desktop warm-session wedge,
|
|
2337
|
-
# Karol 2026-07-06) needs attention just like missing/disabled.
|
|
2338
|
-
# schedule_state.py is account-scoped (2026-07-08): an account switch
|
|
2339
|
-
# now correctly resolves to 'missing' (the active account genuinely
|
|
2340
|
-
# has no registration), not 'stalled' — a stale OTHER account's
|
|
2341
|
-
# registry is never consulted for the active account's health, so
|
|
2342
|
-
# 'stalled' can no longer be misdiagnosed here.
|
|
2343
|
-
attention = setup_complete and schedule_state in ("missing", "disabled", "stalled")
|
|
2383
|
+
attention = setup_complete and schedule_state in ("missing", "disabled")
|
|
2344
2384
|
# Routines-lane rate limit (429): the draft tasks ARE registered and firing
|
|
2345
2385
|
# for this account, but every run dies on a Claude rate limit, so nothing
|
|
2346
2386
|
# drafts. Re-arm can't fix that — surface it as its own ⚠ attention state
|
|
2347
|
-
# with a "rate-limited" reason.
|
|
2348
|
-
# ('ok'); the missing/disabled case
|
|
2349
|
-
#
|
|
2350
|
-
|
|
2387
|
+
# with a "rate-limited" reason. Meaningful whenever the schedule exists
|
|
2388
|
+
# ('ok' or the degraded-but-firing 'stalled'); the missing/disabled case
|
|
2389
|
+
# already owns the ⚠. Throttled (~30s): scanning the worker-transcript
|
|
2390
|
+
# bucket is glob-heavy and changes slowly.
|
|
2391
|
+
if setup_complete and schedule_state in ("ok", "stalled"):
|
|
2351
2392
|
now_rl = time.time()
|
|
2352
2393
|
if now_rl - getattr(self, "_rl_checked_at", 0.0) >= 30:
|
|
2353
2394
|
self._rl_checked_at = now_rl
|
|
@@ -2371,12 +2412,10 @@ class S4LMenuBar(rumps.App):
|
|
|
2371
2412
|
# draft_stuck shadowed the missing/disabled branch in _build_menu — the user
|
|
2372
2413
|
# saw "worker keeps getting killed" with NO Re-arm button instead of "Draft
|
|
2373
2414
|
# tasks aren't scheduled on this account" + the one-click fix (Karol,
|
|
2374
|
-
# 2026-07-06). "stalled" is deliberately included (2026-07-08): that
|
|
2375
|
-
#
|
|
2376
|
-
#
|
|
2377
|
-
#
|
|
2378
|
-
# from the queue itself than the host's lastRunAt staleness, so it's
|
|
2379
|
-
# worth surfacing as its own reason even under "stalled".
|
|
2415
|
+
# 2026-07-06). "stalled" is deliberately included (2026-07-08): a job that
|
|
2416
|
+
# has sat this long — claimed-and-hung, OR never claimed at all (see the ⧖
|
|
2417
|
+
# prefix check below) — is direct evidence from the queue itself, exactly
|
|
2418
|
+
# the layer the ⚠ keys off now that tick staleness alone is diagnostic.
|
|
2380
2419
|
if (
|
|
2381
2420
|
setup_complete
|
|
2382
2421
|
and schedule_state in ("ok", "stalled")
|
|
@@ -2390,6 +2429,44 @@ class S4LMenuBar(rumps.App):
|
|
|
2390
2429
|
):
|
|
2391
2430
|
attention = True
|
|
2392
2431
|
self._stall_reason_info = ("draft_stuck", _act.get("label") or "")
|
|
2432
|
+
# Queue-latency stall (the layer that measures actual user harm): a draft
|
|
2433
|
+
# job sat unclaimed past AUTOPILOT_STALL_SECONDS, wedged in running/ past
|
|
2434
|
+
# AUTOPILOT_RUNNING_STALL_SECONDS, or the producer's drain latch tripped
|
|
2435
|
+
# (consecutive timeouts, zeroed on every successful consume). The activity-
|
|
2436
|
+
# label draft_stuck check above needs a live producer narrating "drafting";
|
|
2437
|
+
# this one works between cycles too (the latch persists the episode), so
|
|
2438
|
+
# together they cover producer-alive and producer-gone stalls. Reason
|
|
2439
|
+
# refines the one-click fix: 'orphaned' (no routine executed recently,
|
|
2440
|
+
# re-arm helps) vs 'failing' (routines run but drafts die, Diagnose).
|
|
2441
|
+
if (
|
|
2442
|
+
setup_complete
|
|
2443
|
+
and schedule_state in ("ok", "stalled")
|
|
2444
|
+
and self._stall_reason_info[0] not in ("rate_limited", "draft_stuck")
|
|
2445
|
+
):
|
|
2446
|
+
if self._autopilot_stalled():
|
|
2447
|
+
_qreason, _qmsg = self._stall_reason()
|
|
2448
|
+
attention = True
|
|
2449
|
+
self._stall_reason_info = (_qreason, _qmsg)
|
|
2450
|
+
elif self._stall_reason_info[0] in ("orphaned", "failing"):
|
|
2451
|
+
# Queue recovered since the last tick: clear the stale reason
|
|
2452
|
+
# now instead of waiting for the 30s rate-limit rescan to
|
|
2453
|
+
# overwrite it (a lingering reason skews the diagnose prompt
|
|
2454
|
+
# and churns the menu signature for nothing).
|
|
2455
|
+
self._stall_reason_info = ("", "")
|
|
2456
|
+
# Tick-freshness diagnostics for the non-alarm "scheduler degraded" menu
|
|
2457
|
+
# line. Only computed while 'stalled' (it reads + parses the registry
|
|
2458
|
+
# JSON, which can be hundreds of KB) and throttled to once a minute.
|
|
2459
|
+
if schedule_state == "stalled":
|
|
2460
|
+
_now_ts = time.time()
|
|
2461
|
+
if _now_ts - getattr(self, "_tick_stats_at", 0.0) >= 60:
|
|
2462
|
+
self._tick_stats_at = _now_ts
|
|
2463
|
+
try:
|
|
2464
|
+
import schedule_state as _ss
|
|
2465
|
+
self._tick_stats = _ss.tick_stats()
|
|
2466
|
+
except Exception:
|
|
2467
|
+
self._tick_stats = None
|
|
2468
|
+
else:
|
|
2469
|
+
self._tick_stats = None
|
|
2393
2470
|
# Drop the stale "drafting" spinner while we need attention so the ⚠ shows.
|
|
2394
2471
|
self._stalled = attention
|
|
2395
2472
|
|
|
@@ -2415,7 +2492,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2415
2492
|
# Once per episode (gated by _stall_notified), so it never spams.
|
|
2416
2493
|
_reason = (
|
|
2417
2494
|
self._stall_reason_info[0]
|
|
2418
|
-
or (
|
|
2495
|
+
or ("disabled" if schedule_state == "disabled" else "missing")
|
|
2419
2496
|
)
|
|
2420
2497
|
_capture_msg(
|
|
2421
2498
|
f"S4L draft autopilot needs attention: {_reason}",
|
|
@@ -2447,19 +2524,26 @@ class S4LMenuBar(rumps.App):
|
|
|
2447
2524
|
"A worker claimed a draft job and never finished it. ")
|
|
2448
2525
|
+ "Open the S4L menu → “Diagnose & fix in Claude…”.",
|
|
2449
2526
|
)
|
|
2527
|
+
elif self._stall_reason_info[0] == "orphaned":
|
|
2528
|
+
self._notify(
|
|
2529
|
+
"S4L drafts not draining",
|
|
2530
|
+
"Draft jobs are stuck in the queue and no routine has picked "
|
|
2531
|
+
"them up. Open the S4L menu, then click "
|
|
2532
|
+
"“Set up draft schedule”.",
|
|
2533
|
+
)
|
|
2534
|
+
elif self._stall_reason_info[0] == "failing":
|
|
2535
|
+
self._notify(
|
|
2536
|
+
"S4L drafts not draining",
|
|
2537
|
+
"Draft jobs are stuck in the queue; runs start but never "
|
|
2538
|
+
"finish. Open the S4L menu, then click "
|
|
2539
|
+
"“Diagnose & fix in Claude…”.",
|
|
2540
|
+
)
|
|
2450
2541
|
elif schedule_state == "disabled":
|
|
2451
2542
|
self._notify(
|
|
2452
2543
|
"S4L draft tasks disabled",
|
|
2453
2544
|
"The draft tasks are scheduled but disabled. Open the S4L menu → "
|
|
2454
2545
|
"“Set up draft schedule” to re-enable.",
|
|
2455
2546
|
)
|
|
2456
|
-
elif schedule_state == "stalled":
|
|
2457
|
-
self._notify(
|
|
2458
|
-
"S4L drafts stopped",
|
|
2459
|
-
"Claude’s scheduler stopped running the draft tasks (a known "
|
|
2460
|
-
"Claude Desktop glitch). Open the S4L menu → “Set up draft "
|
|
2461
|
-
"schedule” to re-register it.",
|
|
2462
|
-
)
|
|
2463
2547
|
else:
|
|
2464
2548
|
can_selfheal = False
|
|
2465
2549
|
try:
|
|
@@ -2527,8 +2611,20 @@ class S4LMenuBar(rumps.App):
|
|
|
2527
2611
|
attention,
|
|
2528
2612
|
schedule_state,
|
|
2529
2613
|
self._stall_reason_info,
|
|
2614
|
+
# Degraded-scheduler skip count, bucketed by 5 so the diagnostic
|
|
2615
|
+
# line refreshes every few minutes at most (not every poll — an
|
|
2616
|
+
# open menu shouldn't be torn down under the cursor for a +1).
|
|
2617
|
+
((self._tick_stats or {}).get("skips_in_window", 0) // 5)
|
|
2618
|
+
if self._tick_stats
|
|
2619
|
+
else None,
|
|
2530
2620
|
os.path.exists(PAUSE_FLAG),
|
|
2531
2621
|
pending_count,
|
|
2622
|
+
# Snooze end (0 = not snoozed): rebuilds the menu when a snooze is
|
|
2623
|
+
# set, cleared, or lapses, so the "Snoozed until HH:MM" label and
|
|
2624
|
+
# the review item stay honest.
|
|
2625
|
+
int(self._review_snooze_until)
|
|
2626
|
+
if time.time() < self._review_snooze_until
|
|
2627
|
+
else 0,
|
|
2532
2628
|
)
|
|
2533
2629
|
if sig != self._sig:
|
|
2534
2630
|
self._sig = sig
|
|
@@ -2602,7 +2698,51 @@ class S4LMenuBar(rumps.App):
|
|
|
2602
2698
|
except Exception:
|
|
2603
2699
|
return None, []
|
|
2604
2700
|
|
|
2605
|
-
def
|
|
2701
|
+
def _read_snooze_until(self):
|
|
2702
|
+
try:
|
|
2703
|
+
with open(SNOOZE_FILE) as f:
|
|
2704
|
+
return float((json.load(f) or {}).get("until") or 0.0)
|
|
2705
|
+
except Exception:
|
|
2706
|
+
return 0.0
|
|
2707
|
+
|
|
2708
|
+
def _set_snooze(self, until, pending=0):
|
|
2709
|
+
"""Set (or clear, until<=now) the review snooze, mirrored to disk."""
|
|
2710
|
+
self._review_snooze_until = until
|
|
2711
|
+
try:
|
|
2712
|
+
if until <= time.time():
|
|
2713
|
+
try:
|
|
2714
|
+
os.remove(SNOOZE_FILE)
|
|
2715
|
+
except FileNotFoundError:
|
|
2716
|
+
pass
|
|
2717
|
+
else:
|
|
2718
|
+
with open(SNOOZE_FILE, "w") as f:
|
|
2719
|
+
json.dump(
|
|
2720
|
+
{"until": until, "set_at": time.time(), "pending": pending},
|
|
2721
|
+
f,
|
|
2722
|
+
)
|
|
2723
|
+
except Exception:
|
|
2724
|
+
pass
|
|
2725
|
+
|
|
2726
|
+
def _review_now(self, _=None):
|
|
2727
|
+
"""Menu: bring the pending draft cards up right now. Clears any snooze;
|
|
2728
|
+
this is the one presentation path that may take keyboard focus (the
|
|
2729
|
+
user explicitly asked for the cards)."""
|
|
2730
|
+
self._set_snooze(0.0)
|
|
2731
|
+
try:
|
|
2732
|
+
import s4l_card
|
|
2733
|
+
|
|
2734
|
+
if s4l_card.active_status():
|
|
2735
|
+
# A card is already on screen (user clicked while one is up,
|
|
2736
|
+
# perhaps parked on another display): bring it to them instead.
|
|
2737
|
+
s4l_card.heal_active()
|
|
2738
|
+
s4l_card.focus_active()
|
|
2739
|
+
return
|
|
2740
|
+
except Exception:
|
|
2741
|
+
pass
|
|
2742
|
+
self._last_review_sig = None
|
|
2743
|
+
self._maybe_start_review(focus=True)
|
|
2744
|
+
|
|
2745
|
+
def _maybe_start_review(self, focus=False):
|
|
2606
2746
|
req = st.read_review_request()
|
|
2607
2747
|
if not req:
|
|
2608
2748
|
return
|
|
@@ -2618,6 +2758,12 @@ class S4LMenuBar(rumps.App):
|
|
|
2618
2758
|
self._last_review_sig = None
|
|
2619
2759
|
st.clear_review_request()
|
|
2620
2760
|
return
|
|
2761
|
+
# Snoozed (the user closed the card with drafts still undecided): leave
|
|
2762
|
+
# the backlog in the store untouched and present nothing until the
|
|
2763
|
+
# snooze lapses. New drafts keep accumulating silently; the menu's
|
|
2764
|
+
# "Review N pending drafts" clears this early.
|
|
2765
|
+
if time.time() < self._review_snooze_until:
|
|
2766
|
+
return
|
|
2621
2767
|
# De-dup on the CONTENT of the pending set (each draft's plan index + reply
|
|
2622
2768
|
# text), not the constant batch_id. This means: re-present whenever NEW
|
|
2623
2769
|
# drafts arrive (the signature changes), but don't re-pop the identical
|
|
@@ -2632,6 +2778,13 @@ class S4LMenuBar(rumps.App):
|
|
|
2632
2778
|
# live. This is the fix for the "card froze at 1 of 4 while 137 piled
|
|
2633
2779
|
# up" bug — drafts that arrived after the card opened used to be
|
|
2634
2780
|
# stranded because this method returned early on _review_active.
|
|
2781
|
+
# Also prune the other direction: any `n` this same stack used to
|
|
2782
|
+
# carry but that dropped out of the fresh `drafts` list (merge_review_
|
|
2783
|
+
# queue.py's backend sync just marked it terminal, most commonly the
|
|
2784
|
+
# freshness gate expiring it) is removed from the not-yet-reached part
|
|
2785
|
+
# of the stack, so an old card can't sit there waiting to be approved
|
|
2786
|
+
# into a silent no-op (see the 2026-07-09 "approved 3, nothing
|
|
2787
|
+
# posted" investigation).
|
|
2635
2788
|
# - Posting is DRAINING with no panel up (_review_active but not
|
|
2636
2789
|
# _panel_open): leave the signature untouched so the full pending set
|
|
2637
2790
|
# is presented fresh once the drain completes (don't pop a card mid-post).
|
|
@@ -2640,6 +2793,11 @@ class S4LMenuBar(rumps.App):
|
|
|
2640
2793
|
try:
|
|
2641
2794
|
import s4l_card
|
|
2642
2795
|
|
|
2796
|
+
prev_ns = {n for n, _ in (self._last_review_sig or ())}
|
|
2797
|
+
cur_ns = {d.get("n") for d in drafts}
|
|
2798
|
+
vanished = prev_ns - cur_ns
|
|
2799
|
+
if vanished:
|
|
2800
|
+
s4l_card.prune_active(vanished)
|
|
2643
2801
|
s4l_card.extend_active(drafts)
|
|
2644
2802
|
except Exception as e:
|
|
2645
2803
|
sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
|
|
@@ -2655,12 +2813,16 @@ class S4LMenuBar(rumps.App):
|
|
|
2655
2813
|
|
|
2656
2814
|
# present_feedback (the menu bar's feedback item) falls back to
|
|
2657
2815
|
# the module-level default handler; register ours before any
|
|
2658
|
-
# card shows.
|
|
2816
|
+
# card shows. Same pattern for the title bar's "Discard all…"
|
|
2817
|
+
# button (moved out of the dropdown 2026-07-10), which reuses the
|
|
2818
|
+
# bulk-discard handler wholesale, confirmation alert included.
|
|
2659
2819
|
s4l_card.set_feedback_handler(self._on_feedback_text)
|
|
2820
|
+
s4l_card.set_discard_all_handler(self._discard_all_pending)
|
|
2660
2821
|
s4l_card.present_review(
|
|
2661
2822
|
drafts,
|
|
2662
2823
|
on_decision=lambda d: self._on_card_decision(batch, d),
|
|
2663
2824
|
on_complete=lambda decisions: self._on_review_closed(batch, decisions),
|
|
2825
|
+
focus=focus,
|
|
2664
2826
|
)
|
|
2665
2827
|
# Record as shown only AFTER the cards are actually up, so a transient
|
|
2666
2828
|
# card-UI failure never permanently suppresses this pending set.
|
|
@@ -2886,6 +3048,20 @@ class S4LMenuBar(rumps.App):
|
|
|
2886
3048
|
remaining = 0
|
|
2887
3049
|
if remaining <= 0:
|
|
2888
3050
|
st.clear_review_request()
|
|
3051
|
+
else:
|
|
3052
|
+
# Undecided drafts left = the user closed the card early (cross or
|
|
3053
|
+
# the title-bar Snooze button): park the backlog instead of
|
|
3054
|
+
# re-popping it on the next tick, which made the card impossible
|
|
3055
|
+
# to put away (2026-07-09 customer feedback). The signature drop
|
|
3056
|
+
# below still runs, so after the snooze lapses the leftover
|
|
3057
|
+
# presents fresh rather than being suppressed as "already shown".
|
|
3058
|
+
until = time.time() + REVIEW_SNOOZE_SECONDS
|
|
3059
|
+
self._set_snooze(until, remaining)
|
|
3060
|
+
sys.stderr.write(
|
|
3061
|
+
f"[s4l-menubar] review snoozed: {remaining} pending until "
|
|
3062
|
+
f"{time.strftime('%H:%M', time.localtime(until))}\n"
|
|
3063
|
+
)
|
|
3064
|
+
sys.stderr.flush()
|
|
2889
3065
|
# Drop the dedup signature so whatever is left is presented fresh (not
|
|
2890
3066
|
# suppressed as "already shown") once posting finishes draining.
|
|
2891
3067
|
self._last_review_sig = None
|
|
@@ -2895,7 +3071,15 @@ class S4LMenuBar(rumps.App):
|
|
|
2895
3071
|
st.flush_review_events_async()
|
|
2896
3072
|
except Exception:
|
|
2897
3073
|
pass
|
|
2898
|
-
if
|
|
3074
|
+
if remaining > 0:
|
|
3075
|
+
plural = "s" if remaining != 1 else ""
|
|
3076
|
+
self._notify(
|
|
3077
|
+
"S4L",
|
|
3078
|
+
f"Snoozed {remaining} pending draft{plural} until "
|
|
3079
|
+
f"{time.strftime('%H:%M', time.localtime(self._review_snooze_until))}. "
|
|
3080
|
+
"Review sooner from the S4L menu.",
|
|
3081
|
+
)
|
|
3082
|
+
elif not any(d.get("approved") for d in decisions):
|
|
2899
3083
|
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
2900
3084
|
|
|
2901
3085
|
def _discard_all_pending(self, _=None):
|
|
@@ -3123,26 +3307,21 @@ class S4LMenuBar(rumps.App):
|
|
|
3123
3307
|
" " + (self._stall_reason_info[1] or "drafting") + " — no result yet"
|
|
3124
3308
|
))
|
|
3125
3309
|
items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
|
|
3310
|
+
elif self._stall_reason_info[0] == "orphaned":
|
|
3311
|
+
# Queue-latency stall, no routine executing (no recent worker
|
|
3312
|
+
# transcript): draft jobs sit unclaimed. Re-arm re-registers the
|
|
3313
|
+
# schedule through the real create_scheduled_task path, the one
|
|
3314
|
+
# mechanical fix that addresses "nothing is picking jobs up".
|
|
3315
|
+
items.append(self._label("⚠ Draft jobs stuck, no routine picking them up"))
|
|
3316
|
+
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3317
|
+
elif self._stall_reason_info[0] == "failing":
|
|
3318
|
+
# Queue-latency stall but routines DO execute: runs start and
|
|
3319
|
+
# die before draining. Re-arm can't fix that; Diagnose can.
|
|
3320
|
+
items.append(self._label("⚠ Draft jobs stuck, runs start but don't finish"))
|
|
3321
|
+
items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
|
|
3126
3322
|
elif schedule_state == "disabled":
|
|
3127
3323
|
items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
|
|
3128
3324
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3129
|
-
elif schedule_state == "stalled":
|
|
3130
|
-
# Task registered + enabled FOR THE ACTIVE ACCOUNT (schedule_state.py
|
|
3131
|
-
# is account-scoped, 2026-07-08 — see its module docstring) but the
|
|
3132
|
-
# host stopped launching it: the Claude Desktop warm-session wedge
|
|
3133
|
-
# (finished worker sessions never exit; the overlap guard skips
|
|
3134
|
-
# every fire). Re-arm goes through the same real create_scheduled_task
|
|
3135
|
-
# path as onboarding — a verified fix the user has seen work —
|
|
3136
|
-
# rather than a silent Claude Desktop quit/relaunch with no visible
|
|
3137
|
-
# feedback, which field evidence (2026-07-08) showed doesn't
|
|
3138
|
-
# reliably clear the underlying stall (that field incident turned
|
|
3139
|
-
# out to be a DIFFERENT bug: schedule_state.py was reading a stale,
|
|
3140
|
-
# no-longer-active account's registry — now fixed, so "stalled"
|
|
3141
|
-
# here means what it says: same account, task present, just not
|
|
3142
|
-
# firing). Distinct label from the "aren't scheduled" case below
|
|
3143
|
-
# since the task DOES exist here; same one-click remedy either way.
|
|
3144
|
-
items.append(self._label("⚠ Drafts stopped — Claude’s scheduler is stuck"))
|
|
3145
|
-
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3146
3325
|
else:
|
|
3147
3326
|
items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
|
|
3148
3327
|
# Prefer the automatic fix (2026-07-08): if the active account
|
|
@@ -3172,6 +3351,29 @@ class S4LMenuBar(rumps.App):
|
|
|
3172
3351
|
else:
|
|
3173
3352
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3174
3353
|
items.append(rumps.separator)
|
|
3354
|
+
elif setup_complete and schedule_state == "stalled":
|
|
3355
|
+
# NON-ALARM diagnostic (2026-07-09): the task is registered + enabled
|
|
3356
|
+
# for the active account but the host's per-minute tick is stale or
|
|
3357
|
+
# mostly skipped (Desktop warm-session wedge). The queue checks above
|
|
3358
|
+
# would have flipped ⚠ if jobs were actually stuck, so reaching here
|
|
3359
|
+
# means drafts still drain; say so with numbers instead of alarming.
|
|
3360
|
+
# tick_stats is cached by _tick (refreshed ≤ once/min, None outside
|
|
3361
|
+
# 'stalled').
|
|
3362
|
+
_ts = self._tick_stats or {}
|
|
3363
|
+
_skips = _ts.get("skips_in_window")
|
|
3364
|
+
_age = _ts.get("last_run_age_s")
|
|
3365
|
+
_bits = []
|
|
3366
|
+
if _skips is not None:
|
|
3367
|
+
_bits.append(f"{_skips} of ~60 ticks skipped last hour")
|
|
3368
|
+
if _age is not None:
|
|
3369
|
+
_bits.append(f"last run {max(0, int(_age)) // 60}m ago")
|
|
3370
|
+
items.append(self._label("Scheduler degraded (drafts still running)"))
|
|
3371
|
+
if _bits:
|
|
3372
|
+
items.append(self._label(" " + ", ".join(_bits)))
|
|
3373
|
+
# Keep the one-click remedy reachable without dressing it as urgent:
|
|
3374
|
+
# re-registering through create_scheduled_task un-wedges the host.
|
|
3375
|
+
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3376
|
+
items.append(rumps.separator)
|
|
3175
3377
|
|
|
3176
3378
|
if not runtime_ready:
|
|
3177
3379
|
items += self._state_a()
|
|
@@ -3329,12 +3531,26 @@ class S4LMenuBar(rumps.App):
|
|
|
3329
3531
|
def _state_c(self, snap, pending_count=0):
|
|
3330
3532
|
if pending_count <= 0:
|
|
3331
3533
|
return []
|
|
3332
|
-
|
|
3534
|
+
plural = "s" if pending_count != 1 else ""
|
|
3535
|
+
items = [
|
|
3333
3536
|
rumps.MenuItem(
|
|
3334
|
-
f"
|
|
3335
|
-
callback=self.
|
|
3537
|
+
f"Review {pending_count} pending draft{plural}",
|
|
3538
|
+
callback=self._review_now,
|
|
3336
3539
|
)
|
|
3337
3540
|
]
|
|
3541
|
+
if time.time() < self._review_snooze_until:
|
|
3542
|
+
items.append(
|
|
3543
|
+
self._label(
|
|
3544
|
+
"Snoozed until "
|
|
3545
|
+
+ time.strftime(
|
|
3546
|
+
"%H:%M", time.localtime(self._review_snooze_until)
|
|
3547
|
+
)
|
|
3548
|
+
)
|
|
3549
|
+
)
|
|
3550
|
+
# No bulk-discard item here anymore: "Discard all…" lives in the
|
|
3551
|
+
# review card's title bar (2026-07-10). Reaching it with no card open
|
|
3552
|
+
# is "Review N pending drafts" -> Discard all, two clicks.
|
|
3553
|
+
return items
|
|
3338
3554
|
|
|
3339
3555
|
|
|
3340
3556
|
if __name__ == "__main__":
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -736,6 +736,37 @@ def _match_candidate(data, n, candidate_id):
|
|
|
736
736
|
return None
|
|
737
737
|
|
|
738
738
|
|
|
739
|
+
def candidate_state(c):
|
|
740
|
+
"""Canonical lifecycle state of ONE review-queue candidate. Use this instead
|
|
741
|
+
of ad hoc flag checks — "not posted" is NOT "awaiting review".
|
|
742
|
+
|
|
743
|
+
The review queue (review-queue.json) is an APPEND-FOREVER LEDGER: handled
|
|
744
|
+
candidates are never removed, they are flag-stamped in place, so most rows
|
|
745
|
+
in an old queue are retired. States, in precedence order:
|
|
746
|
+
|
|
747
|
+
posted — approved and successfully posted (our_url set).
|
|
748
|
+
terminal — retired without a post; terminal_reason says why
|
|
749
|
+
(rejected, human_rejected, human_discarded_all,
|
|
750
|
+
duplicate_thread_pre_post, ...; None on older rows).
|
|
751
|
+
post_failed — approved but the post attempt errored (post_error says
|
|
752
|
+
why). Settled from the cards' point of view; the resume
|
|
753
|
+
path retries only after a fresh approval clears it.
|
|
754
|
+
approved — human approved, post not yet attempted/confirmed.
|
|
755
|
+
awaiting_review — none of the above. The ONLY state cards present, and
|
|
756
|
+
the only honest "pending" count (review-request.json's
|
|
757
|
+
.count mirrors it at merge time).
|
|
758
|
+
"""
|
|
759
|
+
if c.get("posted") is True:
|
|
760
|
+
return "posted"
|
|
761
|
+
if c.get("terminal") is True:
|
|
762
|
+
return "terminal"
|
|
763
|
+
if c.get("post_failed"):
|
|
764
|
+
return "post_failed"
|
|
765
|
+
if c.get("approved") is True:
|
|
766
|
+
return "approved"
|
|
767
|
+
return "awaiting_review"
|
|
768
|
+
|
|
769
|
+
|
|
739
770
|
def store_stamp_decision(batch, decision):
|
|
740
771
|
"""Write a card decision INTO the store the instant the user clicks. This is
|
|
741
772
|
the durable record (the old approved-queue.json ledger is no longer
|
|
@@ -784,7 +815,7 @@ def discard_all_pending(drafts):
|
|
|
784
815
|
done = 0
|
|
785
816
|
for d in drafts:
|
|
786
817
|
c = _match_candidate(data, d.get("n"), d.get("candidate_id"))
|
|
787
|
-
if c is None or c
|
|
818
|
+
if c is None or candidate_state(c) in ("posted", "terminal"):
|
|
788
819
|
continue
|
|
789
820
|
c["terminal"] = True
|
|
790
821
|
c["terminal_reason"] = "human_discarded_all"
|
|
@@ -865,7 +896,7 @@ def store_pending_posts(batch="review-queue"):
|
|
|
865
896
|
plan = read_plan(store_path())
|
|
866
897
|
out = []
|
|
867
898
|
for i, c in enumerate((plan or {}).get("candidates") or []):
|
|
868
|
-
if
|
|
899
|
+
if candidate_state(c) != "approved":
|
|
869
900
|
continue
|
|
870
901
|
d = c.get("decision") or {}
|
|
871
902
|
out.append(
|
|
@@ -932,7 +963,7 @@ def review_queue_posted_count():
|
|
|
932
963
|
cands = (plan or {}).get("candidates")
|
|
933
964
|
if not cands:
|
|
934
965
|
return None
|
|
935
|
-
return sum(1 for c in cands if c
|
|
966
|
+
return sum(1 for c in cands if candidate_state(c) == "posted")
|
|
936
967
|
|
|
937
968
|
|
|
938
969
|
def _plan_generation(batch):
|
|
@@ -1008,7 +1039,9 @@ def review_drafts(plan, batch="review-queue"):
|
|
|
1008
1039
|
settled_ns = {it.get("n") for it in items if it.get("candidate_id") is None}
|
|
1009
1040
|
out = []
|
|
1010
1041
|
for i, c in enumerate(((plan or {}).get("candidates") or [])):
|
|
1011
|
-
|
|
1042
|
+
# Only awaiting_review rows become cards. This also skips post_failed
|
|
1043
|
+
# rows (decided-but-failed is settled per the docstring above).
|
|
1044
|
+
if candidate_state(c) != "awaiting_review":
|
|
1012
1045
|
continue
|
|
1013
1046
|
cid = c.get("candidate_id")
|
|
1014
1047
|
if cid is not None and cid in settled_ids:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED