@m13v/s4l 1.7.6-rc.8 → 1.7.6

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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +33 -9
  3. package/mcp/dist/index.js +290 -177
  4. package/mcp/dist/panel.html +31 -17
  5. package/mcp/dist/setup.js +27 -7
  6. package/mcp/dist/telemetry.js +15 -6
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +10 -2
  9. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  10. package/mcp/menubar/s4l_card.py +63 -4
  11. package/mcp/menubar/s4l_card_canvas.py +62 -11
  12. package/mcp/menubar/s4l_menubar.py +380 -95
  13. package/mcp/menubar/s4l_state.py +119 -41
  14. package/mcp/package.json +1 -1
  15. package/package.json +1 -1
  16. package/scripts/account_resolver.py +58 -14
  17. package/scripts/amplitude_24h_signups.py +7 -4
  18. package/scripts/amplitude_signups.py +7 -4
  19. package/scripts/autopilot_stall_watch.py +8 -1
  20. package/scripts/browser_lifecycle.py +90 -0
  21. package/scripts/cdp_ready_check.py +81 -50
  22. package/scripts/claude_job.py +70 -10
  23. package/scripts/dm_conversation.py +15 -12
  24. package/scripts/draft_prompt_core.py +132 -64
  25. package/scripts/engage_github.py +11 -5
  26. package/scripts/engage_reddit.py +12 -5
  27. package/scripts/engagement_signal_feedback.py +1 -4
  28. package/scripts/engagement_styles.py +144 -105
  29. package/scripts/feedback_digest.py +54 -11
  30. package/scripts/find_threads.py +9 -7
  31. package/scripts/github_tools.py +23 -10
  32. package/scripts/learned_preferences.py +112 -78
  33. package/scripts/li_discovery.py +4 -1
  34. package/scripts/link_tail.py +1 -1
  35. package/scripts/live_status.py +151 -0
  36. package/scripts/merge_review_queue.py +15 -2
  37. package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
  38. package/scripts/pick_project.py +7 -4
  39. package/scripts/pick_thread_target.py +64 -13
  40. package/scripts/pick_twitter_thread_target.py +7 -4
  41. package/scripts/platform_strike_events.py +139 -7
  42. package/scripts/post_github.py +376 -35
  43. package/scripts/post_reddit.py +202 -192
  44. package/scripts/preflight.sh +23 -3
  45. package/scripts/preview_reddit_card.py +38 -0
  46. package/scripts/project_stats.py +7 -4
  47. package/scripts/reddit_ban_check.py +324 -0
  48. package/scripts/reddit_browser.py +56 -19
  49. package/scripts/reddit_chat_sync.py +10 -11
  50. package/scripts/reddit_tools.py +20 -4
  51. package/scripts/release-mcpb.sh +47 -7
  52. package/scripts/run_moltbook_cycle.py +9 -5
  53. package/scripts/s4l_ctl.sh +2 -2
  54. package/scripts/s4l_posting_mode.py +67 -6
  55. package/scripts/scan_dm_candidates.py +15 -9
  56. package/scripts/scan_github_replies.py +9 -7
  57. package/scripts/scan_moltbook_replies.py +7 -6
  58. package/scripts/scan_reddit_replies.py +11 -7
  59. package/scripts/scan_twitter_mentions_browser.py +7 -6
  60. package/scripts/scan_twitter_thread_followups.py +7 -6
  61. package/scripts/schedule_state.py +8 -4
  62. package/scripts/score_twitter_candidates.py +8 -3
  63. package/scripts/setup_twitter_auth.py +20 -96
  64. package/scripts/snapshot.py +19 -0
  65. package/scripts/stats.py +12 -10
  66. package/scripts/strike_alert.py +165 -12
  67. package/scripts/sub_moderation_history.py +129 -0
  68. package/scripts/subreddit_bans_client.py +157 -0
  69. package/scripts/test_cross_language_parity.py +185 -0
  70. package/scripts/test_no_silent_fallbacks.py +84 -0
  71. package/scripts/test_percard_posting.py +3 -3
  72. package/scripts/test_stall_watch_batch_progression.py +99 -0
  73. package/scripts/top_performers.py +24 -0
  74. package/scripts/twitter_browser.py +22 -52
  75. package/scripts/twitter_gen_links.py +3 -3
  76. package/scripts/twitter_post_plan.py +4 -4
  77. package/scripts/virality.py +151 -0
  78. package/setup/SKILL.md +1 -1
  79. package/skill/engage-dm-replies.sh +11 -0
  80. package/skill/lib/reddit-backend.sh +1 -0
  81. package/skill/run-draft-and-publish.sh +16 -0
  82. package/skill/run-reddit-search-launchd.sh +1 -0
  83. package/skill/run-reddit-search.sh +10 -1
  84. package/skill/run-reddit-threads.sh +12 -0
  85. package/skill/run-twitter-cycle.sh +104 -88
@@ -1,44 +1,61 @@
1
1
  #!/usr/bin/env python3
2
- """Real CDP readiness probe for the harness Chrome.
2
+ """Raw-websocket CDP readiness probe for the harness Chrome.
3
3
 
4
- Exit 0 when a full Playwright connect_over_cdp handshake completes against the
5
- given CDP URL, 1 when it does not. /json/version alone is a LIVENESS check: a
6
- wedged Chrome (process alive, HTTP answering, websocket upgrade completing,
7
- but the browser loop never servicing the CDP session) passes it, and every
8
- downstream attach then eats Playwright's 180s default timeout while holding
9
- the browser lock (S4L-4H, Karol 2026-07-11; identical wedge locally 2026-07-09,
10
- twice, same Chrome instance both times).
4
+ Exit 0 when a real DevTools websocket handshake completes against the given
5
+ CDP URL (browser-level connect + Browser.getVersion round-trip), 1 when it
6
+ does not. /json/version alone is a LIVENESS check: a Chrome whose ws accept
7
+ path died still passes it; this probe exercises the actual session dispatch.
11
8
 
12
- Usage: cdp_ready_check.py [CDP_URL] [TIMEOUT_MS]
9
+ HISTORY — why raw websocket and NOT Playwright (2026-07-17): the original
10
+ probe used sync_playwright().connect_over_cdp. Wedge-diag captures
11
+ (skill/logs/wedge-diag/, v3 discriminator) proved that during every observed
12
+ "wedge" the raw ws handshake completed in <5s while the Playwright connect
13
+ timed out at 20s — with Chrome's DevToolsHandlerThread idle in kevent64 the
14
+ whole time. The stall lives in Playwright's node driver under machine load,
15
+ NOT in Chrome; the Playwright-based verdict was executing a healthy browser
16
+ roughly hourly for days (every cdp_wedge relaunch 07-14..07-17). A probe must
17
+ not depend on machinery heavier than the thing it probes. The S4L-4H /
18
+ Karol-box incidents that motivated the deep probe are equally covered by the
19
+ ws round-trip below (and in hindsight may have been this same client-side
20
+ stall).
21
+
22
+ Also preserves the renderer-liveness sweep (2026-07-14): a tab whose RENDERER
23
+ crashed ("Aw, Snap") keeps its title/url in every listing while sitting dead;
24
+ probe each page with a trivial Runtime.evaluate and reload it IN PLACE on
25
+ failure — fresh renderer, no kill, no new window, no focus change.
13
26
 
14
- Prints a one-line JSON verdict to stdout so the caller can persist it
15
- (twitter-backend.sh writes it to skill/logs/cdp-health.json, which
16
- memory_snapshot.py carries onto the per-minute heartbeat sample).
27
+ Usage: cdp_ready_check.py [CDP_URL] [TIMEOUT_MS]
17
28
 
18
- Falls back to an HTTP-only probe when playwright is not importable under the
19
- invoking interpreter, so a bare python3 caller degrades to the legacy
20
- liveness behavior instead of hard-failing.
29
+ Prints a one-line JSON verdict to stdout (same shape/keys as before; mode is
30
+ now "raw_ws"). Falls back to an HTTP-only probe when websocket-client is not
31
+ importable, so a bare python3 caller degrades to liveness instead of failing.
21
32
  """
22
33
  import json
23
34
  import sys
24
35
  import time
36
+ import urllib.request
37
+
38
+ # ProxyHandler({}): loopback CDP must never route through a proxy. macOS
39
+ # system proxy settings leak into urllib's default opener, and a box-wide
40
+ # forwarder 403s 127.0.0.1 probes (2026-07-13 root cause of a "wedged
41
+ # Chrome" misdiagnosis).
42
+ _OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
43
+
44
+
45
+ def _get_json(url: str, timeout: float):
46
+ with _OPENER.open(url, timeout=timeout) as r:
47
+ return json.loads(r.read())
25
48
 
26
49
 
27
50
  def main() -> int:
28
51
  url = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:9555").rstrip("/")
29
- timeout_ms = int(sys.argv[2]) if len(sys.argv) > 2 else 8000
52
+ timeout_s = (int(sys.argv[2]) if len(sys.argv) > 2 else 20000) / 1000.0
30
53
  t0 = time.time()
31
54
  try:
32
- from playwright.sync_api import sync_playwright
55
+ import websocket # websocket-client, present in the owned runtime venv
33
56
  except Exception:
34
- import urllib.request
35
57
  try:
36
- # ProxyHandler({}): loopback CDP must never route through a proxy.
37
- # macOS system proxy settings leak into urllib's default opener, and
38
- # a box-wide forwarder 403s 127.0.0.1 probes (2026-07-13 root cause
39
- # of the "wedged Chrome" misdiagnosis).
40
- opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
41
- opener.open(f"{url}/json/version", timeout=3)
58
+ _OPENER.open(f"{url}/json/version", timeout=3)
42
59
  print(json.dumps({"ready": True, "mode": "http-only"}))
43
60
  return 0
44
61
  except Exception as e:
@@ -46,35 +63,49 @@ def main() -> int:
46
63
  "ready": False, "mode": "http-only", "error": str(e)[:120],
47
64
  }))
48
65
  return 1
66
+
67
+ def _ws_call(ws_url: str, method: str, timeout: float, **params):
68
+ # suppress_origin: Chrome 111+ rejects ws clients whose Origin header
69
+ # is not in --remote-allow-origins (same pattern as the tab parker).
70
+ ws = websocket.create_connection(ws_url, timeout=timeout, suppress_origin=True)
71
+ try:
72
+ ws.send(json.dumps({"id": 1, "method": method, "params": params}))
73
+ deadline = time.time() + timeout
74
+ while time.time() < deadline:
75
+ msg = json.loads(ws.recv())
76
+ if msg.get("id") == 1:
77
+ return msg
78
+ raise TimeoutError(f"no reply to {method}")
79
+ finally:
80
+ ws.close()
81
+
49
82
  try:
50
- with sync_playwright() as p:
51
- browser = p.chromium.connect_over_cdp(url, timeout=timeout_ms)
52
- n_contexts = len(browser.contexts)
53
- # Renderer-liveness sweep (2026-07-14). A tab whose RENDERER
54
- # crashed ("Aw, Snap", error code 5) keeps its normal title/url in
55
- # every CDP listing and the browser-level handshake stays green,
56
- # so it sat visibly dead for 20+ minutes with nothing entitled to
57
- # touch it — below the wedge detector (browser-level) and the
58
- # stall guard (scan-progress-level). Probe each page with a
59
- # trivial evaluate; on failure reload the tab IN PLACE, which
60
- # spawns a fresh renderer. No kill, no new window, no focus
61
- # change. Best-effort: revival must never fail the readiness
62
- # verdict the wedge gate depends on.
63
- revived = 0
64
- for ctx in browser.contexts:
65
- for page in ctx.pages:
83
+ # Browser-level handshake: the readiness verdict.
84
+ info = _get_json(f"{url}/json/version", timeout=3)
85
+ _ws_call(info["webSocketDebuggerUrl"], "Browser.getVersion", timeout_s)
86
+
87
+ # Renderer-liveness sweep: revive crashed tabs in place, best-effort;
88
+ # revival must never fail the readiness verdict the wedge gate uses.
89
+ revived = 0
90
+ pages = []
91
+ try:
92
+ pages = [t for t in _get_json(f"{url}/json/list", timeout=3)
93
+ if t.get("type") == "page" and t.get("webSocketDebuggerUrl")]
94
+ for t in pages:
95
+ try:
96
+ _ws_call(t["webSocketDebuggerUrl"], "Runtime.evaluate",
97
+ 4.0, expression="1")
98
+ except Exception:
66
99
  try:
67
- page.set_default_timeout(4000)
68
- page.evaluate("1")
100
+ _ws_call(t["webSocketDebuggerUrl"], "Page.reload", 8.0)
101
+ revived += 1
69
102
  except Exception:
70
- try:
71
- page.reload(timeout=15000, wait_until="commit")
72
- revived += 1
73
- except Exception:
74
- pass
75
- browser.close()
103
+ pass
104
+ except Exception:
105
+ pass
106
+
76
107
  out = {
77
- "ready": True, "mode": "cdp", "contexts": n_contexts,
108
+ "ready": True, "mode": "raw_ws", "contexts": len(pages),
78
109
  "elapsed_s": round(time.time() - t0, 2),
79
110
  }
80
111
  if revived:
@@ -83,7 +114,7 @@ def main() -> int:
83
114
  return 0
84
115
  except Exception as e:
85
116
  print(json.dumps({
86
- "ready": False, "mode": "cdp",
117
+ "ready": False, "mode": "raw_ws",
87
118
  "elapsed_s": round(time.time() - t0, 2),
88
119
  "error": str(e)[:200].replace("\n", " "),
89
120
  }))
@@ -608,14 +608,26 @@ def _atomic_write_text(path: str, text: str) -> None:
608
608
 
609
609
 
610
610
  def _sweep_stale() -> int:
611
- """Remove pending/running job files older than STALE_TTL_S. Returns count."""
611
+ """GC job files orphaned by dead producers. Returns count removed.
612
+
613
+ Two different TTLs on purpose:
614
+ - pending/: a job unclaimed past the producer's whole wait window
615
+ (DEFAULT_TIMEOUT_S + margin) has no live producer left to consume its
616
+ result — drafting it later only burns a worker turn on a stale prompt.
617
+ This is the leak signature of a producer that was SIGKILLed by its
618
+ external wrapper before its own timeout-cleanup ran (2026-07-18: three
619
+ orphaned reddit-draft jobs piled up while paused).
620
+ - running/: a claimed job may legitimately still be drafting, so it gets
621
+ the longer STALE_TTL_S before we conclude the worker died too.
622
+ """
612
623
  removed = 0
613
624
  now = time.time()
614
- roots = [running_dir()]
625
+ pending_ttl = DEFAULT_TIMEOUT_S + 120
626
+ roots = [(running_dir(), STALE_TTL_S)]
615
627
  pend = os.path.join(queue_root(), "pending")
616
628
  if os.path.isdir(pend):
617
- roots += [os.path.join(pend, d) for d in os.listdir(pend)]
618
- for d in roots:
629
+ roots += [(os.path.join(pend, d), pending_ttl) for d in os.listdir(pend)]
630
+ for d, ttl in roots:
619
631
  if not os.path.isdir(d):
620
632
  continue
621
633
  for name in os.listdir(d):
@@ -625,7 +637,7 @@ def _sweep_stale() -> int:
625
637
  try:
626
638
  with open(fp) as f:
627
639
  created = json.load(f).get("created_at", 0)
628
- if now - float(created) > STALE_TTL_S:
640
+ if now - float(created) > ttl:
629
641
  os.remove(fp)
630
642
  removed += 1
631
643
  except Exception:
@@ -719,6 +731,13 @@ def cmd_provider(ns) -> int:
719
731
  % (_before_len - len(prompt))
720
732
  )
721
733
 
734
+ # S4L paused: refuse to enqueue at all. Workers won't claim while paused, so
735
+ # an enqueued job would just sit in pending/ while this producer burns its
736
+ # whole wait window "drafting" and then dies to its external timeout.
737
+ if _is_paused():
738
+ _plog(f"S4L paused; refusing to enqueue {qtype} job")
739
+ return 1
740
+
722
741
  job_id = uuid.uuid4().hex
723
742
  created = time.time()
724
743
  # Cycle batch id (run-twitter-cycle.sh exports BATCH_ID) so every provider.log
@@ -749,11 +768,31 @@ def cmd_provider(ns) -> int:
749
768
  _act_write(qtype)
750
769
 
751
770
  res_path = os.path.join(result_dir(), f"{job_id}.json")
752
- deadline = created + ns.timeout
771
+ # Give up before ns.timeout, not at it: external wrappers (post_reddit's
772
+ # subprocess timeout, gtimeout in shell lanes) start their clocks at
773
+ # process start — before stdin read + enqueue — so an internal deadline of
774
+ # exactly created + timeout LOSES that race and the producer gets SIGKILLed
775
+ # before the cleanup below ever runs (2026-07-18 deathwatch "unexpected
776
+ # death" at enqueue+1800s, leaking pending files and a stuck "drafting"
777
+ # label). The 60s margin guarantees the graceful path wins.
778
+ deadline = created + max(60, ns.timeout - 60)
753
779
  last_hb = created # last menu-bar heartbeat (see below)
754
780
  claimed_at = None # set the moment a worker moves the job pending/ -> running/
755
781
  while time.time() < deadline:
756
782
  now = time.time()
783
+ # Pause landed mid-wait: abandon immediately with the same cleanup the
784
+ # timeout path does, instead of blocking the full wait window against a
785
+ # queue no worker will drain while paused.
786
+ if _is_paused():
787
+ _plog(f"job {job_id} abandoned: S4L paused mid-wait; cleaning up")
788
+ for p in (pending_path, running_path):
789
+ try:
790
+ os.remove(p)
791
+ except OSError:
792
+ pass
793
+ _act_clear()
794
+ _disarm_deathwatch(job_id)
795
+ return 1
757
796
  # A worker claims a job by atomically renaming pending/ -> running/, so the
758
797
  # pending file vanishing is our signal that drafting actually STARTED (vs.
759
798
  # the job still sitting unclaimed). Latch the claim time once so the label
@@ -972,12 +1011,26 @@ def _attempt_claim(ns, qtype: str) -> bool:
972
1011
  return False
973
1012
 
974
1013
 
1014
+ def _is_paused() -> bool:
1015
+ # S4L paused (mcp/src/index.ts pauseS4L / the menubar pause button): the
1016
+ # one flag file every pipeline surface honors.
1017
+ return os.path.exists(os.path.join(state_dir(), "paused.flag"))
1018
+
1019
+
975
1020
  def cmd_next(ns) -> int:
976
1021
  _apply_state_dir_override(ns)
977
- # S4L paused (mcp/src/index.ts pauseAutopilot): report no work instead of
978
- # claiming a job, so an already-queued draft can't drain/post while paused
979
- # even though the scheduled task worker still fires on its own cadence.
980
- if os.path.exists(os.path.join(state_dir(), "paused.flag")):
1022
+ # GC jobs orphaned by dead/killed producers BEFORE claiming (and before the
1023
+ # pause check, so orphans left behind by a pause don't sit until the next
1024
+ # enqueue-side sweep — while paused nothing enqueues, so this is the only
1025
+ # sweeper running). Without this, a resume would hand workers stale jobs
1026
+ # whose producer died long ago.
1027
+ swept = _sweep_stale()
1028
+ if swept:
1029
+ _plog(f"swept {swept} stale queue job file(s) before claim pass")
1030
+ # S4L paused: report no work instead of claiming a job, so an
1031
+ # already-queued draft can't drain/post while paused even though the
1032
+ # scheduled task worker still fires on its own cadence.
1033
+ if _is_paused():
981
1034
  print(json.dumps({}))
982
1035
  return 0
983
1036
  qtype = ns.type
@@ -997,6 +1050,13 @@ def cmd_next(ns) -> int:
997
1050
  start = time.time()
998
1051
  attempt = 0
999
1052
  while True:
1053
+ # Re-check pause on every iteration, not just at entry: a worker
1054
+ # already inside its wait window when Pause is clicked must stop
1055
+ # claiming immediately (observed 2026-07-18: pause at 12:24, a
1056
+ # pre-pause poller claimed a reddit-draft job at 12:29 anyway).
1057
+ if _is_paused():
1058
+ _plog(f"polling {qtype}: paused.flag appeared mid-wait; exiting without claiming")
1059
+ break
1000
1060
  if _attempt_claim(ns, qtype):
1001
1061
  return 0
1002
1062
  attempt += 1
@@ -42,7 +42,13 @@ import sys
42
42
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
43
43
  import db as dbmod
44
44
 
45
- CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
45
+ # THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
46
+ # S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
47
+ # its hardcoded config path (the S4L-4H dead-path class on customer boxes).
48
+ import os as _cfg_os, sys as _cfg_sys
49
+ _cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
50
+ from config import config_path as _canonical_config_path, load_config
51
+ CONFIG_PATH = _canonical_config_path()
46
52
 
47
53
 
48
54
  def _valid_chat_url(platform, url):
@@ -76,24 +82,21 @@ def _valid_chat_url(platform, url):
76
82
  return u
77
83
 
78
84
 
79
- def load_config():
80
- if os.path.exists(CONFIG_PATH):
81
- with open(CONFIG_PATH) as f:
82
- return json.load(f)
83
- return {}
84
85
 
85
86
 
86
87
  def get_our_account(config, platform):
87
88
  accounts = config.get("accounts", {})
89
+ # No hardcoded fallback on any platform: a default handle/username/name stamped
90
+ # on a DM silently mis-attributes it to the repo owner. Resolve from config / env
91
+ # through the one resolver; "" means unknown (caller decides how to degrade).
92
+ from account_resolver import resolve as _resolve_account
88
93
  if platform == "reddit":
89
- return accounts.get("reddit", {}).get("username", "Deep_Ad1959")
94
+ return accounts.get("reddit", {}).get("username") or _resolve_account("reddit") or ""
90
95
  elif platform == "linkedin":
91
- return accounts.get("linkedin", {}).get("name", "Matthew Diakonov")
96
+ return accounts.get("linkedin", {}).get("name") or _resolve_account("linkedin") or ""
92
97
  elif platform == "x":
93
- # No hardcoded fallback: stamping a default handle on an outbound DM
94
- # silently mis-attributes it to the repo owner. Resolve from config / env
95
- # and fail loud if absent.
96
- from account_resolver import resolve as _resolve_account
98
+ # Twitter fails LOUD if unresolved (an outbound DM must carry a real
99
+ # sender); reddit/linkedin above degrade to "" since their callers can.
97
100
  h = _resolve_account("twitter")
98
101
  if not h:
99
102
  raise RuntimeError(