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

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.23",
3
+ "installedAt": "2026-07-15T22:57:05.841Z"
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.23",
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.
@@ -398,6 +479,12 @@ def _reply_heading_suffix(d):
398
479
  project = (d.get("project") or "").strip()
399
480
  lane = ((d.get("experiments") or {}).get("lane") or "").strip()
400
481
  bits = []
482
+ if (d.get("experiments") or {}).get("sandbox"):
483
+ # Prompt-sandbox replay (run-twitter-cycle.sh S4L_SANDBOX_CANDIDATES_FILE):
484
+ # visible on the heading itself, not just the details popover, since
485
+ # approve_() blocks posting on these and the reviewer should know why
486
+ # before clicking rather than after.
487
+ bits.append("⚠ SANDBOX — not postable")
401
488
  # Project and lane are independent concepts most of the time (project
402
489
  # "fazm" drafted under lane "personal_brand"), but the operator's own
403
490
  # PersonalBrand project IS the personal_brand lane, so project=="PersonalBrand"
@@ -523,6 +610,20 @@ def _details_lines(d):
523
610
  return lines
524
611
 
525
612
 
613
+ # Twitter's hard-expire ceiling: skill/run-twitter-cycle.sh's FRESHNESS_HOURS,
614
+ # a fixed constant with "NO env-var knobs" per the 2026-07-06 decision (6h
615
+ # steady-state as of 2026-07-15, raised from 2h; widened to 48h while
616
+ # first-run-boost.json exists in the state dir, mirroring the exact marker
617
+ # run-draft-and-publish.sh reads to decide the same thing). The real Phase 0
618
+ # gate now compares tweet_posted_at directly (basis="tweet_posted_at" passed
619
+ # by score_twitter_candidates.py), so this constant tracks thread age exactly,
620
+ # not a discovery-age proxy. Reddit cards never carry a `stats` dict (see
621
+ # _reddit_plan_to_candidates), so _expiry_str never fires for them; reddit's
622
+ # own 24h ceiling (post_reddit.py) has no card-facing clock yet.
623
+ _TWITTER_EXPIRE_HOURS = 6
624
+ _TWITTER_EXPIRE_HOURS_BOOST = 48
625
+
626
+
526
627
  def _age_str(iso):
527
628
  """Thread age since tweet_posted_at, minute-granular for fresh threads
528
629
  ('38m'); rolls to hours/days only when minutes would be absurd."""
@@ -546,6 +647,89 @@ def _age_str(iso):
546
647
  return f"{hours // 24}d"
547
648
 
548
649
 
650
+ def _first_run_boost_active():
651
+ try:
652
+ from pathlib import Path
653
+
654
+ import s4l_state
655
+
656
+ return (Path(s4l_state.state_dir()) / "first-run-boost.json").exists()
657
+ except Exception:
658
+ return False
659
+
660
+
661
+ def _expiry_secs_left(iso, platform):
662
+ """Seconds remaining until the Phase 0 hard-expire cutoff, shared by the
663
+ header's minute-granular label and the hover popover's second-granular
664
+ live countdown. None when there's nothing to count down: no timestamp,
665
+ or a platform this doesn't apply to."""
666
+ if not iso or (platform or "twitter").lower() != "twitter":
667
+ return None
668
+ try:
669
+ t = datetime.datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
670
+ if t.tzinfo is None:
671
+ t = t.replace(tzinfo=datetime.timezone.utc)
672
+ hours = (
673
+ _TWITTER_EXPIRE_HOURS_BOOST
674
+ if _first_run_boost_active()
675
+ else _TWITTER_EXPIRE_HOURS
676
+ )
677
+ deadline = t + datetime.timedelta(hours=hours)
678
+ return int(
679
+ (deadline - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
680
+ )
681
+ except Exception:
682
+ return None
683
+
684
+
685
+ def _expiry_seconds_str(iso, platform):
686
+ """Second-granular countdown ('1h22m03s left', '4m09s left', '38s
687
+ left'), or 'expired'. The header label re-renders this every second
688
+ (2026-07-15 per user: it should visibly count down in the inline card
689
+ itself, not just on hover) via tickAgeExpiryLabel_."""
690
+ secs_left = _expiry_secs_left(iso, platform)
691
+ if secs_left is None:
692
+ return None
693
+ if secs_left <= 0:
694
+ return "expired"
695
+ h, rem = divmod(secs_left, 3600)
696
+ m, s = divmod(rem, 60)
697
+ if h:
698
+ return f"{h}h{m:02d}m{s:02d}s left"
699
+ if m:
700
+ return f"{m}m{s:02d}s left"
701
+ return f"{s}s left"
702
+
703
+
704
+ def _age_expiry_display(iso, platform):
705
+ """(text, urgent) for the header's age/expiry label: 'Ns ago (Xh Ym Zs
706
+ left)', bold+full-strength once <=15min remain or it's already expired
707
+ (weight, not a new color, per this repo's severity convention -- see
708
+ CLAUDE.md "Dashboard colors"). Shared by _render (initial paint) and
709
+ tickAgeExpiryLabel_ (the per-second update) so the two never drift.
710
+ (None, False) when there's nothing to show (no timestamp, or a platform
711
+ _expiry_secs_left doesn't apply to)."""
712
+ age = _age_str(iso)
713
+ secs_left = _expiry_secs_left(iso, platform)
714
+ expiry_seconds = _expiry_seconds_str(iso, platform)
715
+ if not expiry_seconds:
716
+ return None, False
717
+ text = f"{age} ago ({expiry_seconds})" if age else expiry_seconds
718
+ urgent = secs_left is not None and secs_left <= 900
719
+ return text, urgent
720
+
721
+
722
+ # Hover popover on the header's age/expiry label (2026-07-15 per user): the
723
+ # reviewer sees the countdown but not necessarily WHY it exists, so the
724
+ # popover pairs the live seconds-granular clock with the reasoning behind the
725
+ # freshness gate itself.
726
+ _EXPIRY_EDUCATION_TEXT = (
727
+ "What we care about is not a post that has a lot of engagement, but the "
728
+ "fresh ones: ideally we're the first to comment and like a post, to have "
729
+ "the highest share of voice and be the first-ranking comment on a thread."
730
+ )
731
+
732
+
549
733
  # ---- contemporary styling helpers --------------------------------------------
550
734
  # Style-only layer (2026-07-07): frames, sizes, and control positions are
551
735
  # untouched; these helpers change nothing but the skin. The card reads as a
@@ -728,6 +912,8 @@ class _ReviewController(NSObject):
728
912
  self._eye_btn = None
729
913
  self._details_btn = None
730
914
  self._stats_popover = None
915
+ self._age_expiry_label = None
916
+ self._age_expiry_timer = None
731
917
  # Per-card telemetry, reset when a NEW card renders (not on the
732
918
  # card <-> reason-picker swap, which is the same card).
733
919
  self._rendered_idx = -1
@@ -1155,8 +1341,10 @@ class _ReviewController(NSObject):
1155
1341
  )
1156
1342
  right_x = W - M
1157
1343
  self._close_stats_popover()
1344
+ self._stop_age_expiry_timer()
1158
1345
  self._eye_btn = None
1159
1346
  self._details_btn = None
1347
+ self._age_expiry_label = None
1160
1348
  if _engagement_line(stats):
1161
1349
  # y is nudged 2px above the label row: the label's 12pt text draws
1162
1350
  # top-aligned in its 18px frame while the button centers its image,
@@ -1167,19 +1355,50 @@ class _ReviewController(NSObject):
1167
1355
  content.addSubview_(eye)
1168
1356
  self._eye_btn = eye
1169
1357
  right_x -= 24
1170
- age = _age_str(stats.get("tweet_posted_at"))
1171
- if age:
1358
+ # Age + a live, second-granular countdown to the Phase 0 hard-expire
1359
+ # cutoff, combined in one header label ("10m ago (1h49m20s left)")
1360
+ # that ticks every second via tickAgeExpiryLabel_ (2026-07-15 per
1361
+ # user) -- no need to hover to see it counting down. Hovering still
1362
+ # shows the fixed "why freshness matters" explanation.
1363
+ age_expiry, urgent = _age_expiry_display(
1364
+ stats.get("tweet_posted_at"), d.get("platform")
1365
+ )
1366
+ if age_expiry:
1367
+ # Urgent state (<=15min left, or already past the cutoff) drops
1368
+ # the muted gray and goes bold+full-strength instead of adding a
1369
+ # color: this repo's severity convention is weight, never a new
1370
+ # chromatic accent (see CLAUDE.md "Dashboard colors").
1172
1371
  age_w = int(
1173
1372
  NSAttributedString.alloc().initWithString_attributes_(
1174
- age, {NSFontAttributeName: NSFont.systemFontOfSize_(11)}
1373
+ age_expiry, {NSFontAttributeName: _font(11, urgent)}
1175
1374
  ).size().width
1176
- ) + 8
1375
+ ) + 12 # +12 not +8: a little slack so a minute/hour rollover
1376
+ # mid-tick (e.g. "59s left" -> "1m00s left") doesn't clip before
1377
+ # the next full _render recomputes the exact width.
1177
1378
  age_label = _label(
1178
- NSMakeRect(right_x - age_w, H - 70, age_w, 18), age, size=11, muted=True
1379
+ NSMakeRect(right_x - age_w, H - 70, age_w, 18),
1380
+ age_expiry,
1381
+ size=11,
1382
+ bold=urgent,
1383
+ muted=not urgent,
1179
1384
  )
1180
1385
  age_label.setAlignment_(NSTextAlignmentRight)
1181
1386
  content.addSubview_(age_label)
1182
1387
  right_x -= age_w + 4
1388
+ age_label.addTrackingArea_(
1389
+ NSTrackingArea.alloc().initWithRect_options_owner_userInfo_(
1390
+ age_label.bounds(),
1391
+ NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways,
1392
+ self,
1393
+ {"kind": "expiry"},
1394
+ )
1395
+ )
1396
+ self._age_expiry_label = age_label
1397
+ self._age_expiry_timer = (
1398
+ NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
1399
+ 1.0, self, "tickAgeExpiryLabel:", None, True
1400
+ )
1401
+ )
1183
1402
  # Platform mark (brand identification, inline with the author row):
1184
1403
  # Reddit's orange "r/" vs X's glyph, so a mixed-platform review queue
1185
1404
  # reads at a glance which network each card posts to.
@@ -1269,32 +1488,13 @@ class _ReviewController(NSObject):
1269
1488
  thread_tv.setDrawsBackground_(False)
1270
1489
  # An NSTextView grows vertically by default; long threads inflated the
1271
1490
  # 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.
1491
+ # UP). Pin the frame, then _fit_thread_body shrinks the text (never
1492
+ # the trailing link) until the arrow actually lands inside it.
1274
1493
  thread_tv.setVerticallyResizable_(False)
1275
1494
  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
1495
  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
1496
  thread_tv.setDelegate_(self)
1297
- thread_tv.textStorage().setAttributedString_(body)
1497
+ _fit_thread_body(thread_tv, thread_en or d.get("thread_text"), thread_url)
1298
1498
  content.addSubview_(thread_tv)
1299
1499
  # Reply heading — bold. A concise "project/lane · viral N" tag rides
1300
1500
  # right after it in a SEPARATE, regular-weight label (2026-07-08:
@@ -1516,6 +1716,19 @@ class _ReviewController(NSObject):
1516
1716
  self._panel.makeFirstResponder_(tv)
1517
1717
  self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
1518
1718
 
1719
+ @objc.python_method
1720
+ def _stop_age_expiry_timer(self):
1721
+ """Stop the header label's per-second tick (tickAgeExpiryLabel_).
1722
+ Called at the top of every _render (the label it targets is about to
1723
+ be replaced) and from _finish (the whole panel is going away), so a
1724
+ repeating NSTimer can never outlive the label it updates."""
1725
+ if self._age_expiry_timer is not None:
1726
+ try:
1727
+ self._age_expiry_timer.invalidate()
1728
+ except Exception:
1729
+ pass
1730
+ self._age_expiry_timer = None
1731
+
1519
1732
  @objc.python_method
1520
1733
  def _close_stats_popover(self):
1521
1734
  try:
@@ -1600,6 +1813,38 @@ class _ReviewController(NSObject):
1600
1813
  lines = _details_lines(self._drafts[self._idx])
1601
1814
  self._show_popover(lines, self._details_btn, "details")
1602
1815
 
1816
+ @objc.python_method
1817
+ def _show_expiry_popover(self):
1818
+ """Hover popover for the header's age/expiry label: just the fixed
1819
+ explanation of why freshness matters (2026-07-15 per user). The
1820
+ countdown itself doesn't need a popover-only live view anymore -- the
1821
+ header label ticks in place every second (see tickAgeExpiryLabel_),
1822
+ visible whether or not the pointer is over it."""
1823
+ self._show_popover(_EXPIRY_EDUCATION_TEXT, self._age_expiry_label, "expiry")
1824
+
1825
+ def tickAgeExpiryLabel_(self, timer):
1826
+ """NSTimer target (2026-07-15): re-renders the header's age/expiry
1827
+ label every second so its countdown visibly counts down without
1828
+ needing hover. Not a python_method -- NSTimer invokes this through
1829
+ the ObjC runtime."""
1830
+ if self._age_expiry_label is None:
1831
+ return
1832
+ try:
1833
+ d = self._drafts[self._idx]
1834
+ stats = d.get("stats") or {}
1835
+ text, urgent = _age_expiry_display(
1836
+ stats.get("tweet_posted_at"), d.get("platform")
1837
+ )
1838
+ if not text:
1839
+ return
1840
+ self._age_expiry_label.setStringValue_(text)
1841
+ self._age_expiry_label.setFont_(_font(11, urgent))
1842
+ self._age_expiry_label.setTextColor_(
1843
+ NSColor.labelColor() if urgent else NSColor.secondaryLabelColor()
1844
+ )
1845
+ except Exception:
1846
+ pass
1847
+
1603
1848
  # Click on an eye SHOWS its popover, never toggles it closed: a click is
1604
1849
  # physically preceded by hover (mouseEntered already opened it), so a
1605
1850
  # toggle would close what the hover just opened and the user sees nothing.
@@ -1618,8 +1863,9 @@ class _ReviewController(NSObject):
1618
1863
  def _hover_info(self, event):
1619
1864
  """(kind, slot) a tracking-area event belongs to, from the userInfo
1620
1865
  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."""
1866
+ ('expiry', None) for the age/expiry label, ('draft', 0|1) for the two
1867
+ draft boxes. Defaults to ('stats', None), the original single-eye
1868
+ behavior, if the area carries no info."""
1623
1869
  try:
1624
1870
  info = event.trackingArea().userInfo()
1625
1871
  if info:
@@ -1628,22 +1874,26 @@ class _ReviewController(NSObject):
1628
1874
  return "draft", int(info.get("slot"))
1629
1875
  if kind == "details":
1630
1876
  return "details", None
1877
+ if kind == "expiry":
1878
+ return "expiry", None
1631
1879
  except Exception:
1632
1880
  pass
1633
1881
  return "stats", None
1634
1882
 
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).
1883
+ # NSTrackingArea owner callbacks (hover over either eye icon, the
1884
+ # age/expiry label, or, on two-draft cards, either draft box). Draft
1885
+ # hovers only bank dwell time (no popover, no logging: the boxes are big
1886
+ # and enter/exit fires on every pass of the pointer).
1639
1887
  def mouseEntered_(self, event):
1640
1888
  kind, slot = self._hover_info(event)
1641
1889
  if kind == "draft":
1642
1890
  self._draft_hover_open[slot] = time.time()
1643
1891
  return
1644
- _log(f"{kind} eye hover enter")
1892
+ _log(f"{kind} eye hover enter" if kind != "expiry" else "expiry label hover enter")
1645
1893
  if kind == "details":
1646
1894
  self._show_details_popover()
1895
+ elif kind == "expiry":
1896
+ self._show_expiry_popover()
1647
1897
  else:
1648
1898
  self._show_stats_popover()
1649
1899
 
@@ -1994,6 +2244,20 @@ class _ReviewController(NSObject):
1994
2244
  # level. Commits and advances immediately; level 2+ is the loved
1995
2245
  # signal, with the exact strength riding along as an approve_level_N
1996
2246
  # interaction.
2247
+ d = self._drafts[self._idx]
2248
+ if (d.get("experiments") or {}).get("sandbox"):
2249
+ # Sandbox replay of a historical thread (run-twitter-cycle.sh's
2250
+ # S4L_SANDBOX_CANDIDATES_FILE short-circuit): approving this would
2251
+ # feed a real reply to twitter_post_plan.py against a months-old
2252
+ # thread. Block it here, the single choke point _record(True, ...)
2253
+ # always routes through, rather than downstream — Reject is still
2254
+ # the only way to clear a sandbox card.
2255
+ _log("blocked approve on sandbox draft (not postable)")
2256
+ try:
2257
+ self._panel.setTitle_("s4l · SANDBOX draft — not postable, use Reject")
2258
+ except Exception:
2259
+ pass
2260
+ return
1997
2261
  try:
1998
2262
  level = int(sender.tag())
1999
2263
  except Exception:
@@ -2147,6 +2411,7 @@ class _ReviewController(NSObject):
2147
2411
  def _finish(self):
2148
2412
  global _active
2149
2413
  self._close_stats_popover()
2414
+ self._stop_age_expiry_timer()
2150
2415
  try:
2151
2416
  if self._panel is not None:
2152
2417
  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):