@m13v/s4l 1.7.12-rc.6 → 1.7.12-rc.8

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.12-rc.6",
3
- "installedAt": "2026-09-24T20:11:16.353Z"
2
+ "version": "1.7.12-rc.8",
3
+ "installedAt": "2026-09-28T16:44:48.332Z"
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.12-rc.6",
5
+ "version": "1.7.12-rc.8",
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": {
@@ -1690,6 +1690,30 @@ class S4LMenuBar(rumps.App):
1690
1690
  verification on such machines.
1691
1691
  """
1692
1692
  app_support = os.path.expanduser("~/Library/Application Support")
1693
+ # LIVE profile first (2026-09-28): on a multi-profile machine (account
1694
+ # rotator: Claude, Claude-mediar, Claude-mddy, ...) every profile keeps
1695
+ # its OWN copy of the extension, and "newest mtime" is simply the
1696
+ # profile that was updated most recently, not the one Claude is
1697
+ # running right now. The operator Mac sat on 1.7.7-rc.4 in the live
1698
+ # Claude-mediar profile for two months while every update landed in
1699
+ # Claude-mddy, and the updater kept reporting "already on latest".
1700
+ # Prefer the extension dir under a RUNNING Claude's --user-data-dir
1701
+ # (default profile == plain "Claude"); fall back to the newest-mtime
1702
+ # scan only when no Claude instance is running. Mirrors
1703
+ # scripts/s4l_box_update.sh; keep the two in sync.
1704
+ for udd in S4LMenuBar._claude_user_data_dirs():
1705
+ root = os.path.join(udd or os.path.join(app_support, "Claude"),
1706
+ "Claude Extensions")
1707
+ try:
1708
+ names = sorted(os.listdir(root))
1709
+ except OSError:
1710
+ continue
1711
+ for name in names:
1712
+ if not name.endswith("social-autoposter"):
1713
+ continue
1714
+ d = os.path.join(root, name)
1715
+ if os.path.exists(os.path.join(d, "manifest.json")):
1716
+ return d
1693
1717
  best, best_mtime = None, -1.0
1694
1718
  for root in glob.glob(os.path.join(app_support, "Claude*", "Claude Extensions")):
1695
1719
  try:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.12-rc.6",
3
+ "version": "1.7.12-rc.8",
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.12-rc.6",
3
+ "version": "1.7.12-rc.8",
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",
@@ -13,8 +13,10 @@ Design constraints, deliberately:
13
13
  own clicks. It is purely cosmetic.
14
14
  - CSP-safe: the overlay is built with createElement + element.style.<prop> +
15
15
  textContent only. No innerHTML-with-style-attributes and no injected <style>
16
- tag, both of which x.com's CSP can refuse. The "pulse" + "updated Ns ago"
17
- ticker are driven by a JS setInterval, not CSS @keyframes.
16
+ tag, both of which x.com's CSP can refuse.
17
+ - Static: no timers, no animation, no backdrop blur. The harness window is
18
+ kept painting even when hidden (--disable-backgrounding-occluded-windows),
19
+ so anything animated here redraws at display rate around the clock.
18
20
  - Survives navigation two ways: (1) Playwright add_init_script registers the
19
21
  builder on the browser context so every new document re-creates it, and
20
22
  (2) the watch loop re-asserts it via evaluate every couple seconds.
@@ -29,15 +31,14 @@ the locked pipeline scripts. Drive it from the CLI:
29
31
  # log into the overlay
30
32
 
31
33
  `watch` is the always-on mode: it tails the newest skill/logs/twitter-cycle-*.log
32
- and pushes a friendly one-liner into the overlay as each step lands, with a
33
- heartbeat so even idle-looking moments read as alive. If the harness Chrome is
34
- down it sleeps and retries; it never crashes the pipeline.
34
+ and repaints the overlay only when the status line changes or a page
35
+ navigates. If the harness Chrome is down or has no http tab it backs off and
36
+ retries; it never crashes the pipeline.
35
37
  """
36
38
 
37
39
  from __future__ import annotations
38
40
 
39
41
  import fcntl
40
- import glob
41
42
  import json
42
43
  import os
43
44
  import re
@@ -86,9 +87,7 @@ REASSURE = (
86
87
 
87
88
  # --- the page-side overlay builder ------------------------------------------
88
89
  # `_BODY` defines window.__s4lPaint(payload): idempotently creates the overlay
89
- # DOM, then updates its text. A lone setInterval drives both the pulse and the
90
- # "updated Ns ago" ticker so the overlay always looks alive between status
91
- # pushes. Built with createElement + element.style.<prop> + textContent only
90
+ # DOM, then updates its text. Built with createElement + element.style.<prop> + textContent only
92
91
  # (CSP-safe; no <style> tag, no innerHTML-with-style-attrs). pointer-events is
93
92
  # none so the overlay can never intercept the automation's own clicks.
94
93
  _BODY = r"""
@@ -114,7 +113,6 @@ window.__s4lAnnounce = function(payload){
114
113
  bs.zIndex="2147483647"; bs.display="flex";
115
114
  bs.alignItems="center"; bs.justifyContent="center";
116
115
  bs.background="rgba(0,0,0,0.55)";
117
- bs.backdropFilter="blur(3px)"; bs.webkitBackdropFilter="blur(3px)";
118
116
  // The ENTIRE modal (backdrop + card + text) is pointer-events:none so that a
119
117
  // bot click during this one-time window always passes through to the page,
120
118
  // even if the user never clicks OK. The OK button is the ONLY element that
@@ -184,7 +182,6 @@ window.__s4lPaint = function(payload){
184
182
  s.background="rgba(15,15,17,0.92)"; s.color="#fff";
185
183
  s.font="13px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif";
186
184
  s.boxShadow="0 6px 22px rgba(0,0,0,0.35)"; s.border="1px solid rgba(255,255,255,0.12)";
187
- s.backdropFilter="blur(6px)"; s.webkitBackdropFilter="blur(6px)";
188
185
 
189
186
  var head = mk("div", root); head.style.display="flex"; head.style.alignItems="center"; head.style.gap="8px";
190
187
  head.style.cursor="move"; head.style.userSelect="none"; head.style.webkitUserSelect="none";
@@ -225,19 +222,13 @@ window.__s4lPaint = function(payload){
225
222
  document.addEventListener("mouseup", function(){ drag = null; });
226
223
  })();
227
224
 
228
- if(st._iv) clearInterval(st._iv);
229
- st._iv = setInterval(function(){
230
- try{
231
- var dt = Math.max(0, Math.round((Date.now()-st.ts)/1000));
232
- st._ago.textContent = dt < 1 ? "now" : (dt < 60 ? dt+"s ago" : Math.round(dt/60)+"m ago");
233
- var stale = dt > 90; // fade the dot once activity goes quiet
234
- var phase = (Date.now()/650) % 2;
235
- st._dot.style.opacity = stale ? "0.3" : (phase < 1 ? "1" : "0.35");
236
- }catch(e){}
237
- }, 250);
238
225
  }
226
+ // Documents painted by an older overlay version still run its 250ms ticker.
227
+ if(st._iv){ clearInterval(st._iv); st._iv = null; st._dot.style.opacity = "1"; }
239
228
  st._title.textContent = st.title;
240
229
  st._status.textContent = st.status;
230
+ var d = new Date(st.ts);
231
+ st._ago.textContent = "since " + ("0"+d.getHours()).slice(-2) + ":" + ("0"+d.getMinutes()).slice(-2);
241
232
  } catch(e) { /* overlay is best-effort, never throw into the page */ }
242
233
  };
243
234
  """
@@ -277,7 +268,14 @@ class Harness:
277
268
  def __enter__(self):
278
269
  from playwright.sync_api import sync_playwright
279
270
  self._pw = sync_playwright().start()
280
- self._browser = self._pw.chromium.connect_over_cdp(self.cdp_url, timeout=5000)
271
+ try:
272
+ self._browser = self._pw.chromium.connect_over_cdp(self.cdp_url, timeout=5000)
273
+ except Exception:
274
+ # A started-but-unstopped Playwright leaks its node driver and leaves
275
+ # its asyncio loop running, so the next start() fails with "Sync API
276
+ # inside the asyncio loop".
277
+ self.__exit__(None, None, None)
278
+ raise
281
279
  return self
282
280
 
283
281
  def __exit__(self, *exc):
@@ -292,6 +290,12 @@ class Harness:
292
290
  except Exception:
293
291
  pass
294
292
 
293
+ def connected(self) -> bool:
294
+ try:
295
+ return bool(self._browser) and self._browser.is_connected()
296
+ except Exception:
297
+ return False
298
+
295
299
  def _pages(self):
296
300
  pages = []
297
301
  for ctx in self._browser.contexts:
@@ -323,10 +327,12 @@ class Harness:
323
327
  except Exception:
324
328
  pass
325
329
 
326
- def paint(self, title: str, reassure: str, status: str) -> int:
327
- """Paint/refresh the overlay on every live page. Returns pages touched."""
330
+ def paint(self, title: str, reassure: str, status: str, ts_ms: int | None = None) -> int:
331
+ """Paint/refresh the overlay on every live page. Returns pages touched.
332
+ ts_ms is when the status last changed (defaults to now)."""
328
333
  n = 0
329
- payload = {"title": title, "reassure": reassure, "status": status, "ts": int(time.time() * 1000)}
334
+ payload = {"title": title, "reassure": reassure, "status": status,
335
+ "ts": ts_ms or int(time.time() * 1000)}
330
336
  for p in self._pages():
331
337
  try:
332
338
  p.evaluate(PAINT_EXPR, payload)
@@ -363,14 +369,35 @@ def _safe_mtime(p: str) -> float:
363
369
  return 0.0
364
370
 
365
371
 
372
+ _LOG_NAME_RE = re.compile(r"^twitter-cycle-\d{4}-\d{2}-\d{2}_\d{6}\.log$")
373
+ _log_cache: dict = {"dir_mtime": None, "newest": None}
374
+
375
+
366
376
  def _latest_cycle_log() -> Path | None:
367
- files = glob.glob(str(LOG_DIR / "twitter-cycle-*.log"))
368
- if not files:
377
+ # LOG_DIR holds tens of thousands of cycle logs, so stat-ing each one every
378
+ # tick is expensive. Names embed the start time (lexicographic max = newest),
379
+ # and the directory's mtime changes only when a log is created or removed,
380
+ # so rescan only then.
381
+ try:
382
+ dir_mtime = os.stat(LOG_DIR).st_mtime
383
+ except OSError:
369
384
  return None
370
- newest = max(files, key=_safe_mtime)
371
- # The winner could STILL have been deleted between selection and use; the
372
- # caller (_current_status) stats it again, so hand back None if it's gone.
373
- return Path(newest) if os.path.exists(newest) else None
385
+ if dir_mtime != _log_cache["dir_mtime"]:
386
+ newest = None
387
+ try:
388
+ with os.scandir(LOG_DIR) as it:
389
+ for e in it:
390
+ if _LOG_NAME_RE.match(e.name) and (newest is None or e.name > newest):
391
+ newest = e.name
392
+ except OSError:
393
+ return None
394
+ _log_cache.update(dir_mtime=dir_mtime, newest=newest)
395
+ if not _log_cache["newest"]:
396
+ return None
397
+ # The winner could have been deleted since the scan; the caller stats it
398
+ # again, so hand back None if it's gone.
399
+ p = LOG_DIR / _log_cache["newest"]
400
+ return p if p.exists() else None
374
401
 
375
402
 
376
403
  _RE_SCAN = re.compile(r"project='([^']+)'\s+q=(['\"])(.*?)\2\s+kept=(\d+)")
@@ -391,7 +418,7 @@ def _prettify(line: str) -> str | None:
391
418
  kept_txt = f" \u00b7 kept {kept}" if kept != "0" else ""
392
419
  return f"Scanning X \u00b7 {proj} \u00b7 \u201c{query}\u201d{kept_txt}"
393
420
  # A few recognizable phase markers; otherwise show the trimmed tail.
394
- if "posting" in low or "posted reply" in low:
421
+ if ("posting" in low and "without posting" not in low and "not posting" not in low) or "posted reply" in low:
395
422
  return "Posting reply on X\u2026"
396
423
  if "drafting" in low or "draft" in low and "cycle" not in low:
397
424
  return "Drafting replies\u2026"
@@ -517,7 +544,12 @@ def cmd_watch(interval: float = 2.0) -> int:
517
544
 
518
545
  threading.Thread(target=_watchdog, daemon=True).start()
519
546
 
547
+ # Backoff while there is nothing to paint (harness Chrome down, or no http
548
+ # tab): reconnecting every tick spawned a new Playwright node driver each time.
549
+ IDLE_BACKOFF_SEC = 30.0
520
550
  last_status = None
551
+ status_ts = int(time.time() * 1000)
552
+ painted = None # (status, page urls) as of the last paint
521
553
  h: Harness | None = None
522
554
  registered = False
523
555
  try:
@@ -529,21 +561,33 @@ def cmd_watch(interval: float = 2.0) -> int:
529
561
  status = _current_status()
530
562
  except Exception:
531
563
  status = "Working\u2026"
564
+ if status != last_status:
565
+ status_ts = int(time.time() * 1000)
566
+ sleep_for = interval
532
567
  try:
533
568
  if h is None:
534
569
  h = Harness().__enter__()
535
570
  registered = False
571
+ painted = None
572
+ if not h.connected():
573
+ raise RuntimeError("harness Chrome disconnected")
536
574
  if not registered:
537
575
  # Re-register init on each (re)connect so fresh tabs inherit it.
538
576
  h.register_init(TITLE, REASSURE, status)
539
577
  registered = True
540
- # Repaint every tick even when text is unchanged: the timestamp
541
- # reset keeps the heartbeat fresh so the dot never looks dead.
542
- if h.paint(TITLE, REASSURE, status) == 0:
543
- # No live page (all tabs closed/navigating) -> drop & retry.
544
- raise RuntimeError("no live page")
578
+ pages = h._pages()
579
+ if not pages:
580
+ # No http tab (closed or mid-navigation) is a normal idle state:
581
+ # keep the connection and check back slowly.
582
+ sleep_for = IDLE_BACKOFF_SEC
583
+ else:
584
+ # A navigation replaces the document with the init script's
585
+ # seed text, so repaint on URL changes as well as status changes.
586
+ sig = (status, tuple(sorted(p.url for p in pages)))
587
+ if sig != painted and h.paint(TITLE, REASSURE, status, status_ts) > 0:
588
+ painted = sig
545
589
  except Exception:
546
- # Harness down or transient CDP hiccup; tear down and retry next tick.
590
+ # Harness down or CDP hiccup; tear down and retry after a backoff.
547
591
  if h is not None:
548
592
  try:
549
593
  h.__exit__(None, None, None)
@@ -551,10 +595,11 @@ def cmd_watch(interval: float = 2.0) -> int:
551
595
  pass
552
596
  h = None
553
597
  registered = False
598
+ sleep_for = IDLE_BACKOFF_SEC
554
599
  if status != last_status:
555
600
  print(f"[{time.strftime('%H:%M:%S')}] {status}")
556
601
  last_status = status
557
- time.sleep(interval)
602
+ time.sleep(sleep_for)
558
603
  except KeyboardInterrupt:
559
604
  print("\nstopping watch; clearing overlay")
560
605
  finally:
@@ -29,10 +29,31 @@ set -euo pipefail
29
29
  # `*social-autoposter` dir that actually carries a manifest.json.
30
30
  APP_SUPPORT="$HOME/Library/Application Support"
31
31
  EXT_DIR=""
32
- for d in "$APP_SUPPORT"/Claude*/"Claude Extensions"/*social-autoposter; do
33
- [ -f "$d/manifest.json" ] || continue
34
- if [ -z "$EXT_DIR" ] || [ "$d" -nt "$EXT_DIR" ]; then EXT_DIR="$d"; fi
35
- done
32
+ # LIVE profile first (2026-09-28): on a multi-profile machine (account rotator:
33
+ # Claude, Claude-mediar, Claude-mddy, ...) every profile keeps its OWN copy of
34
+ # the extension, and "newest mtime" is just the profile updated most recently,
35
+ # not the one Claude is running right now. The operator Mac sat on 1.7.7-rc.4
36
+ # in the live Claude-mediar profile for two months while every update landed
37
+ # in Claude-mddy, and this script kept printing "already on latest". Prefer
38
+ # the extension dir under a RUNNING Claude's --user-data-dir (no flag == plain
39
+ # "Claude"); fall back to the newest-mtime scan only when nothing is running.
40
+ # Mirrors mcp/menubar/s4l_menubar.py::_ext_dir; keep the two in sync.
41
+ while IFS= read -r line; do
42
+ case "$line" in *"/Claude.app/Contents/MacOS/Claude"*) ;; *) continue ;; esac
43
+ udd="$(printf '%s' "$line" | sed -n 's/.*--user-data-dir=\(.*\)$/\1/p' | sed 's/ --.*$//')"
44
+ root="${udd:-$APP_SUPPORT/Claude}/Claude Extensions"
45
+ for d in "$root"/*social-autoposter; do
46
+ [ -f "$d/manifest.json" ] || continue
47
+ EXT_DIR="$d"; break
48
+ done
49
+ [ -n "$EXT_DIR" ] && break
50
+ done < <(ps -axo command 2>/dev/null)
51
+ if [ -z "$EXT_DIR" ]; then
52
+ for d in "$APP_SUPPORT"/Claude*/"Claude Extensions"/*social-autoposter; do
53
+ [ -f "$d/manifest.json" ] || continue
54
+ if [ -z "$EXT_DIR" ] || [ "$d" -nt "$EXT_DIR" ]; then EXT_DIR="$d"; fi
55
+ done
56
+ fi
36
57
  # Last-resort fallback to the historical path so behavior is unchanged on old boxes.
37
58
  [ -n "$EXT_DIR" ] || EXT_DIR="$APP_SUPPORT/Claude/Claude Extensions/local.mcpb.m13v.social-autoposter"
38
59
  PY="/usr/bin/python3"
@@ -89,11 +89,6 @@ def _registry_worker_recs(by_id):
89
89
  return recs
90
90
  return None
91
91
 
92
- # A worker task whose lastRunAt is within this many seconds counts as "firing".
93
- # 7 min tolerates the host's per-task throttle + Claude restart gaps without a
94
- # false "not scheduled".
95
- FIRING_WINDOW = 420
96
-
97
92
  # --- Stall thresholds (single source of truth) --------------------------------
98
93
  # The longest a fully healthy drain can take end to end: a draft job can wait
99
94
  # minutes for a worker slot (per-minute scheduler cadence, workers busy on other
@@ -116,6 +111,15 @@ RUNNING_STALL_SECONDS = HEALTHY_DRAIN_MAX_SECONDS
116
111
  # is the only timer that spans claim→die→requeue churn loops, which reset the two
117
112
  # per-state timers above on every transition while nothing ever completes.
118
113
  DRAFT_STUCK_SECONDS = HEALTHY_DRAIN_MAX_SECONDS
114
+ # A worker task whose lastRunAt is within this many seconds counts as "firing".
115
+ # The host scheduler serializes runs of one task (overlap guard), so lastRunAt
116
+ # stays frozen for the whole duration of an in-flight run. A healthy draft run
117
+ # can take up to HEALTHY_DRAIN_MAX_SECONDS, so the window must be at least that
118
+ # long; the old 7-minute window flipped tasks_scheduled to "stalled" mid-run on
119
+ # every 8-15 minute drain (operator Mac, 2026-09-28: checklist showed 8/10 with
120
+ # the worker alive and firing between jobs). A genuinely wedged scheduler still
121
+ # trips once the drain budget lapses.
122
+ FIRING_WINDOW = HEALTHY_DRAIN_MAX_SECONDS
119
123
 
120
124
  # Grace for a JUST-scheduled task that hasn't fired yet. When the user runs
121
125
  # "Set up draft schedule", create_scheduled_task registers both worker tasks
@@ -1258,6 +1258,12 @@ for q in queries:
1258
1258
  dt = time.time() - t0
1259
1259
  print(f' err project={project!r} q={query[:50]!r} in {dt:.1f}s {type(e).__name__}: {e}', flush=True)
1260
1260
  PY
1261
+ # Park the X tab now: DRAFT_ONLY and empty-plan cycles exit before Phase 2b,
1262
+ # so twitter_browser.py's at-exit parker never runs and the tab would sit on
1263
+ # a live x.com search (scripts + service worker running) until the next
1264
+ # cycle. robots.txt, never about:blank (focus steal; see browser_lifecycle.py).
1265
+ [ -z "${S4L_NO_TAB_PARK:-}" ] && python3 -c "import sys; sys.path.insert(0, sys.argv[1]); from browser_lifecycle import park_tabs; park_tabs(sys.argv[2], ('x.com', 'twitter.com'), 'https://x.com/robots.txt', 'twitter_scan')" \
1266
+ "$REPO_DIR/scripts" "${TWITTER_CDP_URL:-http://127.0.0.1:9555}" 2>&1 | tee -a "$LOG_FILE" || true
1261
1267
  fi
1262
1268
  rm -f "$QUERIES_TMP"
1263
1269