@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 +11 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +279 -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 +14 -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/skill/run-twitter-cycle.sh +111 -61
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.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": {
|
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.
|
|
@@ -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
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
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),
|
|
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)
|
|
1273
|
-
#
|
|
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.
|
|
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
|
-
('
|
|
1622
|
-
|
|
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
|
|
1636
|
-
# two-draft cards, either draft box). Draft
|
|
1637
|
-
# (no popover, no logging: the boxes are big
|
|
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
|
-
|
|
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):
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.5-rc.
|
|
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