@m13v/s4l 1.7.5-rc.21 → 1.7.5-rc.22

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
@@ -1094,6 +1094,17 @@ async function postApproved(batchId, plan) {
1094
1094
  };
1095
1095
  if (cc.engagement_style)
1096
1096
  decPost.engagement_style = cc.engagement_style;
1097
+ // Two-draft cards (2026-07-15): a human draft-switch (see the generic
1098
+ // edit-handling above, `if (e.variant && Array.isArray(c.drafts))`)
1099
+ // stamps the CHOSEN draft's assigned_style/assigned_mode onto the card
1100
+ // (cc). Forward both into decPost so post_reddit.py's _post_iteration
1101
+ // validates/logs against whichever draft is ACTUALLY posting, not
1102
+ // whichever was recommended at plan-write time — mirrors
1103
+ // twitter_post_plan.py's identical per-candidate override.
1104
+ if (cc.assigned_style !== undefined)
1105
+ decPost.assigned_style = cc.assigned_style;
1106
+ if (cc.assigned_mode !== undefined)
1107
+ decPost.assigned_mode = cc.assigned_mode;
1097
1108
  const miniPlan = {
1098
1109
  project_name: meta.project_name || cc.matched_project,
1099
1110
  batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.5-rc.21",
3
- "installedAt": "2026-07-15T17:33:38.021Z"
2
+ "version": "1.7.5-rc.22",
3
+ "installedAt": "2026-07-15T22:40:42.989Z"
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.5-rc.21",
5
+ "version": "1.7.5-rc.22",
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": {
@@ -69,10 +69,12 @@ import objc
69
69
  from Foundation import (
70
70
  NSObject,
71
71
  NSMakeRect,
72
+ NSMakeRange,
72
73
  NSMakeSize,
73
74
  NSAttributedString,
74
75
  NSMutableAttributedString,
75
76
  NSURL,
77
+ NSTimer,
76
78
  )
77
79
  from AppKit import (
78
80
  NSApp,
@@ -330,6 +332,85 @@ def _truncate(s, n=320):
330
332
  return s if len(s) <= n else s[: n - 1] + "…"
331
333
 
332
334
 
335
+ def _fit_thread_body(thread_tv, text, link_url, *, font_size=12, step=15, floor=10):
336
+ """Set the thread-quote text on `thread_tv` ('text… ↗' when link_url is
337
+ given), shrinking `text` -- never the trailing link -- until the arrow
338
+ actually lands inside the view's visible box.
339
+
340
+ thread_tv is fixed-height and not vertically resizable, with no
341
+ scrollview around it, so a glyph laid out below its frame is simply
342
+ never drawn and never clickable. The old fixed `_truncate(text, 200)`
343
+ assumed ~4 lines always fit 200 characters, which holds most of the
344
+ time but not when word/URL lengths wrap more per line -- the trailing
345
+ ↗ link, the card's only way to open the source thread, could then sit
346
+ past the visible box (2026-07-15 user report: link "sometimes appears
347
+ outside the visible area", "doesn't fit into the card"). Checking the
348
+ arrow glyph's own bounding rect (rather than comparing total laid-out
349
+ height to the box) is what makes this correct whether or not the text
350
+ container itself turns out to be height-bounded."""
351
+ text = (text or "").strip()
352
+ box_h = thread_tv.frame().size.height
353
+ try:
354
+ inset = thread_tv.textContainerInset()
355
+ available_h = box_h - 2 * inset.height
356
+ except Exception:
357
+ available_h = box_h
358
+
359
+ def _attributed(shown_text):
360
+ b = NSMutableAttributedString.alloc().initWithString_attributes_(
361
+ shown_text,
362
+ {
363
+ NSFontAttributeName: NSFont.systemFontOfSize_(font_size),
364
+ NSForegroundColorAttributeName: NSColor.labelColor(),
365
+ },
366
+ )
367
+ if link_url:
368
+ b.appendAttributedString_(
369
+ NSAttributedString.alloc().initWithString_attributes_(
370
+ " ↗",
371
+ {
372
+ NSFontAttributeName: NSFont.systemFontOfSize_(font_size),
373
+ # Delegate (textView:clickedOnLink:atIndex:) tracks the
374
+ # click as a thread_click interaction, then opens the
375
+ # URL itself via NSWorkspace.
376
+ NSLinkAttributeName: NSURL.URLWithString_(link_url),
377
+ },
378
+ )
379
+ )
380
+ return b
381
+
382
+ length = min(len(text), 200)
383
+ lm = thread_tv.layoutManager()
384
+ tc = thread_tv.textContainer()
385
+ # Bounded loop: each pass is a cheap native layout of at most a couple
386
+ # hundred characters. The common case (thread fits in ~200 chars)
387
+ # exits after the first pass; only pathological long-word wrapping
388
+ # iterates further, and it always terminates at `floor`.
389
+ for _ in range(20):
390
+ shown = text[:length].rstrip()
391
+ if length < len(text):
392
+ shown += "…"
393
+ attributed = _attributed(shown)
394
+ thread_tv.textStorage().setAttributedString_(attributed)
395
+ if not link_url:
396
+ return
397
+ total_len = attributed.length()
398
+ if total_len == 0:
399
+ return
400
+ lm.ensureLayoutForTextContainer_(tc)
401
+ glyph_range = lm.glyphRangeForTextContainer_(tc)
402
+ laid_out_all = (glyph_range.location + glyph_range.length) >= total_len
403
+ fits = False
404
+ if laid_out_all:
405
+ last_rect = lm.boundingRectForGlyphRange_inTextContainer_(
406
+ NSMakeRange(total_len - 1, 1), tc
407
+ )
408
+ fits = (last_rect.origin.y + last_rect.size.height) <= available_h
409
+ if fits or length <= floor:
410
+ return
411
+ length = max(floor, length - step)
412
+
413
+
333
414
  # Human names for the ISO 639-1 codes the drafting model actually emits, so the
334
415
  # card can say "posts in Japanese" instead of "posts in ja". Unknown codes fall
335
416
  # back to the bare code; never raises.
@@ -523,6 +604,20 @@ def _details_lines(d):
523
604
  return lines
524
605
 
525
606
 
607
+ # Twitter's hard-expire ceiling: skill/run-twitter-cycle.sh's FRESHNESS_HOURS,
608
+ # a fixed constant with "NO env-var knobs" per the 2026-07-06 decision (2h
609
+ # steady-state; widened to 48h while first-run-boost.json exists in the state
610
+ # dir, mirroring the exact marker run-draft-and-publish.sh reads to decide the
611
+ # same thing). The real Phase 0 gate compares discovered_at, not
612
+ # tweet_posted_at, but logic D caps discovery freshness at 1h so the two are
613
+ # within ~1h of each other, close enough for an on-card countdown. Reddit
614
+ # cards never carry a `stats` dict (see _reddit_plan_to_candidates), so
615
+ # _expiry_str never fires for them; reddit's own 24h ceiling (post_reddit.py)
616
+ # has no card-facing clock yet.
617
+ _TWITTER_EXPIRE_HOURS = 2
618
+ _TWITTER_EXPIRE_HOURS_BOOST = 48
619
+
620
+
526
621
  def _age_str(iso):
527
622
  """Thread age since tweet_posted_at, minute-granular for fresh threads
528
623
  ('38m'); rolls to hours/days only when minutes would be absurd."""
@@ -546,6 +641,89 @@ def _age_str(iso):
546
641
  return f"{hours // 24}d"
547
642
 
548
643
 
644
+ def _first_run_boost_active():
645
+ try:
646
+ from pathlib import Path
647
+
648
+ import s4l_state
649
+
650
+ return (Path(s4l_state.state_dir()) / "first-run-boost.json").exists()
651
+ except Exception:
652
+ return False
653
+
654
+
655
+ def _expiry_secs_left(iso, platform):
656
+ """Seconds remaining until the Phase 0 hard-expire cutoff, shared by the
657
+ header's minute-granular label and the hover popover's second-granular
658
+ live countdown. None when there's nothing to count down: no timestamp,
659
+ or a platform this doesn't apply to."""
660
+ if not iso or (platform or "twitter").lower() != "twitter":
661
+ return None
662
+ try:
663
+ t = datetime.datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
664
+ if t.tzinfo is None:
665
+ t = t.replace(tzinfo=datetime.timezone.utc)
666
+ hours = (
667
+ _TWITTER_EXPIRE_HOURS_BOOST
668
+ if _first_run_boost_active()
669
+ else _TWITTER_EXPIRE_HOURS
670
+ )
671
+ deadline = t + datetime.timedelta(hours=hours)
672
+ return int(
673
+ (deadline - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
674
+ )
675
+ except Exception:
676
+ return None
677
+
678
+
679
+ def _expiry_seconds_str(iso, platform):
680
+ """Second-granular countdown ('1h22m03s left', '4m09s left', '38s
681
+ left'), or 'expired'. The header label re-renders this every second
682
+ (2026-07-15 per user: it should visibly count down in the inline card
683
+ itself, not just on hover) via tickAgeExpiryLabel_."""
684
+ secs_left = _expiry_secs_left(iso, platform)
685
+ if secs_left is None:
686
+ return None
687
+ if secs_left <= 0:
688
+ return "expired"
689
+ h, rem = divmod(secs_left, 3600)
690
+ m, s = divmod(rem, 60)
691
+ if h:
692
+ return f"{h}h{m:02d}m{s:02d}s left"
693
+ if m:
694
+ return f"{m}m{s:02d}s left"
695
+ return f"{s}s left"
696
+
697
+
698
+ def _age_expiry_display(iso, platform):
699
+ """(text, urgent) for the header's age/expiry label: 'Ns ago (Xh Ym Zs
700
+ left)', bold+full-strength once <=15min remain or it's already expired
701
+ (weight, not a new color, per this repo's severity convention -- see
702
+ CLAUDE.md "Dashboard colors"). Shared by _render (initial paint) and
703
+ tickAgeExpiryLabel_ (the per-second update) so the two never drift.
704
+ (None, False) when there's nothing to show (no timestamp, or a platform
705
+ _expiry_secs_left doesn't apply to)."""
706
+ age = _age_str(iso)
707
+ secs_left = _expiry_secs_left(iso, platform)
708
+ expiry_seconds = _expiry_seconds_str(iso, platform)
709
+ if not expiry_seconds:
710
+ return None, False
711
+ text = f"{age} ago ({expiry_seconds})" if age else expiry_seconds
712
+ urgent = secs_left is not None and secs_left <= 900
713
+ return text, urgent
714
+
715
+
716
+ # Hover popover on the header's age/expiry label (2026-07-15 per user): the
717
+ # reviewer sees the countdown but not necessarily WHY it exists, so the
718
+ # popover pairs the live seconds-granular clock with the reasoning behind the
719
+ # freshness gate itself.
720
+ _EXPIRY_EDUCATION_TEXT = (
721
+ "What we care about is not a post that has a lot of engagement, but the "
722
+ "fresh ones: ideally we're the first to comment and like a post, to have "
723
+ "the highest share of voice and be the first-ranking comment on a thread."
724
+ )
725
+
726
+
549
727
  # ---- contemporary styling helpers --------------------------------------------
550
728
  # Style-only layer (2026-07-07): frames, sizes, and control positions are
551
729
  # untouched; these helpers change nothing but the skin. The card reads as a
@@ -728,6 +906,8 @@ class _ReviewController(NSObject):
728
906
  self._eye_btn = None
729
907
  self._details_btn = None
730
908
  self._stats_popover = None
909
+ self._age_expiry_label = None
910
+ self._age_expiry_timer = None
731
911
  # Per-card telemetry, reset when a NEW card renders (not on the
732
912
  # card <-> reason-picker swap, which is the same card).
733
913
  self._rendered_idx = -1
@@ -1155,8 +1335,10 @@ class _ReviewController(NSObject):
1155
1335
  )
1156
1336
  right_x = W - M
1157
1337
  self._close_stats_popover()
1338
+ self._stop_age_expiry_timer()
1158
1339
  self._eye_btn = None
1159
1340
  self._details_btn = None
1341
+ self._age_expiry_label = None
1160
1342
  if _engagement_line(stats):
1161
1343
  # y is nudged 2px above the label row: the label's 12pt text draws
1162
1344
  # top-aligned in its 18px frame while the button centers its image,
@@ -1167,19 +1349,50 @@ class _ReviewController(NSObject):
1167
1349
  content.addSubview_(eye)
1168
1350
  self._eye_btn = eye
1169
1351
  right_x -= 24
1170
- age = _age_str(stats.get("tweet_posted_at"))
1171
- if age:
1352
+ # Age + a live, second-granular countdown to the Phase 0 hard-expire
1353
+ # cutoff, combined in one header label ("10m ago (1h49m20s left)")
1354
+ # that ticks every second via tickAgeExpiryLabel_ (2026-07-15 per
1355
+ # user) -- no need to hover to see it counting down. Hovering still
1356
+ # shows the fixed "why freshness matters" explanation.
1357
+ age_expiry, urgent = _age_expiry_display(
1358
+ stats.get("tweet_posted_at"), d.get("platform")
1359
+ )
1360
+ if age_expiry:
1361
+ # Urgent state (<=15min left, or already past the cutoff) drops
1362
+ # the muted gray and goes bold+full-strength instead of adding a
1363
+ # color: this repo's severity convention is weight, never a new
1364
+ # chromatic accent (see CLAUDE.md "Dashboard colors").
1172
1365
  age_w = int(
1173
1366
  NSAttributedString.alloc().initWithString_attributes_(
1174
- age, {NSFontAttributeName: NSFont.systemFontOfSize_(11)}
1367
+ age_expiry, {NSFontAttributeName: _font(11, urgent)}
1175
1368
  ).size().width
1176
- ) + 8
1369
+ ) + 12 # +12 not +8: a little slack so a minute/hour rollover
1370
+ # mid-tick (e.g. "59s left" -> "1m00s left") doesn't clip before
1371
+ # the next full _render recomputes the exact width.
1177
1372
  age_label = _label(
1178
- NSMakeRect(right_x - age_w, H - 70, age_w, 18), age, size=11, muted=True
1373
+ NSMakeRect(right_x - age_w, H - 70, age_w, 18),
1374
+ age_expiry,
1375
+ size=11,
1376
+ bold=urgent,
1377
+ muted=not urgent,
1179
1378
  )
1180
1379
  age_label.setAlignment_(NSTextAlignmentRight)
1181
1380
  content.addSubview_(age_label)
1182
1381
  right_x -= age_w + 4
1382
+ age_label.addTrackingArea_(
1383
+ NSTrackingArea.alloc().initWithRect_options_owner_userInfo_(
1384
+ age_label.bounds(),
1385
+ NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways,
1386
+ self,
1387
+ {"kind": "expiry"},
1388
+ )
1389
+ )
1390
+ self._age_expiry_label = age_label
1391
+ self._age_expiry_timer = (
1392
+ NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
1393
+ 1.0, self, "tickAgeExpiryLabel:", None, True
1394
+ )
1395
+ )
1183
1396
  # Platform mark (brand identification, inline with the author row):
1184
1397
  # Reddit's orange "r/" vs X's glyph, so a mixed-platform review queue
1185
1398
  # reads at a glance which network each card posts to.
@@ -1269,32 +1482,13 @@ class _ReviewController(NSObject):
1269
1482
  thread_tv.setDrawsBackground_(False)
1270
1483
  # An NSTextView grows vertically by default; long threads inflated the
1271
1484
  # frame over the author row above (non-flipped superview: growth goes
1272
- # UP) and pushed the trailing out of the box. Pin the frame and
1273
- # truncate to what 4 lines actually fit so the arrow stays visible.
1485
+ # UP). Pin the frame, then _fit_thread_body shrinks the text (never
1486
+ # the trailing link) until the arrow actually lands inside it.
1274
1487
  thread_tv.setVerticallyResizable_(False)
1275
1488
  thread_tv.setHorizontallyResizable_(False)
1276
- body = NSMutableAttributedString.alloc().initWithString_attributes_(
1277
- _truncate(thread_en or d.get("thread_text"), 200),
1278
- {
1279
- NSFontAttributeName: NSFont.systemFontOfSize_(12),
1280
- NSForegroundColorAttributeName: NSColor.labelColor(),
1281
- },
1282
- )
1283
1489
  if thread_url:
1284
- body.appendAttributedString_(
1285
- NSAttributedString.alloc().initWithString_attributes_(
1286
- " ↗",
1287
- {
1288
- NSFontAttributeName: NSFont.systemFontOfSize_(12),
1289
- # Delegate (textView:clickedOnLink:atIndex:) tracks the
1290
- # click as a thread_click interaction, then opens the
1291
- # URL itself via NSWorkspace.
1292
- NSLinkAttributeName: NSURL.URLWithString_(thread_url),
1293
- },
1294
- )
1295
- )
1296
1490
  thread_tv.setDelegate_(self)
1297
- thread_tv.textStorage().setAttributedString_(body)
1491
+ _fit_thread_body(thread_tv, thread_en or d.get("thread_text"), thread_url)
1298
1492
  content.addSubview_(thread_tv)
1299
1493
  # Reply heading — bold. A concise "project/lane · viral N" tag rides
1300
1494
  # right after it in a SEPARATE, regular-weight label (2026-07-08:
@@ -1516,6 +1710,19 @@ class _ReviewController(NSObject):
1516
1710
  self._panel.makeFirstResponder_(tv)
1517
1711
  self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
1518
1712
 
1713
+ @objc.python_method
1714
+ def _stop_age_expiry_timer(self):
1715
+ """Stop the header label's per-second tick (tickAgeExpiryLabel_).
1716
+ Called at the top of every _render (the label it targets is about to
1717
+ be replaced) and from _finish (the whole panel is going away), so a
1718
+ repeating NSTimer can never outlive the label it updates."""
1719
+ if self._age_expiry_timer is not None:
1720
+ try:
1721
+ self._age_expiry_timer.invalidate()
1722
+ except Exception:
1723
+ pass
1724
+ self._age_expiry_timer = None
1725
+
1519
1726
  @objc.python_method
1520
1727
  def _close_stats_popover(self):
1521
1728
  try:
@@ -1600,6 +1807,38 @@ class _ReviewController(NSObject):
1600
1807
  lines = _details_lines(self._drafts[self._idx])
1601
1808
  self._show_popover(lines, self._details_btn, "details")
1602
1809
 
1810
+ @objc.python_method
1811
+ def _show_expiry_popover(self):
1812
+ """Hover popover for the header's age/expiry label: just the fixed
1813
+ explanation of why freshness matters (2026-07-15 per user). The
1814
+ countdown itself doesn't need a popover-only live view anymore -- the
1815
+ header label ticks in place every second (see tickAgeExpiryLabel_),
1816
+ visible whether or not the pointer is over it."""
1817
+ self._show_popover(_EXPIRY_EDUCATION_TEXT, self._age_expiry_label, "expiry")
1818
+
1819
+ def tickAgeExpiryLabel_(self, timer):
1820
+ """NSTimer target (2026-07-15): re-renders the header's age/expiry
1821
+ label every second so its countdown visibly counts down without
1822
+ needing hover. Not a python_method -- NSTimer invokes this through
1823
+ the ObjC runtime."""
1824
+ if self._age_expiry_label is None:
1825
+ return
1826
+ try:
1827
+ d = self._drafts[self._idx]
1828
+ stats = d.get("stats") or {}
1829
+ text, urgent = _age_expiry_display(
1830
+ stats.get("tweet_posted_at"), d.get("platform")
1831
+ )
1832
+ if not text:
1833
+ return
1834
+ self._age_expiry_label.setStringValue_(text)
1835
+ self._age_expiry_label.setFont_(_font(11, urgent))
1836
+ self._age_expiry_label.setTextColor_(
1837
+ NSColor.labelColor() if urgent else NSColor.secondaryLabelColor()
1838
+ )
1839
+ except Exception:
1840
+ pass
1841
+
1603
1842
  # Click on an eye SHOWS its popover, never toggles it closed: a click is
1604
1843
  # physically preceded by hover (mouseEntered already opened it), so a
1605
1844
  # toggle would close what the hover just opened and the user sees nothing.
@@ -1618,8 +1857,9 @@ class _ReviewController(NSObject):
1618
1857
  def _hover_info(self, event):
1619
1858
  """(kind, slot) a tracking-area event belongs to, from the userInfo
1620
1859
  stamped at creation: ('stats'|'details', None) for the eye icons,
1621
- ('draft', 0|1) for the two draft boxes. Defaults to ('stats', None),
1622
- the original single-eye behavior, if the area carries no info."""
1860
+ ('expiry', None) for the age/expiry label, ('draft', 0|1) for the two
1861
+ draft boxes. Defaults to ('stats', None), the original single-eye
1862
+ behavior, if the area carries no info."""
1623
1863
  try:
1624
1864
  info = event.trackingArea().userInfo()
1625
1865
  if info:
@@ -1628,22 +1868,26 @@ class _ReviewController(NSObject):
1628
1868
  return "draft", int(info.get("slot"))
1629
1869
  if kind == "details":
1630
1870
  return "details", None
1871
+ if kind == "expiry":
1872
+ return "expiry", None
1631
1873
  except Exception:
1632
1874
  pass
1633
1875
  return "stats", None
1634
1876
 
1635
- # NSTrackingArea owner callbacks (hover over either eye icon or, on
1636
- # two-draft cards, either draft box). Draft hovers only bank dwell time
1637
- # (no popover, no logging: the boxes are big and enter/exit fires on
1638
- # every pass of the pointer).
1877
+ # NSTrackingArea owner callbacks (hover over either eye icon, the
1878
+ # age/expiry label, or, on two-draft cards, either draft box). Draft
1879
+ # hovers only bank dwell time (no popover, no logging: the boxes are big
1880
+ # and enter/exit fires on every pass of the pointer).
1639
1881
  def mouseEntered_(self, event):
1640
1882
  kind, slot = self._hover_info(event)
1641
1883
  if kind == "draft":
1642
1884
  self._draft_hover_open[slot] = time.time()
1643
1885
  return
1644
- _log(f"{kind} eye hover enter")
1886
+ _log(f"{kind} eye hover enter" if kind != "expiry" else "expiry label hover enter")
1645
1887
  if kind == "details":
1646
1888
  self._show_details_popover()
1889
+ elif kind == "expiry":
1890
+ self._show_expiry_popover()
1647
1891
  else:
1648
1892
  self._show_stats_popover()
1649
1893
 
@@ -2147,6 +2391,7 @@ class _ReviewController(NSObject):
2147
2391
  def _finish(self):
2148
2392
  global _active
2149
2393
  self._close_stats_popover()
2394
+ self._stop_age_expiry_timer()
2150
2395
  try:
2151
2396
  if self._panel is not None:
2152
2397
  self._panel.setDelegate_(None)
@@ -1087,6 +1087,7 @@ class S4LMenuBar(rumps.App):
1087
1087
  (1800, "Every 30 minutes"),
1088
1088
  (3600, "Every hour"),
1089
1089
  (14400, "Every 4 hours"),
1090
+ (-1, "Never (menu bar count only)"),
1090
1091
  )
1091
1092
 
1092
1093
  # Posting volume (2026-07-13): server-side per-install throttle for the
@@ -1116,10 +1117,14 @@ class S4LMenuBar(rumps.App):
1116
1117
  def _on_cadence_preset(self, secs, _sender=None):
1117
1118
  """Menu callback shim: rumps passes the clicked MenuItem last."""
1118
1119
  written = st.write_reveal_cadence(secs)
1119
- self._notify(
1120
- "S4L draft cards",
1121
- f"New draft cards will show: {self._cadence_label(written).lower()}",
1122
- )
1120
+ if written < 0:
1121
+ msg = (
1122
+ "Cards won’t pop up anymore — check the pending count in "
1123
+ "the menu bar, or click “Review N pending drafts” to see them."
1124
+ )
1125
+ else:
1126
+ msg = f"New draft cards will show: {self._cadence_label(written).lower()}"
1127
+ self._notify("S4L draft cards", msg)
1123
1128
  self._sig = None
1124
1129
  try:
1125
1130
  self._tick(None)
@@ -2479,9 +2484,17 @@ class S4LMenuBar(rumps.App):
2479
2484
  # Drop the stale "drafting" spinner while we need attention so the ⚠ shows.
2480
2485
  self._stalled = attention
2481
2486
 
2487
+ # Pending draft-card count, for the bulk-discard menu item (visibility +
2488
+ # label) AND the concise inline count on the menu bar title itself
2489
+ # (2026-07-15, so a glance at the bar shows the backlog without opening
2490
+ # the dropdown — most useful with the reveal cadence set to "Never").
2491
+ # Cheap local JSON reads, same source _maybe_start_review uses.
2492
+ _, pending_drafts = self._pending_review()
2493
+ pending_count = len(pending_drafts)
2494
+
2482
2495
  # Spinner owns the title while busy; _spin already keeps the ⬆ visible there.
2483
2496
  if not busy:
2484
- self._render_title(setup_complete, ob, blocker, attention)
2497
+ self._render_title(setup_complete, ob, blocker, attention, pending_count)
2485
2498
 
2486
2499
  # Blocker notification only on transition into a new blocker.
2487
2500
  if blocker and blocker_code != self._last_blocker_code:
@@ -2595,10 +2608,7 @@ class S4LMenuBar(rumps.App):
2595
2608
  if ob
2596
2609
  else 0
2597
2610
  )
2598
- # Pending draft-card count, for the bulk-discard menu item (visibility +
2599
- # label). Cheap local JSON reads, same source _maybe_start_review uses.
2600
- _, pending_drafts = self._pending_review()
2601
- pending_count = len(pending_drafts)
2611
+ # pending_count was already computed above (before _render_title).
2602
2612
 
2603
2613
  # _update_available / _latest_version are in the signature so a freshly
2604
2614
  # detected update rebuilds the menu (adding "Update now & restart Claude Desktop") even mid-run.
@@ -2756,15 +2766,18 @@ class S4LMenuBar(rumps.App):
2756
2766
 
2757
2767
  def _reveal_hold_until(self, pending_count):
2758
2768
  """Epoch until which the reveal cadence is actually holding fresh cards
2759
- back (0.0 = no hold). A hold only exists while there is something to
2760
- hold: pending drafts, no review in flight, and not snoozed (the snooze
2761
- label and gate take precedence)."""
2769
+ back (0.0 = no hold, -1.0 = held indefinitely the "Never" preset, cards
2770
+ only reveal via an explicit "Review N pending drafts" click). A hold only
2771
+ exists while there is something to hold: pending drafts, no review in
2772
+ flight, and not snoozed (the snooze label and gate take precedence)."""
2762
2773
  if pending_count <= 0 or self._review_active:
2763
2774
  return 0.0
2764
2775
  now = time.time()
2765
2776
  if now < self._review_snooze_until:
2766
2777
  return 0.0
2767
2778
  cadence = st.read_reveal_cadence()
2779
+ if cadence < 0:
2780
+ return -1.0
2768
2781
  until = self._last_presented_at + cadence
2769
2782
  return until if (cadence > 0 and now < until) else 0.0
2770
2783
 
@@ -2858,6 +2871,10 @@ class S4LMenuBar(rumps.App):
2858
2871
  # stamped on a hold, so the same set still presents fresh when due.
2859
2872
  if not focus:
2860
2873
  cadence = st.read_reveal_cadence()
2874
+ if cadence < 0:
2875
+ # "Never" preset: cards never auto-pop, only an explicit
2876
+ # "Review N pending drafts" click (focus=True) reveals them.
2877
+ return
2861
2878
  if cadence > 0 and time.time() - self._last_presented_at < cadence:
2862
2879
  return
2863
2880
  with self._review_lock:
@@ -3312,16 +3329,21 @@ class S4LMenuBar(rumps.App):
3312
3329
  self._reset_posting_progress_locked()
3313
3330
  self._post_q.task_done()
3314
3331
 
3315
- def _render_title(self, setup_complete, ob, blocker, attention=False):
3332
+ def _render_title(self, setup_complete, ob, blocker, attention=False, pending_count=0):
3316
3333
  if blocker or attention:
3317
- self.title = "S4L ⚠" # warning (setup blocked OR autopilot needs attention)
3334
+ base = "S4L ⚠" # warning (setup blocked OR autopilot needs attention)
3318
3335
  elif not setup_complete and ob and not ob.get("complete"):
3319
3336
  done = sum(1 for m in ob["milestones"] if m.get("status") == "complete")
3320
- self.title = f"S4L {done}/{len(ob['milestones'])}"
3337
+ base = f"S4L {done}/{len(ob['milestones'])}"
3321
3338
  elif self._update_available:
3322
- self.title = "S4L ⬆" # update available — open the menu to update
3339
+ base = "S4L ⬆" # update available — open the menu to update
3323
3340
  else:
3324
- self.title = "S4L"
3341
+ base = "S4L"
3342
+ # Concise inline backlog count, right on the bar (not just the dropdown's
3343
+ # "Review N pending drafts") — per user request 2026-07-15, most useful
3344
+ # when the reveal cadence is "Never" and this count is the only signal
3345
+ # that drafts are waiting.
3346
+ self.title = f"{base} · {pending_count}" if pending_count > 0 else base
3325
3347
 
3326
3348
  # ---- menu construction ------------------------------------------------
3327
3349
  def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok", pending_count=0):
@@ -3658,10 +3680,13 @@ class S4LMenuBar(rumps.App):
3658
3680
  )
3659
3681
  else:
3660
3682
  # Reveal-cadence hold: the backlog exists but the pop-up is being
3661
- # paced. Same courtesy line as the snooze; "Review N pending
3662
- # drafts" above shows them right now regardless.
3683
+ # paced, or suppressed entirely ("Never"). Same courtesy line as
3684
+ # the snooze; "Review N pending drafts" above shows them right
3685
+ # now regardless.
3663
3686
  hold_until = self._reveal_hold_until(pending_count)
3664
- if hold_until:
3687
+ if hold_until < 0:
3688
+ items.append(self._label("Cards hidden — see count in the menu bar"))
3689
+ elif hold_until:
3665
3690
  items.append(
3666
3691
  self._label(
3667
3692
  "Next cards around "
@@ -1229,22 +1229,25 @@ REVEAL_CADENCE_DEFAULT = 3600.0
1229
1229
 
1230
1230
 
1231
1231
  def read_reveal_cadence():
1232
- """Seconds between draft-card reveals (0 = immediately, default 1 hour)."""
1232
+ """Seconds between draft-card reveals (0 = immediately, default 1 hour,
1233
+ -1 = "Never" — manual-only reveal via the menu's "Review N pending
1234
+ drafts", the pending count shows in the menu bar title instead)."""
1233
1235
  d = read_json(MODE_FILE)
1234
1236
  try:
1235
1237
  secs = float(d.get("reveal_cadence_secs"))
1236
1238
  except (TypeError, ValueError, AttributeError):
1237
1239
  return REVEAL_CADENCE_DEFAULT
1238
- return max(0.0, secs)
1240
+ return secs if secs < 0 else max(0.0, secs)
1239
1241
 
1240
1242
 
1241
1243
  def write_reveal_cadence(secs):
1242
1244
  """Persist the reveal cadence, preserving every other mode.json key.
1243
1245
  Returns the written value. Never raises (a menu click must not crash)."""
1244
1246
  try:
1245
- secs = max(0.0, float(secs))
1247
+ secs = float(secs)
1246
1248
  except (TypeError, ValueError):
1247
1249
  return read_reveal_cadence()
1250
+ secs = -1.0 if secs < 0 else max(0.0, secs)
1248
1251
  try:
1249
1252
  payload = read_json(MODE_FILE)
1250
1253
  if not isinstance(payload, dict):
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.5-rc.21",
3
+ "version": "1.7.5-rc.22",
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": "@m13v/s4l",
3
- "version": "1.7.5-rc.21",
3
+ "version": "1.7.5-rc.22",
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",