social-autoposter 1.7.2-rc.9 → 1.7.2

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 CHANGED
@@ -870,6 +870,18 @@ async function ensureTwitterBrowserForPost() {
870
870
  },
871
871
  });
872
872
  }
873
+ // A terminal stamp written by merge_review_queue.py's backend sync for a row the
874
+ // freshness gate merely EXPIRED (not posted, not skipped) records "nobody decided
875
+ // this in time" — an explicit human approval outranks it. The poster's own
876
+ // at-post-time tweet_unavailable check remains the real gate on whether the
877
+ // thread still exists. Without this override, a card approved while (or just
878
+ // before) the sync stamped it is refused as already-decided and the approval
879
+ // silently no-ops (2 of 3 approvals lost on 2026-07-10).
880
+ function expiredStampOverridable(c) {
881
+ return (c.terminal === true &&
882
+ c.posted !== true &&
883
+ c.discard_reason === "backend_status_expired");
884
+ }
873
885
  async function postApproved(batchId, plan) {
874
886
  // Drain serialization (2026-07-06 incident). Every call drains the WHOLE
875
887
  // approved backlog, so overlapping drains are pure waste and actively harmful:
@@ -922,7 +934,18 @@ async function postApproved(batchId, plan) {
922
934
  // drains the not-yet-posted approved backlog (e.g. a card a restart interrupted),
923
935
  // never re-posts a done one. This is what lets the startup backlog-drain and the
924
936
  // per-card menu-bar calls share one code path safely.
925
- const approved = (plan.candidates || []).filter((c) => c.approved === true && c.posted !== true && c.terminal !== true);
937
+ // An approved card whose only blocker is an overridable backend-expiry stamp is
938
+ // included: approval outranks the freshness gate (see expiredStampOverridable),
939
+ // and the stamp is cleared so every downstream terminal check agrees it's live.
940
+ const approved = (plan.candidates || []).filter((c) => c.approved === true &&
941
+ c.posted !== true &&
942
+ (c.terminal !== true || expiredStampOverridable(c)));
943
+ for (const c of approved) {
944
+ if (c.terminal === true) {
945
+ c.terminal = false;
946
+ delete c.discard_reason;
947
+ }
948
+ }
926
949
  if (approved.length === 0)
927
950
  return { attempted: 0, exit_code: 0, summary: "nothing approved" };
928
951
  // PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
@@ -2316,9 +2339,17 @@ tool("post_drafts", {
2316
2339
  });
2317
2340
  // Cross-surface de-dup: chat and the menu-bar pop-ups can both approve, so
2318
2341
  // never re-post a candidate the other surface already posted OR ruled out.
2342
+ // Exception: an overridable backend-expiry stamp yields to this explicit
2343
+ // approval (see expiredStampOverridable) — clear it and post.
2319
2344
  const alreadyDone = [];
2320
2345
  for (const n of Array.from(approve)) {
2321
- if (candidates[n - 1]?.posted === true || candidates[n - 1]?.terminal === true) {
2346
+ const c = candidates[n - 1];
2347
+ if (c && expiredStampOverridable(c)) {
2348
+ c.terminal = false;
2349
+ delete c.discard_reason;
2350
+ continue;
2351
+ }
2352
+ if (c?.posted === true || c?.terminal === true) {
2322
2353
  approve.delete(n);
2323
2354
  alreadyDone.push(n);
2324
2355
  }
@@ -4963,7 +4994,9 @@ async function drainApprovedBacklog() {
4963
4994
  try {
4964
4995
  const plan = readPlan(REVIEW_QUEUE_ID);
4965
4996
  const cands = plan?.candidates || [];
4966
- const backlog = cands.filter((c) => c.approved === true && c.posted !== true && c.terminal !== true);
4997
+ const backlog = cands.filter((c) => c.approved === true &&
4998
+ c.posted !== true &&
4999
+ (c.terminal !== true || expiredStampOverridable(c)));
4967
5000
  if (!backlog.length)
4968
5001
  return;
4969
5002
  console.error(`[post] draining ${backlog.length} approved-but-unposted card(s) left from before`);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.2-rc.9",
3
- "installedAt": "2026-07-09T04:41:50.762Z"
2
+ "version": "1.7.2",
3
+ "installedAt": "2026-07-10T15:25:23.017Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.2-rc.9",
5
+ "version": "1.7.2",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
@@ -8,6 +8,15 @@ fires once the last card is decided or the window is closed. The whole AppKit
8
8
  surface is isolated behind that one function so the menu bar wiring doesn't
9
9
  depend on the windowing details.
10
10
 
11
+ The panel is NONACTIVATING and auto-presented cards never take keyboard focus
12
+ (2026-07-09 customer complaint: cards stole the caret mid-typing); the reply
13
+ field becomes editable on first click, and only a user-initiated open
14
+ (present_review(focus=True), the menu bar's "Review N pending drafts")
15
+ activates the app and seats the caret immediately. A title-bar "Snooze 1h"
16
+ button sits opposite the close cross; it and the cross both just close the
17
+ panel, and the menu bar interprets any close with undecided drafts as a
18
+ snooze (drafts stay pending, re-present after REVIEW_SNOOZE_SECONDS).
19
+
11
20
  Decision shape: {"n": int, "approved": bool, "loved": bool, "text": str,
12
21
  "edited": bool, "drop_link": bool, "reject_category": str|None,
13
22
  "reject_note": str|None, "interactions": [{"type": str, "ts": str}],
@@ -49,6 +58,7 @@ run loop, so that holds).
49
58
 
50
59
  import datetime
51
60
  import json
61
+ import os
52
62
  import re
53
63
  import time
54
64
 
@@ -116,6 +126,26 @@ try:
116
126
  from AppKit import NSVisualEffectView
117
127
  except Exception:
118
128
  NSVisualEffectView = None
129
+ # Nonactivating panel: an auto-presented card must never yank keyboard focus
130
+ # from whatever the user is typing into (customer complaint 2026-07-09); the
131
+ # panel becomes key only when the user clicks into one of its text fields.
132
+ # Bit value 1 << 7 per AppKit; 0 degrades to the old always-activating panel.
133
+ try:
134
+ from AppKit import NSWindowStyleMaskNonactivatingPanel
135
+ except Exception:
136
+ NSWindowStyleMaskNonactivatingPanel = 0
137
+ # Title-bar "Snooze 1h" button (sits opposite the close cross). Missing on
138
+ # very old AppKit; the card then degrades to cross-only (closing still
139
+ # snoozes, the label is just absent).
140
+ try:
141
+ from AppKit import NSTitlebarAccessoryViewController, NSLayoutAttributeRight
142
+ except Exception:
143
+ NSTitlebarAccessoryViewController = None
144
+ NSLayoutAttributeRight = None
145
+ try:
146
+ from AppKit import NSBezelStyleInline
147
+ except Exception:
148
+ NSBezelStyleInline = None
119
149
  try:
120
150
  # Registers the CGColor bridge type; without it every NSColor.CGColor()
121
151
  # call in _round_rect logs an ObjCPointerWarning to menubar.err.log.
@@ -149,14 +179,28 @@ REJECT_REASONS = (
149
179
  # Client-side cap on tracked interactions per card (server clips at 50 too).
150
180
  MAX_INTERACTIONS = 50
151
181
 
152
- # Inline approve row: emoji + tooltip per approval level (button tag =
153
- # level). Level 1 = plain approve; 2+ = loved=True on the decision, with the
182
+
183
+ def _snooze_secs():
184
+ """Snooze duration, for the title-bar button LABEL only. The menu bar owns
185
+ the actual timer (its REVIEW_SNOOZE_SECONDS reads the same env var)."""
186
+ try:
187
+ return max(60, int(float(os.environ.get("S4L_REVIEW_SNOOZE_S", "3600"))))
188
+ except Exception:
189
+ return 3600
190
+
191
+
192
+ def _snooze_title():
193
+ s = _snooze_secs()
194
+ return f"Snooze {s // 3600}h" if s % 3600 == 0 else f"Snooze {s // 60}m"
195
+
196
+ # Inline approve row: glyph + tooltip per approval level (button tag =
197
+ # level). Level 1 = plain approve; 2 = loved=True on the decision, with the
154
198
  # exact level shipped as an approve_level_N interaction for the feedback
155
- # digest.
199
+ # digest. Monochrome text-presentation glyphs (U+FE0E) so the buttons stay
200
+ # black-and-white in both appearances instead of rendering as color emoji.
156
201
  APPROVE_EMOJIS = (
157
- ("👍", "Approve"),
158
- ("😄", "Approve, really good pick"),
159
- ("❤️‍🔥", "Approve, best of the best"),
202
+ ("✓︎", "Approve"),
203
+ ("♥︎", "Approve, best of the best"),
160
204
  )
161
205
 
162
206
  # Review-surface state mirrored to the state dir for out-of-process observers
@@ -626,13 +670,21 @@ def _editable_scroll(frame, text=""):
626
670
 
627
671
 
628
672
  class _ReviewController(NSObject):
629
- def initWithDrafts_onDecision_onComplete_(self, drafts, on_decision, on_complete):
673
+ def initWithDrafts_onDecision_onComplete_focus_(
674
+ self, drafts, on_decision, on_complete, focus
675
+ ):
630
676
  self = objc.super(_ReviewController, self).init()
631
677
  if self is None:
632
678
  return None
633
679
  self._drafts = list(drafts)
634
680
  self._on_decision = on_decision
635
681
  self._on_complete = on_complete
682
+ # focus=True only for a USER-initiated open (the menu bar's "Review N
683
+ # pending drafts"): activate the app and seat the caret in the reply
684
+ # field. Auto-presented cards (the timer tick) must appear without
685
+ # taking keyboard focus from whatever the user is doing.
686
+ self._focus = bool(focus)
687
+ self._close_reason = None # "snooze" when the title-bar button closed us
636
688
  self._idx = 0
637
689
  self._decisions = []
638
690
  self._panel = None
@@ -776,26 +828,35 @@ class _ReviewController(NSObject):
776
828
  NSWindowStyleMaskTitled
777
829
  | NSWindowStyleMaskClosable
778
830
  | NSWindowStyleMaskUtilityWindow
831
+ | NSWindowStyleMaskNonactivatingPanel
779
832
  )
780
833
  panel = _ReviewPanel.alloc().initWithContentRect_styleMask_backing_defer_(
781
834
  frame, style, NSBackingStoreBuffered, False
782
835
  )
783
836
  panel.setLevel_(NSFloatingWindowLevel)
784
837
  panel.setFloatingPanel_(True)
785
- panel.setBecomesKeyOnlyIfNeeded_(False) # let the reply field be edited
838
+ # Key only when a text field is clicked: buttons (Approve/Reject) work
839
+ # without ever taking keyboard focus, and clicking into the reply field
840
+ # makes this nonactivating panel key without activating the app, so
841
+ # the user's frontmost app keeps its state. The old False + activate-
842
+ # on-present combination stole the keyboard mid-typing (2026-07-09
843
+ # customer complaint).
844
+ panel.setBecomesKeyOnlyIfNeeded_(True)
786
845
  panel.setHidesOnDeactivate_(False)
787
846
  panel.setReleasedWhenClosed_(False)
788
847
  panel.setDelegate_(self)
848
+ self._add_snooze_accessory(panel)
789
849
  self._panel = panel
790
850
  self._render()
791
- panel.makeKeyAndOrderFront_(None)
792
851
  panel.orderFrontRegardless()
793
852
  self._log_surface("presented")
794
- self.focusReply_(None)
795
- # App activation/key-window promotion lands on the run loop; do one
796
- # deferred pass so the first card is editable even when opened from the
797
- # status-bar timer while another app is frontmost.
798
- self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
853
+ if self._focus:
854
+ # User asked for the cards (menu item): behave like a normal open,
855
+ # activate and put the caret in the reply field. Deferred second
856
+ # pass because activation/key promotion lands on the run loop.
857
+ panel.makeKeyAndOrderFront_(None)
858
+ self.focusReply_(None)
859
+ self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
799
860
 
800
861
  def focusReply_(self, sender):
801
862
  panel = self._panel
@@ -826,6 +887,52 @@ class _ReviewController(NSObject):
826
887
  except Exception:
827
888
  pass
828
889
 
890
+ @objc.python_method
891
+ def _add_snooze_accessory(self, panel):
892
+ """"Snooze 1h" in the title bar, opposite the close cross: the explicit
893
+ "not now" affordance. It just closes the panel; the menu bar treats any
894
+ close with undecided drafts as a snooze, so the cross and this button
895
+ are the same action, this one merely says so out loud."""
896
+ if NSTitlebarAccessoryViewController is None or NSLayoutAttributeRight is None:
897
+ return
898
+ try:
899
+ btn = NSButton.alloc().initWithFrame_(NSMakeRect(0, 0, 80, 17))
900
+ btn.setTitle_(_snooze_title())
901
+ if NSBezelStyleInline is not None:
902
+ btn.setBezelStyle_(NSBezelStyleInline)
903
+ else:
904
+ btn.setBezelStyle_(NSBezelStyleRounded)
905
+ btn.setFont_(NSFont.systemFontOfSize_(10.0))
906
+ btn.setToolTip_(
907
+ "Put these drafts away for now. They stay pending and the "
908
+ "card comes back later (or sooner from the S4L menu)."
909
+ )
910
+ btn.setTarget_(self)
911
+ btn.setAction_("snoozeClicked:")
912
+ btn.sizeToFit()
913
+ bf = btn.frame()
914
+ holder = NSView.alloc().initWithFrame_(
915
+ NSMakeRect(0, 0, bf.size.width + 8, max(19, bf.size.height + 2))
916
+ )
917
+ btn.setFrameOrigin_(
918
+ (4, max(0, (holder.frame().size.height - bf.size.height) / 2.0))
919
+ )
920
+ holder.addSubview_(btn)
921
+ acc = NSTitlebarAccessoryViewController.alloc().init()
922
+ acc.setView_(holder)
923
+ acc.setLayoutAttribute_(NSLayoutAttributeRight)
924
+ panel.addTitlebarAccessoryViewController_(acc)
925
+ except Exception as e:
926
+ _log(f"snooze accessory unavailable: {e}")
927
+
928
+ def snoozeClicked_(self, sender):
929
+ self._track("snooze")
930
+ self._close_reason = "snooze"
931
+ try:
932
+ self._panel.performClose_(None)
933
+ except Exception:
934
+ self._finish()
935
+
829
936
  @objc.python_method
830
937
  def _eye_button(self, frame, kind):
831
938
  """Borderless SF-Symbol eye whose hover/click opens a popover. A plain
@@ -884,12 +991,13 @@ class _ReviewController(NSObject):
884
991
  # Bezeled, not borderless: bare emoji read as decoration and
885
992
  # users doubted the click registered (2026-07-03/04 feedback,
886
993
  # twice now — the outline is what says "button").
887
- btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42, 44, 30))
994
+ btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42, 38, 30))
888
995
  btn.setTitle_(emoji)
889
996
  btn.setBezelStyle_(NSBezelStyleRounded)
890
- # 13pt = the size Reject's title renders at; anything bigger
891
- # overflows the rounded bezel's ~22px content lane (16pt did).
892
- btn.setFont_(NSFont.systemFontOfSize_(13))
997
+ # 16pt fits the rounded bezel's ~22px content lane for these
998
+ # text-presentation glyphs (the old color emoji clipped at 16pt,
999
+ # which is why this used to be 13pt).
1000
+ btn.setFont_(NSFont.systemFontOfSize_(16))
893
1001
  btn.setTag_(i + 1) # tag = approval level
894
1002
  btn.setTarget_(self)
895
1003
  btn.setAction_("approve:")
@@ -898,7 +1006,7 @@ class _ReviewController(NSObject):
898
1006
  except Exception:
899
1007
  pass
900
1008
  content.addSubview_(btn)
901
- x += 48
1009
+ x += 42
902
1010
 
903
1011
  reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
904
1012
  reject.setTitle_("Reject")
@@ -1239,9 +1347,15 @@ class _ReviewController(NSObject):
1239
1347
  )
1240
1348
  # setContentView_ rebuilds the view tree, so the caret would otherwise
1241
1349
  # default to the Approve button. Re-seat it in the reply field for every
1242
- # card (not just the first) so each one is immediately editable.
1243
- self._panel.makeFirstResponder_(tv)
1244
- self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
1350
+ # card the user is ACTIVELY reviewing (they opened the stack from the
1351
+ # menu, or already decided/touched something), so each one is
1352
+ # immediately editable. NOT on the initial auto-presented render:
1353
+ # focusReply_ activates the app, and yanking the caret out of whatever
1354
+ # the user was typing was the 2026-07-09 "too distracting" complaint.
1355
+ # An untouched card's field becomes editable on first click instead.
1356
+ if self._focus or self._decisions or self._last_interaction_at is not None:
1357
+ self._panel.makeFirstResponder_(tv)
1358
+ self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
1245
1359
 
1246
1360
  @objc.python_method
1247
1361
  def _close_stats_popover(self):
@@ -1600,6 +1714,37 @@ class _ReviewController(NSObject):
1600
1714
  self._log_surface(f"extended +{len(added)}")
1601
1715
  return len(added)
1602
1716
 
1717
+ @objc.python_method
1718
+ def prune_drafts(self, ns):
1719
+ """Drop not-yet-reached drafts (plan index `n` in `ns`) from the stack,
1720
+ e.g. a card the backend already retired (expired freshness gate, etc.)
1721
+ while it was still waiting to be shown. Only ever removes entries AFTER
1722
+ the current index, so the card on screen right now and everything
1723
+ already decided are untouched -- nothing visible disappears out from
1724
+ under the user. Refreshes the title-bar counter live. Returns the
1725
+ count actually removed."""
1726
+ if self._panel is None or not ns:
1727
+ return 0
1728
+ ns = set(ns)
1729
+ kept = []
1730
+ removed = 0
1731
+ for i, d in enumerate(self._drafts):
1732
+ if i > self._idx and d.get("n") in ns:
1733
+ removed += 1
1734
+ continue
1735
+ kept.append(d)
1736
+ if not removed:
1737
+ return 0
1738
+ self._drafts = kept
1739
+ try:
1740
+ self._panel.setTitle_(
1741
+ f"s4l · Review draft {self._idx + 1} of {len(self._drafts)}"
1742
+ )
1743
+ except Exception:
1744
+ pass
1745
+ self._log_surface(f"pruned {removed} expired")
1746
+ return removed
1747
+
1603
1748
  @objc.python_method
1604
1749
  def _fire_decision(self):
1605
1750
  # Fire the per-card callback the instant a decision is made, so an
@@ -1761,7 +1906,10 @@ class _ReviewController(NSObject):
1761
1906
 
1762
1907
  def windowShouldClose_(self, sender):
1763
1908
  # Closing the window stops review; remaining cards are left undecided
1764
- # (not posted). Finish with whatever was decided so far.
1909
+ # (not posted). Finish with whatever was decided so far. The menu bar
1910
+ # treats a close with undecided drafts as SNOOZE (they re-present after
1911
+ # REVIEW_SNOOZE_SECONDS, or sooner via the menu), whether it came from
1912
+ # the cross or the title-bar Snooze button.
1765
1913
  self._finish()
1766
1914
  return True
1767
1915
 
@@ -1783,11 +1931,12 @@ class _ReviewController(NSObject):
1783
1931
  except Exception:
1784
1932
  pass
1785
1933
  _active = None
1786
- _log(f"closed: {len(self._decisions)} decided of {len(self._drafts)}")
1787
- _write_review_state(last_event="closed")
1934
+ reason = self._close_reason or "closed"
1935
+ _log(f"closed: {len(self._decisions)} decided of {len(self._drafts)} ({reason})")
1936
+ _write_review_state(last_event="snoozed" if reason == "snooze" else "closed")
1788
1937
 
1789
1938
 
1790
- def present_review(drafts, on_decision=None, on_complete=None):
1939
+ def present_review(drafts, on_decision=None, on_complete=None, focus=False):
1791
1940
  """Show the review cards (main thread only). drafts: list of
1792
1941
  {n, thread_author, thread_text, reply_text, link_url, thread_url?, stats?}
1793
1942
  where stats is the discovery-time candidate snapshot
@@ -1809,14 +1958,17 @@ def present_review(drafts, on_decision=None, on_complete=None):
1809
1958
  active experiment/scenario arm.
1810
1959
  on_decision(decision) fires the instant each card is approved/rejected (so an
1811
1960
  approved draft posts right away); on_complete(decisions) fires when the user
1812
- finishes the last card or closes the window. Both run on the main thread."""
1961
+ finishes the last card or closes the window. Both run on the main thread.
1962
+ focus=True (user-initiated open, e.g. the menu bar's "Review N pending
1963
+ drafts") activates the app and seats the caret in the reply field; the
1964
+ default False shows the card without taking keyboard focus."""
1813
1965
  global _active
1814
1966
  if not drafts:
1815
1967
  if on_complete is not None:
1816
1968
  on_complete([])
1817
1969
  return
1818
- _active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_(
1819
- drafts, on_decision, on_complete
1970
+ _active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_focus_(
1971
+ drafts, on_decision, on_complete, focus
1820
1972
  )
1821
1973
 
1822
1974
 
@@ -1832,6 +1984,22 @@ def extend_active(drafts):
1832
1984
  return 0
1833
1985
 
1834
1986
 
1987
+ def prune_active(ns):
1988
+ """Remove not-yet-reached drafts (by plan index `n`) from the open review
1989
+ card, if one is up -- e.g. a card that expired on the backend mid-review
1990
+ (see merge_review_queue.py's backend sync). Never touches the card
1991
+ currently on screen or any already-decided one, so nothing visible is
1992
+ yanked out from under the user. Returns the count actually removed (0 if
1993
+ no card is open or none of `ns` are still ahead in the stack). Main thread
1994
+ only (called from the menu bar's rumps timer)."""
1995
+ if _active is None or not ns:
1996
+ return 0
1997
+ try:
1998
+ return _active.prune_drafts(ns)
1999
+ except Exception:
2000
+ return 0
2001
+
2002
+
1835
2003
  def active_status():
1836
2004
  """Live review-surface snapshot for the menu bar's unattended-review
1837
2005
  watchdog, or None when no card is open. Main thread only."""
@@ -1913,6 +2081,20 @@ def heal_active():
1913
2081
  return False
1914
2082
 
1915
2083
 
2084
+ def focus_active():
2085
+ """Bring the open card to the user deliberately (menu 'Review pending
2086
+ drafts' while a card is already up): activate + caret in the reply field.
2087
+ Main thread only. Returns True if a card was focused."""
2088
+ c = _active
2089
+ if c is None or c._panel is None:
2090
+ return False
2091
+ try:
2092
+ c.focusReply_(None)
2093
+ return True
2094
+ except Exception:
2095
+ return False
2096
+
2097
+
1916
2098
  # ---- overall-feedback composer ----------------------------------------------
1917
2099
  # One small floating panel with a free-text field, for guidance that is about
1918
2100
  # the PIPELINE rather than any single draft ("less shilling", "more dev
@@ -439,6 +439,17 @@ REVIEW_HEAL_EVERY_SECONDS = float(
439
439
  )
440
440
  REVIEW_UNATTENDED_SENTRY_SECONDS = 3600.0
441
441
 
442
+ # Review snooze (2026-07-09 customer feedback: cards interrupt focused work
443
+ # with no way to defer them). Closing the card with undecided drafts, via the
444
+ # cross or the title-bar "Snooze 1h" button, parks the WHOLE pending backlog
445
+ # for REVIEW_SNOOZE_SECONDS: no auto-present, no watchdog heal (nothing is on
446
+ # screen). Drafts stay in the store untouched; the menu's "Review N pending
447
+ # drafts" clears the snooze early. Persisted so a menubar restart mid-snooze
448
+ # doesn't re-pop the card. s4l_card._snooze_secs reads the same env var for
449
+ # the button label; keep them in sync.
450
+ REVIEW_SNOOZE_SECONDS = float(os.environ.get("S4L_REVIEW_SNOOZE_S", "3600"))
451
+ SNOOZE_FILE = os.path.join(st.state_dir(), "review-snooze.json")
452
+
442
453
 
443
454
  def _label_elapsed_secs(label):
444
455
  """Parse the trailing duration the producer encodes in a drafting activity
@@ -519,6 +530,10 @@ class S4LMenuBar(rumps.App):
519
530
  self._post_worker = None
520
531
  self._review_lock = threading.Lock()
521
532
  self._panel_open = False
533
+ # Review snooze: epoch until which draft cards must not auto-present
534
+ # (0 = not snoozed). Loaded from disk so a menubar restart mid-snooze
535
+ # doesn't re-pop the card the user just put away.
536
+ self._review_snooze_until = self._read_snooze_until()
522
537
  # Unattended-review watchdog state (_maybe_heal_review).
523
538
  self._review_heal_at = 0.0
524
539
  self._review_unattended_notified = False
@@ -645,6 +660,11 @@ class S4LMenuBar(rumps.App):
645
660
  # Cached schedule state for the current account: 'missing'/'disabled'/'ok'/
646
661
  # 'unknown'. PRIMARY driver of the menu's attention section.
647
662
  self._schedule_state_cache = "ok"
663
+ # Tick-freshness diagnostics (schedule_state.tick_stats()) shown as the
664
+ # non-alarm "scheduler degraded" menu line while state == 'stalled'.
665
+ # None outside that state; refreshed at most once a minute (_tick).
666
+ self._tick_stats = None
667
+ self._tick_stats_at = 0.0
648
668
  self._reloc_timer = rumps.Timer(self._maybe_relocate_tasks, 90)
649
669
  self._reloc_timer.start()
650
670
  self._tick(None)
@@ -874,11 +894,15 @@ class S4LMenuBar(rumps.App):
874
894
  does NOT stay stale after recovery.
875
895
 
876
896
  NOTE: kept in sync with scripts/autopilot_stall_watch.py (the fleet Sentry
877
- backstop). The menu-bar itself is driven by _schedule_state, NOT this
878
- method the attention/⚠ path keys off schedule_state so a firing-but-
879
- momentarily-empty queue stays green (an earlier drain-latch ⚠ stayed stale
880
- after recovery and was deliberately removed). This method exists for the
881
- watcher-parity contract and _stall_reason.
897
+ backstop). Since 2026-07-09 this method IS an attention/⚠ driver: _tick
898
+ ORs it in (with the reason from _stall_reason) alongside the structural
899
+ schedule states (missing/disabled) and the activity-label draft_stuck
900
+ check. A firing-but-momentarily-empty queue still stays green: an idle
901
+ queue has no pending job, running/ is empty, and the drain latch zeroes
902
+ on every successful consume (claude_job._mark_drain_success), so none of
903
+ the three signals can hold a stale True after recovery. Tick-freshness
904
+ 'stalled' from schedule_state no longer flips the ⚠ at all — it renders
905
+ as a plain diagnostic line (see _build_menu).
882
906
  """
883
907
  qroot = os.path.join(st.state_dir(), "claude-queue")
884
908
  # (1) latched producer drain-status
@@ -2320,34 +2344,38 @@ class S4LMenuBar(rumps.App):
2320
2344
  blocker = (ob or {}).get("current_blocker")
2321
2345
  blocker_code = (blocker or {}).get("code")
2322
2346
  # --- Autopilot health (only meaningful once setup is complete) --------
2323
- # SINGLE signal: is the draft schedule registered AND firing for the live
2324
- # account (schedule_state)? 'ok' = the host is running the tasks -> healthy,
2325
- # NO warning (even if no draft has drained yet — that's just an empty queue
2326
- # between cycles, not a setup problem). 'missing'/'disabled' = not running
2327
- # for this account -> show re-arm. We deliberately do NOT drive the menu off
2328
- # the drain-status latch anymore: it stayed stale after recovery and made a
2329
- # firing, healthy autopilot look "not set up".
2347
+ # TWO layers (2026-07-09 redesign):
2348
+ # STRUCTURAL (schedule_state): 'missing'/'disabled' = the schedule is
2349
+ # genuinely not registered/enabled for this account -> + re-arm.
2350
+ # JOB LATENCY (_autopilot_stalled + the draft_stuck label check below):
2351
+ # a draft job sat unclaimed, wedged mid-run, or the producer's drain
2352
+ # latch tripped -> ⚠, because THAT is user-visible harm.
2353
+ # Tick freshness ('stalled': task present + enabled but lastRunAt stale,
2354
+ # the Desktop warm-session wedge) is DIAGNOSTIC ONLY, never the ⚠: under
2355
+ # the wedge the per-minute tick can skip 70-90% of fires while the queue
2356
+ # still drains every job (jobs arrive ~every 8 min vs 60 fires/hr), so
2357
+ # lastRunAt staleness flip-flopped the icon against a healthy pipeline
2358
+ # (observed on the operator box 2026-07-09: 83% ticks skipped, ~14
2359
+ # posts/hr flowing, icon oscillating). _build_menu renders 'stalled' as
2360
+ # a plain "scheduler degraded" detail line with tick_stats() numbers.
2361
+ # The drain latch IS safe to alarm on now: claude_job._mark_drain_success
2362
+ # zeroes it on every successful consume, so it self-clears (the old
2363
+ # stayed-stale-after-recovery latch this comment used to warn about
2364
+ # predates that reset).
2330
2365
  # Always read the REAL schedule state (no setup-gated "ok" fallback that
2331
2366
  # lied). The re-arm WARNING still only fires once setup is complete, so we
2332
2367
  # never nag the user mid-onboarding — only the value is now always honest.
2333
2368
  schedule_state = self._schedule_state()
2334
2369
  self._schedule_state_cache = schedule_state
2335
- # 'stalled' (task present + enabled FOR THE ACTIVE ACCOUNT, host
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")
2370
+ attention = setup_complete and schedule_state in ("missing", "disabled")
2344
2371
  # Routines-lane rate limit (429): the draft tasks ARE registered and firing
2345
2372
  # for this account, but every run dies on a Claude rate limit, so nothing
2346
2373
  # drafts. Re-arm can't fix that — surface it as its own ⚠ attention state
2347
- # with a "rate-limited" reason. Only meaningful when the schedule is firing
2348
- # ('ok'); the missing/disabled case already owns the ⚠. Throttled (~30s):
2349
- # scanning the worker-transcript bucket is glob-heavy and changes slowly.
2350
- if setup_complete and schedule_state == "ok":
2374
+ # with a "rate-limited" reason. Meaningful whenever the schedule exists
2375
+ # ('ok' or the degraded-but-firing 'stalled'); the missing/disabled case
2376
+ # already owns the ⚠. Throttled (~30s): scanning the worker-transcript
2377
+ # bucket is glob-heavy and changes slowly.
2378
+ if setup_complete and schedule_state in ("ok", "stalled"):
2351
2379
  now_rl = time.time()
2352
2380
  if now_rl - getattr(self, "_rl_checked_at", 0.0) >= 30:
2353
2381
  self._rl_checked_at = now_rl
@@ -2371,12 +2399,10 @@ class S4LMenuBar(rumps.App):
2371
2399
  # draft_stuck shadowed the missing/disabled branch in _build_menu — the user
2372
2400
  # saw "worker keeps getting killed" with NO Re-arm button instead of "Draft
2373
2401
  # 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
- # branch's fix (Set up draft schedule / re-arm) works for it too, but a
2376
- # job that has sat this long claimed-and-hung, OR never claimed at all
2377
- # (see the prefix check below) is more specific, direct evidence
2378
- # from the queue itself than the host's lastRunAt staleness, so it's
2379
- # worth surfacing as its own reason even under "stalled".
2402
+ # 2026-07-06). "stalled" is deliberately included (2026-07-08): a job that
2403
+ # has sat this long claimed-and-hung, OR never claimed at all (see the
2404
+ # prefix check below) is direct evidence from the queue itself, exactly
2405
+ # the layer the keys off now that tick staleness alone is diagnostic.
2380
2406
  if (
2381
2407
  setup_complete
2382
2408
  and schedule_state in ("ok", "stalled")
@@ -2390,6 +2416,44 @@ class S4LMenuBar(rumps.App):
2390
2416
  ):
2391
2417
  attention = True
2392
2418
  self._stall_reason_info = ("draft_stuck", _act.get("label") or "")
2419
+ # Queue-latency stall (the layer that measures actual user harm): a draft
2420
+ # job sat unclaimed past AUTOPILOT_STALL_SECONDS, wedged in running/ past
2421
+ # AUTOPILOT_RUNNING_STALL_SECONDS, or the producer's drain latch tripped
2422
+ # (consecutive timeouts, zeroed on every successful consume). The activity-
2423
+ # label draft_stuck check above needs a live producer narrating "drafting";
2424
+ # this one works between cycles too (the latch persists the episode), so
2425
+ # together they cover producer-alive and producer-gone stalls. Reason
2426
+ # refines the one-click fix: 'orphaned' (no routine executed recently,
2427
+ # re-arm helps) vs 'failing' (routines run but drafts die, Diagnose).
2428
+ if (
2429
+ setup_complete
2430
+ and schedule_state in ("ok", "stalled")
2431
+ and self._stall_reason_info[0] not in ("rate_limited", "draft_stuck")
2432
+ ):
2433
+ if self._autopilot_stalled():
2434
+ _qreason, _qmsg = self._stall_reason()
2435
+ attention = True
2436
+ self._stall_reason_info = (_qreason, _qmsg)
2437
+ elif self._stall_reason_info[0] in ("orphaned", "failing"):
2438
+ # Queue recovered since the last tick: clear the stale reason
2439
+ # now instead of waiting for the 30s rate-limit rescan to
2440
+ # overwrite it (a lingering reason skews the diagnose prompt
2441
+ # and churns the menu signature for nothing).
2442
+ self._stall_reason_info = ("", "")
2443
+ # Tick-freshness diagnostics for the non-alarm "scheduler degraded" menu
2444
+ # line. Only computed while 'stalled' (it reads + parses the registry
2445
+ # JSON, which can be hundreds of KB) and throttled to once a minute.
2446
+ if schedule_state == "stalled":
2447
+ _now_ts = time.time()
2448
+ if _now_ts - getattr(self, "_tick_stats_at", 0.0) >= 60:
2449
+ self._tick_stats_at = _now_ts
2450
+ try:
2451
+ import schedule_state as _ss
2452
+ self._tick_stats = _ss.tick_stats()
2453
+ except Exception:
2454
+ self._tick_stats = None
2455
+ else:
2456
+ self._tick_stats = None
2393
2457
  # Drop the stale "drafting" spinner while we need attention so the ⚠ shows.
2394
2458
  self._stalled = attention
2395
2459
 
@@ -2415,7 +2479,7 @@ class S4LMenuBar(rumps.App):
2415
2479
  # Once per episode (gated by _stall_notified), so it never spams.
2416
2480
  _reason = (
2417
2481
  self._stall_reason_info[0]
2418
- or (schedule_state if schedule_state in ("disabled", "stalled") else "missing")
2482
+ or ("disabled" if schedule_state == "disabled" else "missing")
2419
2483
  )
2420
2484
  _capture_msg(
2421
2485
  f"S4L draft autopilot needs attention: {_reason}",
@@ -2447,19 +2511,26 @@ class S4LMenuBar(rumps.App):
2447
2511
  "A worker claimed a draft job and never finished it. ")
2448
2512
  + "Open the S4L menu → “Diagnose & fix in Claude…”.",
2449
2513
  )
2514
+ elif self._stall_reason_info[0] == "orphaned":
2515
+ self._notify(
2516
+ "S4L drafts not draining",
2517
+ "Draft jobs are stuck in the queue and no routine has picked "
2518
+ "them up. Open the S4L menu, then click "
2519
+ "“Set up draft schedule”.",
2520
+ )
2521
+ elif self._stall_reason_info[0] == "failing":
2522
+ self._notify(
2523
+ "S4L drafts not draining",
2524
+ "Draft jobs are stuck in the queue; runs start but never "
2525
+ "finish. Open the S4L menu, then click "
2526
+ "“Diagnose & fix in Claude…”.",
2527
+ )
2450
2528
  elif schedule_state == "disabled":
2451
2529
  self._notify(
2452
2530
  "S4L draft tasks disabled",
2453
2531
  "The draft tasks are scheduled but disabled. Open the S4L menu → "
2454
2532
  "“Set up draft schedule” to re-enable.",
2455
2533
  )
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
2534
  else:
2464
2535
  can_selfheal = False
2465
2536
  try:
@@ -2527,8 +2598,20 @@ class S4LMenuBar(rumps.App):
2527
2598
  attention,
2528
2599
  schedule_state,
2529
2600
  self._stall_reason_info,
2601
+ # Degraded-scheduler skip count, bucketed by 5 so the diagnostic
2602
+ # line refreshes every few minutes at most (not every poll — an
2603
+ # open menu shouldn't be torn down under the cursor for a +1).
2604
+ ((self._tick_stats or {}).get("skips_in_window", 0) // 5)
2605
+ if self._tick_stats
2606
+ else None,
2530
2607
  os.path.exists(PAUSE_FLAG),
2531
2608
  pending_count,
2609
+ # Snooze end (0 = not snoozed): rebuilds the menu when a snooze is
2610
+ # set, cleared, or lapses, so the "Snoozed until HH:MM" label and
2611
+ # the review item stay honest.
2612
+ int(self._review_snooze_until)
2613
+ if time.time() < self._review_snooze_until
2614
+ else 0,
2532
2615
  )
2533
2616
  if sig != self._sig:
2534
2617
  self._sig = sig
@@ -2602,7 +2685,51 @@ class S4LMenuBar(rumps.App):
2602
2685
  except Exception:
2603
2686
  return None, []
2604
2687
 
2605
- def _maybe_start_review(self):
2688
+ def _read_snooze_until(self):
2689
+ try:
2690
+ with open(SNOOZE_FILE) as f:
2691
+ return float((json.load(f) or {}).get("until") or 0.0)
2692
+ except Exception:
2693
+ return 0.0
2694
+
2695
+ def _set_snooze(self, until, pending=0):
2696
+ """Set (or clear, until<=now) the review snooze, mirrored to disk."""
2697
+ self._review_snooze_until = until
2698
+ try:
2699
+ if until <= time.time():
2700
+ try:
2701
+ os.remove(SNOOZE_FILE)
2702
+ except FileNotFoundError:
2703
+ pass
2704
+ else:
2705
+ with open(SNOOZE_FILE, "w") as f:
2706
+ json.dump(
2707
+ {"until": until, "set_at": time.time(), "pending": pending},
2708
+ f,
2709
+ )
2710
+ except Exception:
2711
+ pass
2712
+
2713
+ def _review_now(self, _=None):
2714
+ """Menu: bring the pending draft cards up right now. Clears any snooze;
2715
+ this is the one presentation path that may take keyboard focus (the
2716
+ user explicitly asked for the cards)."""
2717
+ self._set_snooze(0.0)
2718
+ try:
2719
+ import s4l_card
2720
+
2721
+ if s4l_card.active_status():
2722
+ # A card is already on screen (user clicked while one is up,
2723
+ # perhaps parked on another display): bring it to them instead.
2724
+ s4l_card.heal_active()
2725
+ s4l_card.focus_active()
2726
+ return
2727
+ except Exception:
2728
+ pass
2729
+ self._last_review_sig = None
2730
+ self._maybe_start_review(focus=True)
2731
+
2732
+ def _maybe_start_review(self, focus=False):
2606
2733
  req = st.read_review_request()
2607
2734
  if not req:
2608
2735
  return
@@ -2618,6 +2745,12 @@ class S4LMenuBar(rumps.App):
2618
2745
  self._last_review_sig = None
2619
2746
  st.clear_review_request()
2620
2747
  return
2748
+ # Snoozed (the user closed the card with drafts still undecided): leave
2749
+ # the backlog in the store untouched and present nothing until the
2750
+ # snooze lapses. New drafts keep accumulating silently; the menu's
2751
+ # "Review N pending drafts" clears this early.
2752
+ if time.time() < self._review_snooze_until:
2753
+ return
2621
2754
  # De-dup on the CONTENT of the pending set (each draft's plan index + reply
2622
2755
  # text), not the constant batch_id. This means: re-present whenever NEW
2623
2756
  # drafts arrive (the signature changes), but don't re-pop the identical
@@ -2632,6 +2765,13 @@ class S4LMenuBar(rumps.App):
2632
2765
  # live. This is the fix for the "card froze at 1 of 4 while 137 piled
2633
2766
  # up" bug — drafts that arrived after the card opened used to be
2634
2767
  # stranded because this method returned early on _review_active.
2768
+ # Also prune the other direction: any `n` this same stack used to
2769
+ # carry but that dropped out of the fresh `drafts` list (merge_review_
2770
+ # queue.py's backend sync just marked it terminal, most commonly the
2771
+ # freshness gate expiring it) is removed from the not-yet-reached part
2772
+ # of the stack, so an old card can't sit there waiting to be approved
2773
+ # into a silent no-op (see the 2026-07-09 "approved 3, nothing
2774
+ # posted" investigation).
2635
2775
  # - Posting is DRAINING with no panel up (_review_active but not
2636
2776
  # _panel_open): leave the signature untouched so the full pending set
2637
2777
  # is presented fresh once the drain completes (don't pop a card mid-post).
@@ -2640,6 +2780,11 @@ class S4LMenuBar(rumps.App):
2640
2780
  try:
2641
2781
  import s4l_card
2642
2782
 
2783
+ prev_ns = {n for n, _ in (self._last_review_sig or ())}
2784
+ cur_ns = {d.get("n") for d in drafts}
2785
+ vanished = prev_ns - cur_ns
2786
+ if vanished:
2787
+ s4l_card.prune_active(vanished)
2643
2788
  s4l_card.extend_active(drafts)
2644
2789
  except Exception as e:
2645
2790
  sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
@@ -2661,6 +2806,7 @@ class S4LMenuBar(rumps.App):
2661
2806
  drafts,
2662
2807
  on_decision=lambda d: self._on_card_decision(batch, d),
2663
2808
  on_complete=lambda decisions: self._on_review_closed(batch, decisions),
2809
+ focus=focus,
2664
2810
  )
2665
2811
  # Record as shown only AFTER the cards are actually up, so a transient
2666
2812
  # card-UI failure never permanently suppresses this pending set.
@@ -2886,6 +3032,20 @@ class S4LMenuBar(rumps.App):
2886
3032
  remaining = 0
2887
3033
  if remaining <= 0:
2888
3034
  st.clear_review_request()
3035
+ else:
3036
+ # Undecided drafts left = the user closed the card early (cross or
3037
+ # the title-bar Snooze button): park the backlog instead of
3038
+ # re-popping it on the next tick, which made the card impossible
3039
+ # to put away (2026-07-09 customer feedback). The signature drop
3040
+ # below still runs, so after the snooze lapses the leftover
3041
+ # presents fresh rather than being suppressed as "already shown".
3042
+ until = time.time() + REVIEW_SNOOZE_SECONDS
3043
+ self._set_snooze(until, remaining)
3044
+ sys.stderr.write(
3045
+ f"[s4l-menubar] review snoozed: {remaining} pending until "
3046
+ f"{time.strftime('%H:%M', time.localtime(until))}\n"
3047
+ )
3048
+ sys.stderr.flush()
2889
3049
  # Drop the dedup signature so whatever is left is presented fresh (not
2890
3050
  # suppressed as "already shown") once posting finishes draining.
2891
3051
  self._last_review_sig = None
@@ -2895,7 +3055,15 @@ class S4LMenuBar(rumps.App):
2895
3055
  st.flush_review_events_async()
2896
3056
  except Exception:
2897
3057
  pass
2898
- if not any(d.get("approved") for d in decisions):
3058
+ if remaining > 0:
3059
+ plural = "s" if remaining != 1 else ""
3060
+ self._notify(
3061
+ "S4L",
3062
+ f"Snoozed {remaining} pending draft{plural} until "
3063
+ f"{time.strftime('%H:%M', time.localtime(self._review_snooze_until))}. "
3064
+ "Review sooner from the S4L menu.",
3065
+ )
3066
+ elif not any(d.get("approved") for d in decisions):
2899
3067
  self._notify("S4L", "No drafts approved — nothing posted.")
2900
3068
 
2901
3069
  def _discard_all_pending(self, _=None):
@@ -3123,26 +3291,21 @@ class S4LMenuBar(rumps.App):
3123
3291
  " " + (self._stall_reason_info[1] or "drafting") + " — no result yet"
3124
3292
  ))
3125
3293
  items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
3294
+ elif self._stall_reason_info[0] == "orphaned":
3295
+ # Queue-latency stall, no routine executing (no recent worker
3296
+ # transcript): draft jobs sit unclaimed. Re-arm re-registers the
3297
+ # schedule through the real create_scheduled_task path, the one
3298
+ # mechanical fix that addresses "nothing is picking jobs up".
3299
+ items.append(self._label("⚠ Draft jobs stuck, no routine picking them up"))
3300
+ items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
3301
+ elif self._stall_reason_info[0] == "failing":
3302
+ # Queue-latency stall but routines DO execute: runs start and
3303
+ # die before draining. Re-arm can't fix that; Diagnose can.
3304
+ items.append(self._label("⚠ Draft jobs stuck, runs start but don't finish"))
3305
+ items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
3126
3306
  elif schedule_state == "disabled":
3127
3307
  items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
3128
3308
  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
3309
  else:
3147
3310
  items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
3148
3311
  # Prefer the automatic fix (2026-07-08): if the active account
@@ -3172,6 +3335,29 @@ class S4LMenuBar(rumps.App):
3172
3335
  else:
3173
3336
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
3174
3337
  items.append(rumps.separator)
3338
+ elif setup_complete and schedule_state == "stalled":
3339
+ # NON-ALARM diagnostic (2026-07-09): the task is registered + enabled
3340
+ # for the active account but the host's per-minute tick is stale or
3341
+ # mostly skipped (Desktop warm-session wedge). The queue checks above
3342
+ # would have flipped ⚠ if jobs were actually stuck, so reaching here
3343
+ # means drafts still drain; say so with numbers instead of alarming.
3344
+ # tick_stats is cached by _tick (refreshed ≤ once/min, None outside
3345
+ # 'stalled').
3346
+ _ts = self._tick_stats or {}
3347
+ _skips = _ts.get("skips_in_window")
3348
+ _age = _ts.get("last_run_age_s")
3349
+ _bits = []
3350
+ if _skips is not None:
3351
+ _bits.append(f"{_skips} of ~60 ticks skipped last hour")
3352
+ if _age is not None:
3353
+ _bits.append(f"last run {max(0, int(_age)) // 60}m ago")
3354
+ items.append(self._label("Scheduler degraded (drafts still running)"))
3355
+ if _bits:
3356
+ items.append(self._label(" " + ", ".join(_bits)))
3357
+ # Keep the one-click remedy reachable without dressing it as urgent:
3358
+ # re-registering through create_scheduled_task un-wedges the host.
3359
+ items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
3360
+ items.append(rumps.separator)
3175
3361
 
3176
3362
  if not runtime_ready:
3177
3363
  items += self._state_a()
@@ -3329,12 +3515,29 @@ class S4LMenuBar(rumps.App):
3329
3515
  def _state_c(self, snap, pending_count=0):
3330
3516
  if pending_count <= 0:
3331
3517
  return []
3332
- return [
3518
+ plural = "s" if pending_count != 1 else ""
3519
+ items = [
3333
3520
  rumps.MenuItem(
3334
- f"Discard {pending_count} pending draft{'s' if pending_count != 1 else ''}",
3335
- callback=self._discard_all_pending,
3521
+ f"Review {pending_count} pending draft{plural}",
3522
+ callback=self._review_now,
3336
3523
  )
3337
3524
  ]
3525
+ if time.time() < self._review_snooze_until:
3526
+ items.append(
3527
+ self._label(
3528
+ "Snoozed until "
3529
+ + time.strftime(
3530
+ "%H:%M", time.localtime(self._review_snooze_until)
3531
+ )
3532
+ )
3533
+ )
3534
+ items.append(
3535
+ rumps.MenuItem(
3536
+ f"Discard {pending_count} pending draft{plural}…",
3537
+ callback=self._discard_all_pending,
3538
+ )
3539
+ )
3540
+ return items
3338
3541
 
3339
3542
 
3340
3543
  if __name__ == "__main__":
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.2-rc.9",
3
+ "version": "1.7.2",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.7.2-rc.9",
3
+ "version": "1.7.2",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -0,0 +1,91 @@
1
+ import json, re, urllib.parse
2
+
3
+ NOTIFS = "/tmp/li_notifs.json"
4
+ CID = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/brzcccx3h.txt"
5
+ PAIRS = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/bu0y0bphg.txt"
6
+ POSTS = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/bnb5ginhc.txt"
7
+
8
+ def load_lines(p):
9
+ out=[]
10
+ for ln in open(p, encoding="utf-8", errors="replace"):
11
+ ln=ln.rstrip("\n")
12
+ # skip persisted-output wrapper lines
13
+ if not ln or ln.startswith("<") or ln.startswith("Output too large") or ln.startswith("Preview") or ln.startswith("..."):
14
+ continue
15
+ out.append(ln)
16
+ return out
17
+
18
+ comment_ids = set(load_lines(CID))
19
+ pairs = set(load_lines(PAIRS))
20
+ posts = load_lines(POSTS)
21
+
22
+ # activity number -> post id
23
+ post_by_act = {}
24
+ for ln in posts:
25
+ if "|" not in ln: continue
26
+ pid, url = ln.split("|",1)
27
+ for m in re.findall(r"urn:li:(?:activity|share|ugcPost):(\d+)", url):
28
+ post_by_act[m]=pid
29
+
30
+ # config projects search_topics
31
+ cfg = json.load(open("/Users/matthewdi/social-autoposter/config.json"))
32
+ projtopics = []
33
+ for pr in cfg.get("projects",[]):
34
+ projtopics.append((pr["name"], [t.lower() for t in pr.get("search_topics",[])]))
35
+
36
+ EXCLUDED = {"louis030195","louis3195"}
37
+ OWN = {"matthew diakonov","m13v"}
38
+
39
+ def match_project(snippet):
40
+ s = snippet.lower()
41
+ best=None; bestscore=0
42
+ for name, topics in projtopics:
43
+ score=0
44
+ for t in topics:
45
+ if t and t in s:
46
+ score+=1
47
+ if score>bestscore:
48
+ bestscore=score; best=name
49
+ return best or "S4L"
50
+
51
+ data = json.load(open(NOTIFS))
52
+ decisions=[]
53
+ for d in data:
54
+ href = d["href"]
55
+ dec = urllib.parse.unquote(href)
56
+ m = re.search(r"urn:li:activity:(\d+)", dec)
57
+ activity_id = m.group(1) if m else None
58
+ comment_urn = d["comment_urn"]
59
+ author = d["author"]
60
+ a_low = author.lower().strip()
61
+ rec = dict(author=author, activity_id=activity_id, comment_urn=comment_urn,
62
+ snippet=d["snippet"], href=href, type=d["type"])
63
+ if not comment_urn or not activity_id:
64
+ rec["decision"]="no_comment_urn"; decisions.append(rec); continue
65
+ if a_low in OWN:
66
+ rec["decision"]="own_account"; decisions.append(rec); continue
67
+ if a_low in EXCLUDED or any(x in a_low for x in EXCLUDED):
68
+ rec["decision"]="excluded_author"; decisions.append(rec); continue
69
+ if comment_urn in comment_ids:
70
+ rec["decision"]="already_tracked"; decisions.append(rec); continue
71
+ our_url = "https://www.linkedin.com/feed/update/urn:li:activity:%s/" % activity_id
72
+ apk = author + "|||" + our_url
73
+ if apk in pairs:
74
+ rec["decision"]="author_already_engaged"; decisions.append(rec); continue
75
+ # match post
76
+ pid = post_by_act.get(activity_id)
77
+ rec["post_id"]=pid
78
+ rec["project"]=match_project(d["snippet"])
79
+ rec["decision"]="INSERT"
80
+ decisions.append(rec)
81
+
82
+ json.dump(decisions, open("/tmp/li_decisions.json","w"))
83
+ # summary
84
+ from collections import Counter
85
+ c=Counter(r["decision"] for r in decisions)
86
+ print("TOTAL", len(decisions))
87
+ for k,v in c.items(): print(k,v)
88
+ print("---INSERTS---")
89
+ for i,r in enumerate(decisions):
90
+ if r["decision"]=="INSERT":
91
+ print(i, r["author"], "| act", r["activity_id"], "| post", r.get("post_id"), "| proj", r["project"])
@@ -203,7 +203,7 @@ CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
203
203
  NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(no_reason)} of the rejects without a stated reason, {len(approved)} approved, {len(loved)} of the approvals loved):
204
204
  {ev_lines}{overall_block}
205
205
 
206
- Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; when 2+ edits show the same correction (a phrase type removed, a structure replaced, tone shifted, length cut), distill that recurring pattern into draft_style_notes. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked a stronger emoji in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 3 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
206
+ Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; when 2+ edits show the same correction (a phrase type removed, a structure replaced, tone shifted, length cut), distill that recurring pattern into draft_style_notes. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked the heart in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 2 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
207
207
 
208
208
  You can also block SPECIFIC authors via the plan's block_authors list. A block is a permanent hard exclusion of that one handle from all future thread selection, so it is YOUR judgment call, never automatic. Block when the evidence is strong: a wrong_author reject IS a direct human statement about that author (especially with profile_click), and the author context (author_followers, their post, found_via_topic) or the user's note confirms the account itself was the problem rather than the topic. Do NOT block when the reject looks topic-driven (off_topic/bad_draft on a reasonable account) or when you are unsure; the generalizable TYPE entry in audience_avoid is the softer tool for that.
209
209
 
@@ -129,16 +129,47 @@ STATS_KEYS = (
129
129
  )
130
130
 
131
131
 
132
- def _enrich_with_stats(cands: list) -> int:
133
- """Stamp a `stats` sidecar onto plan candidates that lack one, from the
134
- twitter_candidates rows the discovery pipeline already wrote. ONE listing
135
- call (/api/v1/twitter-candidates?tweet_urls=...) covers the whole queue.
136
- Best-effort: any failure (offline box, missing identity, API error) leaves
137
- candidates unstamped and NEVER blocks card delivery. Returns count stamped."""
138
- want = [c for c in cands if not c.get("stats") and not c.get("posted") and _thread_url(c)]
139
- if not want:
140
- return 0
141
- urls = sorted({_thread_url(c) for c in want})[:500]
132
+ def _sync_with_backend(cands: list) -> tuple[int, int]:
133
+ """One bulk /api/v1/twitter-candidates lookup for every still-open candidate
134
+ (not posted, not terminal), used for two things:
135
+
136
+ - stamp the discovery-time `stats` sidecar the card renders (candidates
137
+ that already have one are left alone), same as the old
138
+ _enrich_with_stats this replaces.
139
+ - notice when the backend has ALREADY retired a candidate this plan
140
+ still thinks is 'pending' (most commonly the Phase 0 freshness gate
141
+ flipping status='expired' after FRESHNESS_HOURS see
142
+ skill/run-twitter-cycle.sh) and mark it terminal here too, same as a
143
+ human "discard all pending" would. Without this, a card can sit in
144
+ the review queue as an approvable draft long after the backend has
145
+ moved on; approving it later silently no-ops (post_drafts returns
146
+ posted:0, no browser ever launches, no post-*.log — see the
147
+ 2026-07-09 "approved 3 cards, nothing posted" investigation).
148
+
149
+ Runs on EVERY merge (every cycle), not just once per candidate, so status
150
+ drift after the initial stamp is still caught while a card is still
151
+ pending. Best-effort: any API failure leaves every candidate untouched
152
+ (fail open, same as before). Returns (stamped_count, pruned_count).
153
+
154
+ APPROVED cards are exempt from the prune: an approval is a settled human
155
+ decision awaiting its serialized post, and stamping terminal on it makes
156
+ post_drafts refuse it as already-decided (posted:0). That exact race ate
157
+ 2 of 3 approvals on 2026-07-10 (approved 04:30:02Z, merge stamped
158
+ backend_status_expired 04:32:33Z, poster refused both). The freshness
159
+ gate exists to stop stale UNDECIDED drafts from burning review attention;
160
+ once a human has said "post it", the only honest gate left is the
161
+ poster's own at-post-time tweet_unavailable check."""
162
+ pending = [
163
+ c
164
+ for c in cands
165
+ if not c.get("posted")
166
+ and not c.get("terminal")
167
+ and not c.get("approved")
168
+ and _thread_url(c)
169
+ ]
170
+ if not pending:
171
+ return 0, 0
172
+ urls = sorted({_thread_url(c) for c in pending})[:500]
142
173
  try:
143
174
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
144
175
  from http_api import api_get
@@ -149,17 +180,24 @@ def _enrich_with_stats(cands: list) -> int:
149
180
  )
150
181
  rows = (resp.get("data") or {}).get("candidates") or []
151
182
  except BaseException as e: # http_api raises SystemExit on terminal failure
152
- print(f"[merge_review_queue] stats enrichment skipped: {e}", file=sys.stderr)
153
- return 0
183
+ print(f"[merge_review_queue] backend sync skipped: {e}", file=sys.stderr)
184
+ return 0, 0
154
185
  by_url = {str(r.get("tweet_url")): r for r in rows if r.get("tweet_url")}
155
186
  stamped = 0
156
- for c in want:
187
+ pruned = 0
188
+ for c in pending:
157
189
  row = by_url.get(_thread_url(c))
158
190
  if not row:
159
191
  continue
160
- c["stats"] = {k: row.get(k) for k in STATS_KEYS}
161
- stamped += 1
162
- return stamped
192
+ if not c.get("stats"):
193
+ c["stats"] = {k: row.get(k) for k in STATS_KEYS}
194
+ stamped += 1
195
+ status = row.get("status")
196
+ if status and status != "pending":
197
+ c["terminal"] = True
198
+ c["discard_reason"] = f"backend_status_{status}"
199
+ pruned += 1
200
+ return stamped, pruned
163
201
 
164
202
 
165
203
  def main() -> int:
@@ -255,9 +293,15 @@ def main() -> int:
255
293
  merged.append(c)
256
294
  added += 1
257
295
 
258
- stamped = _enrich_with_stats(merged)
296
+ stamped, pruned = _sync_with_backend(merged)
259
297
  if stamped:
260
298
  print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
299
+ if pruned:
300
+ print(
301
+ f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
302
+ "backend (expired/etc.) before they were reviewed",
303
+ file=sys.stderr,
304
+ )
261
305
 
262
306
  plan_obj = {"candidates": merged}
263
307
  if plan_created_at:
@@ -274,15 +318,17 @@ def main() -> int:
274
318
  _atomic_write(dst, plan_obj)
275
319
  ensure_store_symlink()
276
320
 
277
- # Refresh the review-request marker the menu bar polls (count = pending, not posted).
278
- pending = len([c for c in merged if not c.get("posted")])
321
+ # Refresh the review-request marker the menu bar polls (count = pending,
322
+ # not posted, not terminal -- a just-pruned expired card must not still
323
+ # inflate the badge).
324
+ pending_count = len([c for c in merged if not c.get("posted") and not c.get("terminal")])
279
325
  project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
280
326
  _atomic_write(
281
327
  review_request_path(),
282
328
  {
283
329
  "batch_id": REVIEW_QUEUE_ID,
284
330
  "project": project,
285
- "count": pending,
331
+ "count": pending_count,
286
332
  "plan_path": dst,
287
333
  "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
288
334
  },
@@ -306,7 +352,7 @@ def main() -> int:
306
352
 
307
353
  print(
308
354
  f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
309
- f"({pending} pending total) from {os.path.basename(src)}",
355
+ f"({pending_count} pending total) from {os.path.basename(src)}",
310
356
  file=sys.stderr,
311
357
  )
312
358
  # Clean up the consumed batch plan so /tmp doesn't fill with orphans.
@@ -38,6 +38,12 @@ States:
38
38
  now correctly resolves to 'missing' (below), since the account
39
39
  scoping means an orphaned OTHER account's registry is never
40
40
  consulted for the active account's health.
41
+ DIAGNOSTIC-ONLY for the menu bar ⚠ since 2026-07-09: under the
42
+ wedge the tick can skip 70-90% of fires while the queue still
43
+ drains every job (jobs arrive ~every 8 min vs 60 fires/hr), so
44
+ a point-in-time lastRunAt check flip-flops against a healthy
45
+ pipeline. The menu bar alarms on queue latency instead and
46
+ renders 'stalled' as a plain detail line via tick_stats().
41
47
  'missing' — the ACTIVE account has no registry with a complete worker set
42
48
  (deleted / never scheduled / orphaned by an account switch)
43
49
  -> the dashboard offers "Set up draft schedule". Consumers
@@ -270,6 +276,53 @@ def _detail(glob_pattern: str | None = None) -> dict:
270
276
  return {"glob": patterns, "registries": regs}
271
277
 
272
278
 
279
+ def tick_stats(window_s: int = 3600, glob_pattern: str | None = None) -> dict:
280
+ """Diagnostic tick-level stats for the active account's worker set: how many
281
+ per-minute fires the host SKIPPED inside the last window_s (the registry's
282
+ recordedSkips ledger — under the Desktop warm-session wedge these carry
283
+ reason per_task_limit) plus the age of the newest successful run. Powers the
284
+ menu bar's non-alarm "scheduler degraded" detail line when compute() returns
285
+ 'stalled'. NEVER a health verdict on its own: a box skipping 80% of ticks
286
+ can still drain every job (jobs arrive ~every 8 min vs 60 fires/hr), which
287
+ is exactly why tick staleness stopped driving the attention ⚠ (2026-07-09).
288
+ Same account scoping as compute(); pass glob_pattern only for tests."""
289
+ patterns = [glob_pattern] if glob_pattern is not None else _active_registry_glob_patterns()
290
+ now = time.time()
291
+ skips = 0
292
+ last_skip_epoch = None
293
+ newest_epoch = None
294
+ for pattern in patterns:
295
+ for f in glob.glob(pattern):
296
+ try:
297
+ with open(f) as fh:
298
+ d = json.load(fh)
299
+ except Exception:
300
+ continue
301
+ by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
302
+ recs = _registry_worker_recs(by_id)
303
+ if recs is None:
304
+ continue
305
+ epochs = [_iso_to_epoch(r.get("lastRunAt")) for r in recs]
306
+ e = max([x for x in epochs if x is not None], default=None)
307
+ if e is not None and (newest_epoch is None or e > newest_epoch):
308
+ newest_epoch = e
309
+ recorded = d.get("recordedSkips") or {}
310
+ for tid in WORKER_TASK_IDS:
311
+ for s in recorded.get(tid) or []:
312
+ at = _ms_to_epoch((s or {}).get("at"))
313
+ if at is None or (now - at) > window_s:
314
+ continue
315
+ skips += 1
316
+ if last_skip_epoch is None or at > last_skip_epoch:
317
+ last_skip_epoch = at
318
+ return {
319
+ "skips_in_window": skips,
320
+ "window_s": window_s,
321
+ "last_run_age_s": int(now - newest_epoch) if newest_epoch is not None else None,
322
+ "last_skip_age_s": int(now - last_skip_epoch) if last_skip_epoch is not None else None,
323
+ }
324
+
325
+
273
326
  def main() -> int:
274
327
  out = {}
275
328
  try: