@m13v/s4l 1.7.11 → 1.7.12-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +119 -1
- package/mcp/menubar/s4l_menubar.py +28 -2
- package/mcp/menubar/s4l_state.py +10 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/reap_stale_claude_sessions.py +13 -2
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
|
+
"version": "1.7.12-rc.2",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -714,6 +714,34 @@ def _age_expiry_display(iso, platform):
|
|
|
714
714
|
return text, urgent
|
|
715
715
|
|
|
716
716
|
|
|
717
|
+
# Local hard-expire at the countdown site (2026-08-17): when a card's clock
|
|
718
|
+
# crosses zero it is RETIRED locally (terminal, reason local_clock_expired)
|
|
719
|
+
# instead of just relabeling itself "expired" while staying approvable. The
|
|
720
|
+
# real freshness gate is cycle-driven (backend Phase 0 flip + merge-time
|
|
721
|
+
# prune), so any stretch where cycles don't run — machine asleep on a dead
|
|
722
|
+
# battery, producer down, hidden window absorbing drafts overnight — used to
|
|
723
|
+
# leave long-expired cards presentable; approving one is a silent no-op
|
|
724
|
+
# server-side (2026-07-09, and again on the 2026-08-17 hibernate wake, 14
|
|
725
|
+
# cards approved 6-19h stale). Corner-card mode only: canvas tiles keep the
|
|
726
|
+
# label-only behavior (the canvas is always an explicit user-opened surface
|
|
727
|
+
# with its own replace flow). The on-screen card additionally requires
|
|
728
|
+
# AUTO_EXPIRE_IDLE_SECONDS without interaction and an unedited draft before
|
|
729
|
+
# it self-retires, so it is never yanked out from under someone actively
|
|
730
|
+
# working the moment the clock crosses zero.
|
|
731
|
+
AUTO_EXPIRE_IDLE_SECONDS = 60
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _draft_expired(d):
|
|
735
|
+
"""True when this draft's Phase 0 hard-expire deadline has passed — the
|
|
736
|
+
same clock the header countdown renders (_expiry_secs_left). False when
|
|
737
|
+
the countdown doesn't apply (reddit cards, missing timestamp)."""
|
|
738
|
+
d = d or {}
|
|
739
|
+
secs = _expiry_secs_left(
|
|
740
|
+
(d.get("stats") or {}).get("tweet_posted_at"), d.get("platform")
|
|
741
|
+
)
|
|
742
|
+
return secs is not None and secs <= 0
|
|
743
|
+
|
|
744
|
+
|
|
717
745
|
# Hover popover on the header's age/expiry label (2026-07-15 per user): the
|
|
718
746
|
# reviewer sees the countdown but not necessarily WHY it exists, so the
|
|
719
747
|
# popover pairs the live seconds-granular clock with the reasoning behind the
|
|
@@ -1154,6 +1182,12 @@ class _ReviewController(NSObject):
|
|
|
1154
1182
|
self._add_snooze_accessory(panel)
|
|
1155
1183
|
self._panel = panel
|
|
1156
1184
|
self._render()
|
|
1185
|
+
if self._panel is None:
|
|
1186
|
+
# The hard-expire sweep retired the ENTIRE stack inside that
|
|
1187
|
+
# first _render (every draft past deadline, e.g. a wake after
|
|
1188
|
+
# hibernate) and _finish already closed the panel. Don't order
|
|
1189
|
+
# the dead panel back onto the screen.
|
|
1190
|
+
return
|
|
1157
1191
|
panel.orderFrontRegardless()
|
|
1158
1192
|
self._log_surface("presented")
|
|
1159
1193
|
if self._focus:
|
|
@@ -1371,7 +1405,41 @@ class _ReviewController(NSObject):
|
|
|
1371
1405
|
return eye
|
|
1372
1406
|
|
|
1373
1407
|
@objc.python_method
|
|
1408
|
+
@objc.python_method
|
|
1409
|
+
def _retire_expired_current(self):
|
|
1410
|
+
"""Stamp the CURRENT draft terminal as local_clock_expired and fire its
|
|
1411
|
+
decision, without advancing (callers own what happens next). The swept
|
|
1412
|
+
card was never reviewed, so per-card telemetry is cleared first: stale
|
|
1413
|
+
interactions/dwell from the previously shown card must not ride along
|
|
1414
|
+
on a machine decision."""
|
|
1415
|
+
d = self._drafts[self._idx]
|
|
1416
|
+
self._interactions = []
|
|
1417
|
+
self._card_shown_at = None
|
|
1418
|
+
self._selected_draft = None
|
|
1419
|
+
_log(
|
|
1420
|
+
f"auto-expired n={d.get('n')} cid={d.get('candidate_id')} "
|
|
1421
|
+
f"(card {self._idx + 1} of {len(self._drafts)})"
|
|
1422
|
+
)
|
|
1423
|
+
self._record(False, auto_expired=True)
|
|
1424
|
+
self._fire_decision()
|
|
1425
|
+
|
|
1374
1426
|
def _render(self):
|
|
1427
|
+
# Hard-expire sweep (2026-08-17, corner-card mode only — see the
|
|
1428
|
+
# AUTO_EXPIRE_IDLE_SECONDS block): retire every already-expired draft
|
|
1429
|
+
# at the front of the stack BEFORE building UI for it, so a stack
|
|
1430
|
+
# that sat through a sleep/hibernate never presents dead cards. No
|
|
1431
|
+
# idle guard here — nothing on screen is being yanked; this card was
|
|
1432
|
+
# about to be shown for the first time (or re-shown after a decision
|
|
1433
|
+
# on its predecessor).
|
|
1434
|
+
if self._host_view is None:
|
|
1435
|
+
while self._idx < len(self._drafts) and _draft_expired(
|
|
1436
|
+
self._drafts[self._idx]
|
|
1437
|
+
):
|
|
1438
|
+
self._retire_expired_current()
|
|
1439
|
+
self._idx += 1
|
|
1440
|
+
if self._idx >= len(self._drafts):
|
|
1441
|
+
self._finish()
|
|
1442
|
+
return
|
|
1375
1443
|
d = self._drafts[self._idx]
|
|
1376
1444
|
# Fresh card (not a card <-> reason-picker swap): reset telemetry.
|
|
1377
1445
|
if self._rendered_idx != self._idx:
|
|
@@ -1974,6 +2042,25 @@ class _ReviewController(NSObject):
|
|
|
1974
2042
|
return
|
|
1975
2043
|
try:
|
|
1976
2044
|
d = self._drafts[self._idx]
|
|
2045
|
+
# The on-screen card crossed its hard-expire deadline: retire it
|
|
2046
|
+
# (terminal, local_clock_expired) instead of letting it sit
|
|
2047
|
+
# approvable under an "expired" label — approving it is a silent
|
|
2048
|
+
# server-side no-op. Guarded so it never yanks a card someone is
|
|
2049
|
+
# actively working: requires AUTO_EXPIRE_IDLE_SECONDS with no
|
|
2050
|
+
# interaction AND an unedited draft body. While either guard
|
|
2051
|
+
# holds, the label keeps showing "expired"; the tick re-checks
|
|
2052
|
+
# every second and retires once the user goes idle. Corner-card
|
|
2053
|
+
# mode only (canvas tiles keep label-only behavior).
|
|
2054
|
+
if self._host_view is None and _draft_expired(d):
|
|
2055
|
+
idle_ok = (
|
|
2056
|
+
self._last_interaction_at is None
|
|
2057
|
+
or time.time() - self._last_interaction_at
|
|
2058
|
+
>= AUTO_EXPIRE_IDLE_SECONDS
|
|
2059
|
+
)
|
|
2060
|
+
if idle_ok and not self._draft_hand_edited(d):
|
|
2061
|
+
self._retire_expired_current()
|
|
2062
|
+
self._advance()
|
|
2063
|
+
return
|
|
1977
2064
|
stats = d.get("stats") or {}
|
|
1978
2065
|
text, urgent = _age_expiry_display(
|
|
1979
2066
|
stats.get("tweet_posted_at"), d.get("platform")
|
|
@@ -2236,7 +2323,25 @@ class _ReviewController(NSObject):
|
|
|
2236
2323
|
return d.get("reply_text") or ""
|
|
2237
2324
|
|
|
2238
2325
|
@objc.python_method
|
|
2239
|
-
def
|
|
2326
|
+
def _draft_hand_edited(self, d):
|
|
2327
|
+
"""True when the on-screen text no longer matches ANY of this draft's
|
|
2328
|
+
original bodies (reply_text plus each two-draft variant) — i.e. the
|
|
2329
|
+
reviewer has invested a hand edit. Used only as an auto-expire guard,
|
|
2330
|
+
so unknowable defaults to True (never yank what we can't verify)."""
|
|
2331
|
+
try:
|
|
2332
|
+
originals = {(d.get("reply_text") or "").strip()}
|
|
2333
|
+
for dd in d.get("drafts") or []:
|
|
2334
|
+
if isinstance(dd, dict):
|
|
2335
|
+
originals.add((dd.get("text") or "").strip())
|
|
2336
|
+
return (self._current_text() or "").strip() not in originals
|
|
2337
|
+
except Exception:
|
|
2338
|
+
return True
|
|
2339
|
+
|
|
2340
|
+
@objc.python_method
|
|
2341
|
+
def _record(
|
|
2342
|
+
self, approved, reject_category=None, reject_note=None, loved=False,
|
|
2343
|
+
auto_expired=False,
|
|
2344
|
+
):
|
|
2240
2345
|
d = self._drafts[self._idx]
|
|
2241
2346
|
# Two-draft cards: `orig` (what counts as "unedited") is whichever
|
|
2242
2347
|
# draft is CURRENTLY SELECTED, not always the plan's default
|
|
@@ -2311,6 +2416,13 @@ class _ReviewController(NSObject):
|
|
|
2311
2416
|
"drop_link": bool(approved and drop_link),
|
|
2312
2417
|
"reject_category": reject_category,
|
|
2313
2418
|
"reject_note": (reject_note or "").strip() or None,
|
|
2419
|
+
# Machine retirement, not a human judgment: the countdown
|
|
2420
|
+
# crossed zero (see the local hard-expire block above). The
|
|
2421
|
+
# store stamps terminal_reason=local_clock_expired and the
|
|
2422
|
+
# menu bar ships NO review event for it (an expiry says
|
|
2423
|
+
# nothing about draft quality; it must not reach the
|
|
2424
|
+
# feedback-digest -> learned_preferences rail).
|
|
2425
|
+
"auto_expired": bool(auto_expired),
|
|
2314
2426
|
"interactions": list(self._interactions),
|
|
2315
2427
|
"dwell_ms": self._dwell_ms(),
|
|
2316
2428
|
# Ride-along candidate context (from review_drafts) so the
|
|
@@ -2678,6 +2790,12 @@ def present_review(drafts, on_decision=None, on_complete=None, focus=False):
|
|
|
2678
2790
|
_active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_focus_(
|
|
2679
2791
|
drafts, on_decision, on_complete, focus
|
|
2680
2792
|
)
|
|
2793
|
+
if _active is not None and _active._panel is None:
|
|
2794
|
+
# The hard-expire sweep finished the whole stack during the initial
|
|
2795
|
+
# render (_finish set the module global to None, but this assignment
|
|
2796
|
+
# just overwrote it with the dead controller). Clear it so
|
|
2797
|
+
# extend/heal/status never operate on a closed surface.
|
|
2798
|
+
_active = None
|
|
2681
2799
|
|
|
2682
2800
|
|
|
2683
2801
|
def extend_active(drafts):
|
|
@@ -3568,7 +3568,12 @@ class S4LMenuBar(rumps.App):
|
|
|
3568
3568
|
# built to avoid (2026-07-16 user report). Every other consequence of
|
|
3569
3569
|
# a decision (review event, posting-queue enqueue) still happens
|
|
3570
3570
|
# here, per draft, same as the normal single-card path.
|
|
3571
|
-
|
|
3571
|
+
# Auto-expiries (card countdown crossed the hard-expire deadline, see
|
|
3572
|
+
# s4l_card's local hard-expire block) are machine retirements, not
|
|
3573
|
+
# judgments: like Discard All, they must never reach the review-events
|
|
3574
|
+
# -> feedback-digest -> learned_preferences rail.
|
|
3575
|
+
if not decision.get("auto_expired"):
|
|
3576
|
+
self._ship_review_event(batch, decision)
|
|
3572
3577
|
if not decision.get("approved"):
|
|
3573
3578
|
n = decision.get("n")
|
|
3574
3579
|
# Durable local record FIRST, mirroring the approve branch below.
|
|
@@ -3585,6 +3590,15 @@ class S4LMenuBar(rumps.App):
|
|
|
3585
3590
|
except Exception as e:
|
|
3586
3591
|
_capture(e, phase="reject_stamp_decision")
|
|
3587
3592
|
|
|
3593
|
+
# Auto-expiries stop at the durable store stamp: no loopback call.
|
|
3594
|
+
# The backend row's own Phase 0 freshness gate flips it
|
|
3595
|
+
# status='expired' on the next cycle (same 6h/48h clock the card
|
|
3596
|
+
# counted down), and the loopback needs Claude Desktop up — which
|
|
3597
|
+
# is exactly what's NOT guaranteed in the sleep/wake and
|
|
3598
|
+
# dead-producer scenarios this path exists for.
|
|
3599
|
+
if decision.get("auto_expired"):
|
|
3600
|
+
return
|
|
3601
|
+
|
|
3588
3602
|
def _persist_reject():
|
|
3589
3603
|
try:
|
|
3590
3604
|
st.approve_drafts(batch, reject=[n], timeout=30)
|
|
@@ -3679,7 +3693,19 @@ class S4LMenuBar(rumps.App):
|
|
|
3679
3693
|
"Review sooner from the S4L menu.",
|
|
3680
3694
|
)
|
|
3681
3695
|
elif not any(d.get("approved") for d in decisions):
|
|
3682
|
-
|
|
3696
|
+
n_exp = sum(1 for d in decisions if d.get("auto_expired"))
|
|
3697
|
+
if n_exp and n_exp == len(decisions):
|
|
3698
|
+
# The whole stack self-retired (typically a wake after
|
|
3699
|
+
# sleep/hibernate): say what happened instead of the generic
|
|
3700
|
+
# line, or the vanished cards look like a bug.
|
|
3701
|
+
plural = "s" if n_exp != 1 else ""
|
|
3702
|
+
self._notify(
|
|
3703
|
+
"S4L",
|
|
3704
|
+
f"Discarded {n_exp} stale draft{plural} — past the "
|
|
3705
|
+
"freshness window, too old to post.",
|
|
3706
|
+
)
|
|
3707
|
+
else:
|
|
3708
|
+
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
3683
3709
|
|
|
3684
3710
|
def _discard_all_pending(self, _=None):
|
|
3685
3711
|
"""Bulk-discard every pending draft card: rejected with NO reason,
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -895,6 +895,11 @@ def store_stamp_decision(batch, decision):
|
|
|
895
895
|
# None on single-draft cards. Durable locally so the choice
|
|
896
896
|
# survives even if the review-events flush never lands.
|
|
897
897
|
"draft_choice": decision.get("draft_choice"),
|
|
898
|
+
# Machine retirement (card countdown crossed the Phase 0 hard-
|
|
899
|
+
# expire deadline with no cycle around to prune it — sleep/wake,
|
|
900
|
+
# dead producer). Distinct from a human reject in terminal_reason
|
|
901
|
+
# below so dashboards/audits never count it as a judgment.
|
|
902
|
+
"auto_expired": bool(decision.get("auto_expired")),
|
|
898
903
|
"decided_at": time_iso(),
|
|
899
904
|
}
|
|
900
905
|
if decision.get("approved"):
|
|
@@ -903,7 +908,11 @@ def store_stamp_decision(batch, decision):
|
|
|
903
908
|
c.pop("post_error", None)
|
|
904
909
|
else:
|
|
905
910
|
c["terminal"] = True
|
|
906
|
-
c["terminal_reason"] =
|
|
911
|
+
c["terminal_reason"] = (
|
|
912
|
+
"local_clock_expired"
|
|
913
|
+
if decision.get("auto_expired")
|
|
914
|
+
else "human_rejected"
|
|
915
|
+
)
|
|
907
916
|
c["decision"] = payload
|
|
908
917
|
return True
|
|
909
918
|
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.12-rc.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -840,8 +840,19 @@ def main() -> int:
|
|
|
840
840
|
|
|
841
841
|
targets_by_pid: dict[int, dict] = {} # dedup across the two rules below
|
|
842
842
|
for uuid, members in groups.items():
|
|
843
|
-
if len(members) <= 1:
|
|
844
|
-
|
|
843
|
+
if len(members) <= 1 and inflight is None:
|
|
844
|
+
# Queue unreadable: cannot tell a husk from a worker about to claim, so
|
|
845
|
+
# the conservative fallback never touches a lone session. When the queue
|
|
846
|
+
# IS readable, singleton groups get the same type-driven sweep as larger
|
|
847
|
+
# ones. The 2026-08-16 wedge: one warm session never exited, Desktop's
|
|
848
|
+
# per_task_limit therefore never spawned a sibling, and the old
|
|
849
|
+
# unconditional `len(members) <= 1: continue` guard meant the reaper and
|
|
850
|
+
# the scheduler starved each other for ~55 minutes (reapable=1, killed=0
|
|
851
|
+
# every cycle). Everything in `members` is already a metadata- or
|
|
852
|
+
# cwd-confirmed S4L worker (interactive sessions are spared upstream in
|
|
853
|
+
# snapshot()), so rule (1)'s invariant applies to a group of one exactly
|
|
854
|
+
# as it does to a group of ten: claimless past claim_grace = proven husk.
|
|
855
|
+
continue
|
|
845
856
|
members.sort(key=lambda p: p["age"]) # ascending: newest first
|
|
846
857
|
|
|
847
858
|
if inflight is not None:
|