@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 +11 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +299 -34
- package/mcp/menubar/s4l_menubar.py +45 -20
- package/mcp/menubar/s4l_state.py +6 -3
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/active_experiments.py +24 -7
- package/scripts/engagement_styles.py +32 -47
- package/scripts/merge_review_queue.py +23 -1
- package/scripts/post_reddit.py +125 -36
- package/scripts/preview_expiry_card.py +50 -0
- package/scripts/scan_x_profile.py +24 -8
- package/scripts/score_twitter_candidates.py +19 -5
- package/scripts/twitter_prompt_sandbox.py +128 -0
- package/skill/run-twitter-cycle.sh +191 -72
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",
|
package/mcp/dist/version.json
CHANGED
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.
|
|
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": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -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
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
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),
|
|
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)
|
|
1273
|
-
#
|
|
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.
|
|
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
|
-
('
|
|
1622
|
-
|
|
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
|
|
1636
|
-
# two-draft cards, either draft box). Draft
|
|
1637
|
-
# (no popover, no logging: the boxes are big
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
#
|
|
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
|
|
2760
|
-
|
|
2761
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3337
|
+
base = f"S4L {done}/{len(ob['milestones'])}"
|
|
3321
3338
|
elif self._update_available:
|
|
3322
|
-
|
|
3339
|
+
base = "S4L ⬆" # update available — open the menu to update
|
|
3323
3340
|
else:
|
|
3324
|
-
|
|
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
|
|
3662
|
-
# drafts" above shows them right
|
|
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 "
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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 =
|
|
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):
|