@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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.11",
3
- "installedAt": "2026-08-14T18:28:05.705Z"
2
+ "version": "1.7.12-rc.2",
3
+ "installedAt": "2026-08-20T01:59:29.922Z"
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.11",
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": {
@@ -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 _record(self, approved, reject_category=None, reject_note=None, loved=False):
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
- self._ship_review_event(batch, decision)
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
- self._notify("S4L", "No drafts approved nothing posted.")
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,
@@ -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"] = "human_rejected"
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.11",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.11",
3
+ "version": "1.7.12-rc.2",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -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
- continue # a healthy / interactive session never touch.
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: