@m13v/s4l 1.7.6-rc.2 → 1.7.6-rc.21

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 (35) hide show
  1. package/mcp/dist/index.js +256 -92
  2. package/mcp/dist/telemetry.js +2 -1
  3. package/mcp/dist/version.json +2 -2
  4. package/mcp/manifest.json +1 -1
  5. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  6. package/mcp/menubar/s4l_card.py +144 -18
  7. package/mcp/menubar/s4l_card_canvas.py +135 -14
  8. package/mcp/menubar/s4l_menubar.py +216 -73
  9. package/mcp/menubar/s4l_state.py +45 -130
  10. package/mcp/package.json +1 -1
  11. package/package.json +1 -1
  12. package/scripts/account_resolver.py +35 -1
  13. package/scripts/autopilot_stall_watch.py +8 -1
  14. package/scripts/browser_lifecycle.py +90 -0
  15. package/scripts/cdp_ready_check.py +81 -50
  16. package/scripts/draft_prompt_core.py +100 -17
  17. package/scripts/engagement_styles.py +135 -98
  18. package/scripts/merge_review_queue.py +192 -129
  19. package/scripts/platform_strike_events.py +54 -5
  20. package/scripts/post_reddit.py +10 -0
  21. package/scripts/preview_reddit_card.py +38 -0
  22. package/scripts/reap_stale_claude_sessions.py +7 -6
  23. package/scripts/reddit_ban_check.py +220 -0
  24. package/scripts/reddit_browser.py +24 -0
  25. package/scripts/release-mcpb.sh +38 -7
  26. package/scripts/setup_twitter_auth.py +20 -96
  27. package/scripts/store_patch.py +111 -0
  28. package/scripts/strike_alert.py +161 -12
  29. package/scripts/test_no_silent_fallbacks.py +11 -0
  30. package/scripts/test_stall_watch_batch_progression.py +99 -0
  31. package/scripts/top_performers.py +24 -0
  32. package/scripts/twitter_browser.py +22 -52
  33. package/skill/archive-old-logs.sh +24 -0
  34. package/skill/lib/reddit-backend.sh +1 -0
  35. package/skill/run-twitter-cycle.sh +146 -66
@@ -43,10 +43,6 @@ try:
43
43
  except Exception:
44
44
  pass
45
45
 
46
- # Serializes read-modify-write on approved-queue.json. The menu bar's main thread
47
- # (approve click / restart resume) and the post-worker thread (status updates)
48
- # both mutate it; without this a concurrent interleave would drop an approval.
49
- _approved_lock = threading.Lock()
50
46
 
51
47
  # Mirrors shared/onboarding-ledger.cjs MILESTONES (same order). The ledger's
52
48
  # OPTIONAL milestones (reddit_connected / reddit_verified) are deliberately NOT
@@ -281,19 +277,39 @@ def autopilot_loaded() -> bool:
281
277
  _NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
282
278
 
283
279
 
280
+ # /health is a trivial constant response, so any real latency is the OWNING MCP
281
+ # process being briefly CPU-starved or paged out under machine load (Nhat's box:
282
+ # ~8.6GB swap, 39% mem pressure, 582 procs), not the endpoint doing work. The old
283
+ # single 1.5s attempt flapped a perfectly-alive server to "unreachable" on one
284
+ # transient stall, and a false negative here strands approved drafts (see the
285
+ # "dead pointer" note in mcp/src/index.ts). So probe forgivingly: a longer
286
+ # deadline plus ONE retry. A genuinely-dead endpoint fails fast either way
287
+ # (ECONNREFUSED returns immediately, both attempts), so this only rescues live
288
+ # servers -- it never lets a real outage look healthy.
289
+ _HEALTH_TIMEOUT_S = 4.0
290
+ _HEALTH_ATTEMPTS = 2
291
+ _HEALTH_RETRY_BACKOFF_S = 0.15
292
+
293
+
284
294
  def _endpoint_url():
285
295
  ep = read_json("panel-endpoint.json")
286
296
  url = (ep or {}).get("url")
287
297
  if not url:
288
298
  return None
289
- try:
290
- with _NO_PROXY_OPENER.open(url + "health", timeout=1.5) as r:
291
- if r.status == 200:
292
- return url
293
- except Exception as e:
294
- sys.stderr.write(f"[s4l-state] loopback health check failed: {type(e).__name__}: {e}\n")
295
- sys.stderr.flush()
296
- return None
299
+ last = None
300
+ for attempt in range(_HEALTH_ATTEMPTS):
301
+ try:
302
+ with _NO_PROXY_OPENER.open(url + "health", timeout=_HEALTH_TIMEOUT_S) as r:
303
+ return url if r.status == 200 else None
304
+ except Exception as e:
305
+ last = e
306
+ if attempt + 1 < _HEALTH_ATTEMPTS:
307
+ time.sleep(_HEALTH_RETRY_BACKOFF_S)
308
+ sys.stderr.write(
309
+ f"[s4l-state] loopback health check failed after {_HEALTH_ATTEMPTS} "
310
+ f"attempts: {type(last).__name__}: {last}\n"
311
+ )
312
+ sys.stderr.flush()
297
313
  return None
298
314
 
299
315
 
@@ -982,89 +998,23 @@ def review_queue_posted_count():
982
998
  return sum(1 for c in cands if candidate_state(c) == "posted")
983
999
 
984
1000
 
985
- def _plan_generation(batch):
986
- """created_at of the CURRENT review plan for this batch (stamped by
987
- merge_review_queue.py when it starts a fresh plan), or None for plans
988
- written before generation stamping existed.
989
-
990
- Why this matters: the plan lives in /tmp and dies on every reboot or tmp
991
- sweep, and its candidate numbering restarts at 1, while approved-queue.json
992
- lives in the state dir forever. Without a generation marker, ledger entries
993
- from a dead plan match the new plan's low indices and every fresh draft is
994
- treated as already-decided: the 2026-07-03 "unapproved cards never show up"
995
- bug (30 stale entries silently swallowed a whole day of drafts)."""
996
- try:
997
- p = Path(store_path())
998
- if not p.exists():
999
- base = os.environ.get("S4L_TMP_DIR") or "/tmp"
1000
- p = Path(base) / f"twitter_cycle_plan_{batch}.json"
1001
- return json.loads(p.read_text()).get("created_at") or None
1002
- except Exception:
1003
- return None
1004
-
1005
-
1006
- def _ts_before(a, b):
1007
- """True when iso timestamp a is strictly before b. Tolerates the two stamp
1008
- shapes in play ('...Z' from merge_review_queue, '+00:00' from time_iso)."""
1009
- try:
1010
- import datetime
1011
-
1012
- def parse(s):
1013
- return datetime.datetime.fromisoformat(str(s).replace("Z", "+00:00"))
1014
-
1015
- return parse(a) < parse(b)
1016
- except Exception:
1017
- return False
1018
-
1019
-
1020
- def _stale_for_plan(it, gen):
1021
- """A ledger item that predates the current plan generation refers to a DEAD
1022
- plan's numbering; its `n` must never match against the live plan."""
1023
- return bool(gen) and bool(it.get("ts")) and _ts_before(it.get("ts"), gen)
1024
-
1025
-
1026
- def _ledger_items_for_plan(batch, gen):
1027
- """Decided ledger items that belong to the CURRENT plan generation."""
1028
- return [
1029
- it
1030
- for it in read_approved_queue()["items"]
1031
- if it.get("batch") == batch
1032
- and it.get("status") in ("queued", "posting", "posted", "failed", "rejected")
1033
- and not _stale_for_plan(it, gen)
1034
- ]
1035
-
1036
-
1037
1001
  def review_drafts(plan, batch="review-queue"):
1038
1002
  """Flatten a plan into the card model: only UNDECIDED candidates. A card that's
1039
1003
  posted, terminal (rejected/dead), or already approved is a settled decision and
1040
1004
  must never be re-presented for review (approved ones proceed to post).
1041
1005
 
1042
- Also excludes cards with ANY durable decision (approved, edited, rejected, or a
1043
- decided-but-failed post) via _ledger_items_for_plan() below. approve/reject/edit
1044
- each write a durable local record via store_stamp_decision() the INSTANT the
1045
- user clicks (see s4l_menubar.py::_on_card_decision), so a
1046
- decided card never re-presents even if the loopback (Claude Desktop) is down
1047
- when the decision's plan-flag write is attempted. The main plan's
1048
- `approved`/`terminal`/`posted` flags are only stamped once the loopback write
1049
- lands, so without this a card the user just decided would re-present (the exact
1050
- "I already decided these" bug)."""
1051
- gen = (plan or {}).get("created_at") or _plan_generation(batch)
1052
- items = _ledger_items_for_plan(batch, gen)
1053
- settled_ids = {
1054
- it.get("candidate_id") for it in items if it.get("candidate_id") is not None
1055
- }
1056
- settled_ns = {it.get("n") for it in items if it.get("candidate_id") is None}
1006
+ Decision durability lives in the store itself: approve/reject/edit each
1007
+ write approved/terminal + the decision payload via store_stamp_decision()
1008
+ the INSTANT the user clicks (see s4l_menubar.py::_on_card_decision), under
1009
+ the store lock, so candidate_state() alone decides what re-presents. The
1010
+ legacy approved-queue.json ledger cross-check that used to live here was
1011
+ removed 2026-07-17 along with the rest of that third state store."""
1057
1012
  out = []
1058
1013
  for i, c in enumerate(((plan or {}).get("candidates") or [])):
1059
1014
  # Only awaiting_review rows become cards. This also skips post_failed
1060
1015
  # rows (decided-but-failed is settled per the docstring above).
1061
1016
  if candidate_state(c) != "awaiting_review":
1062
1017
  continue
1063
- cid = c.get("candidate_id")
1064
- if cid is not None and cid in settled_ids:
1065
- continue
1066
- if (i + 1) in settled_ns:
1067
- continue
1068
1018
  out.append(
1069
1019
  {
1070
1020
  "n": i + 1, # 1-based, matches post_drafts numbering
@@ -1132,35 +1082,13 @@ def review_drafts(plan, batch="review-queue"):
1132
1082
  return out
1133
1083
 
1134
1084
 
1135
- # ---- durable approved-card queue ------------------------------------------
1136
- # Card approvals MUST survive a menu bar / Claude restart. The in-memory post
1137
- # queue does not: a restart strands every approved-but-unposted card, which then
1138
- # re-presents for approval (the system had no record the user already approved
1139
- # it). This file is the durable record, owned SOLELY by the menu bar — persisting
1140
- # the approval in the main plan instead would race with the autopilot, which
1141
- # rewrites that plan continuously and would silently drop the flag. Status flow:
1142
- # queued -> posting -> posted | failed. review_drafts() excludes queued/posting
1143
- # so an approved card is never re-shown while it drains; a restart re-enqueues
1144
- # queued/posting items instead of re-presenting them.
1145
- APPROVED_QUEUE = "approved-queue.json"
1146
-
1147
-
1148
- def read_approved_queue():
1149
- d = read_json(APPROVED_QUEUE)
1150
- if not isinstance(d, dict) or not isinstance(d.get("items"), list):
1151
- return {"items": []}
1152
- return d
1153
-
1154
-
1155
- def _write_approved_queue(d):
1156
- try:
1157
- p = Path(state_dir()) / APPROVED_QUEUE
1158
- p.parent.mkdir(parents=True, exist_ok=True)
1159
- tmp = p.with_suffix(".json.tmp")
1160
- tmp.write_text(json.dumps(d))
1161
- os.replace(str(tmp), str(p)) # atomic: a crash never leaves a half file
1162
- except Exception:
1163
- pass
1085
+ # ---- durable approved-card queue: REMOVED (2026-07-17) ---------------------
1086
+ # approved-queue.json was a second local ledger from before the review store
1087
+ # stamped decisions durably itself (store_stamp_decision, under the store
1088
+ # lock). Once decisions lived in the store, nothing appended to the ledger,
1089
+ # every status write was a no-op over a frozen item list, and its read lane
1090
+ # re-enqueued dead approvals forever. The review store is the ONLY local
1091
+ # decision record now; do not add a second one back.
1164
1092
 
1165
1093
 
1166
1094
  # ---- Engagement mode (2026-06-26, dual-flag 2026-06-29) -------------------
@@ -1422,23 +1350,10 @@ def toggle_lane(lane):
1422
1350
  return write_flags(f["personal_brand"], f["promotion"])
1423
1351
 
1424
1352
 
1425
- def approved_queue_set_status(batch, n, status, error=None):
1426
- with _approved_lock:
1427
- d = read_approved_queue()
1428
- changed = False
1429
- for it in d["items"]:
1430
- if it.get("batch") == batch and it.get("n") == n:
1431
- it.update(status=status, error=error, ts=time_iso())
1432
- changed = True
1433
- if changed:
1434
- _write_approved_queue(d)
1435
-
1436
-
1437
- def approved_queue_pending():
1438
- """Approvals not yet confirmed posted (queued or posting). Re-enqueued by the
1439
- menu bar on startup so a restart RESUMES the drain instead of re-presenting."""
1440
- return [it for it in read_approved_queue()["items"]
1441
- if it.get("status") in ("queued", "posting")]
1353
+ # approved_queue_set_status() / approved_queue_pending() REMOVED (2026-07-17):
1354
+ # the legacy approved-queue.json ledger is gone entirely — see the removal note
1355
+ # above read-approved-queue's old location. The store lane (store_stamp_decision
1356
+ # + store_pending_posts) is the only decision record and resume source now.
1442
1357
 
1443
1358
 
1444
1359
  def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.2",
3
+ "version": "1.7.6-rc.21",
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.6-rc.2",
3
+ "version": "1.7.6-rc.21",
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",
@@ -7,6 +7,16 @@ Resolution order for each platform (first non-empty wins):
7
7
  checked-in file). Twitter retains the legacy `AUTOPOSTER_TWITTER_HANDLE`
8
8
  name as an alias.
9
9
  2. The matching field in `config.json` -> `accounts.<platform>.<field>`.
10
+ 3. The platform's durable identity store, when it has one. Today only
11
+ twitter does: the keychain-independent cookie mirror stamps the live
12
+ @handle at connect time (see twitter_cookie_mirror). This is the SAME
13
+ ground truth the session posts through, so it's a safe last resort where
14
+ a hardcoded fallback would not be. It exists because config.json is not a
15
+ reliable single home for the handle: onboarding populated the mirror but
16
+ not always config (the "posts land 0/N, no_account_configured" bug), so
17
+ making config the ONLY source made it a single point of failure. There is
18
+ deliberately NO hardcoded default anywhere in this resolver; a missing
19
+ handle returns None and callers refuse rather than impersonate an account.
10
20
 
11
21
  The handle is normalized:
12
22
  - leading `@` is stripped (twitter)
@@ -81,6 +91,24 @@ def _load_config() -> dict:
81
91
  return {}
82
92
 
83
93
 
94
+ def _durable_handle(key: str) -> Optional[str]:
95
+ """Best-effort read of the platform's durable identity store, or None.
96
+
97
+ The durable store is written once at connect time and survives a fresh
98
+ install / keychain re-lock / Cookies-DB wipe, so it's the ground-truth last
99
+ resort when neither env nor config carries the handle. Only twitter has one
100
+ today (the cookie mirror); other platforms return None (env + config only).
101
+ Never raises: an unimportable/empty store must not break resolution.
102
+ """
103
+ if key in ("twitter", "x"):
104
+ try:
105
+ import twitter_cookie_mirror # optional dep; scripts-dir sibling
106
+ return normalize((twitter_cookie_mirror.load_meta() or {}).get("handle"))
107
+ except Exception:
108
+ return None
109
+ return None
110
+
111
+
84
112
  def resolve(platform: str) -> Optional[str]:
85
113
  """Return the normalized posting handle for `platform`, or None."""
86
114
  key = (platform or "").strip().lower()
@@ -96,7 +124,13 @@ def resolve(platform: str) -> Optional[str]:
96
124
  cfg = _load_config()
97
125
  accounts = cfg.get("accounts") or {}
98
126
  block = accounts.get(section) or {}
99
- return normalize(block.get(field))
127
+ config_value = normalize(block.get(field))
128
+ if config_value:
129
+ return config_value
130
+
131
+ # Last resort: the durable identity store (twitter cookie mirror). Keeps
132
+ # config.json from being a single point of failure without ever guessing.
133
+ return _durable_handle(key)
100
134
 
101
135
 
102
136
  def require(platform: str) -> str:
@@ -229,7 +229,14 @@ def _recent_batches_not_progressing(min_batches: int = BATCH_PROGRESSION_MIN_BAT
229
229
  import http_api # noqa: E402
230
230
 
231
231
  resp = http_api.api_get("/api/v1/twitter-batches", {"list": "1", "limit": str(min_batches)})
232
- batches = (resp or {}).get("batches") or []
232
+ # http_api.api_get returns the RAW envelope {"ok":..,"data":{"batches":[..],
233
+ # "owner_host":..}} — it does NOT unwrap "data". The batches live at
234
+ # resp["data"]["batches"], never at resp["batches"]. Reading the wrong key
235
+ # made this return [] -> len<min -> False on EVERY call, so batches_stuck was
236
+ # hardwired False and the "drafting is not actually happening" stall cause
237
+ # could never be selected (missed Nhat's 07-15 phase2b-prep pileup entirely).
238
+ # Do NOT collapse back to resp.get("batches").
239
+ batches = ((resp or {}).get("data") or {}).get("batches") or []
233
240
  if len(batches) < min_batches:
234
241
  return False # not enough history yet to conclude a stall
235
242
  return all((b or {}).get("current_phase") not in BATCH_PROGRESSED_PHASES for b in batches)
@@ -0,0 +1,90 @@
1
+ """Shared harness-tab lifecycle: ONE park-on-exit implementation for every
2
+ platform browser lib (twitter_browser.py, reddit_browser.py, future).
3
+
4
+ Born 2026-07-17 from the user's unification directive: the parking/revive
5
+ discipline lived only in twitter_browser.py, so the reddit harness idled on
6
+ live reddit pages in a visible window and kept its own failure modes. Per
7
+ feedback_minimize_code_footprint there must be exactly one implementation.
8
+
9
+ Design constraints inherited from the twitter parker (see
10
+ insights_chrome150_renderer_crash_wedge_2026_07_14):
11
+ - Raw CDP over HTTP+websocket with hard 3s timeouts, NEVER Playwright:
12
+ at atexit the Playwright loop may be gone, and against a wedged browser
13
+ this must fail fast, not hang the exit.
14
+ - suppress_origin: Chrome 111+ rejects ws clients whose Origin header is
15
+ not in --remote-allow-origins.
16
+ - Park target is the platform's own /robots.txt, NOT about:blank: a static
17
+ ~100-byte document with no SPA to leak or crash, whose URL still matches
18
+ every tab-reuse heuristic (bh list_tabs hides "about:" as internal, and
19
+ an about:blank park made scans mint new tabs — the proven focus-steal
20
+ trigger Target.createTarget).
21
+ - Skip tabs already parked; never raise; S4L_NO_TAB_PARK=1 escape hatch.
22
+ """
23
+ import atexit
24
+ import json
25
+ import os
26
+ import sys
27
+ import urllib.request
28
+
29
+ # Loopback CDP must never route through a proxy (macOS system proxy settings
30
+ # leak into urllib's default opener; a box-wide forwarder 403s 127.0.0.1).
31
+ _OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
32
+
33
+ _registered = set()
34
+
35
+
36
+ def _targets(cdp_base: str):
37
+ with _OPENER.open(cdp_base.rstrip("/") + "/json/list", timeout=3) as r:
38
+ return json.loads(r.read())
39
+
40
+
41
+ def _navigate_ws(ws_url: str, url: str) -> None:
42
+ import websocket
43
+
44
+ ws = websocket.create_connection(ws_url, timeout=3, suppress_origin=True)
45
+ try:
46
+ ws.send(json.dumps({
47
+ "id": 1, "method": "Page.navigate", "params": {"url": url},
48
+ }))
49
+ ws.recv()
50
+ finally:
51
+ ws.close()
52
+
53
+
54
+ def park_tabs(cdp_base: str, host_markers, park_url: str, label: str) -> None:
55
+ """Navigate every page tab whose URL matches host_markers to park_url.
56
+ Best-effort; a wedged browser just fails the 3s connects quietly."""
57
+ try:
58
+ for t in _targets(cdp_base):
59
+ u = t.get("url", "")
60
+ if t.get("type") != "page":
61
+ continue
62
+ if not any(h in u for h in host_markers):
63
+ continue
64
+ if u.split("?", 1)[0].rstrip("/").endswith("/robots.txt"):
65
+ continue # already parked
66
+ if not t.get("webSocketDebuggerUrl"):
67
+ continue
68
+ try:
69
+ _navigate_ws(t["webSocketDebuggerUrl"], park_url)
70
+ print(
71
+ f"[{label}] parked tab on {park_url} (was {u[:80]})",
72
+ file=sys.stderr,
73
+ )
74
+ except Exception:
75
+ continue
76
+ except Exception:
77
+ pass
78
+
79
+
80
+ def register_park_on_exit(cdp_base: str, host_markers, park_url: str, label: str) -> None:
81
+ """Arm park_tabs to run at process exit, once per (endpoint, park_url).
82
+ Call from a platform lib's get_browser_and_page so only processes that
83
+ actually used the browser park it."""
84
+ if os.environ.get("S4L_NO_TAB_PARK"):
85
+ return
86
+ key = (cdp_base, park_url)
87
+ if key in _registered:
88
+ return
89
+ _registered.add(key)
90
+ atexit.register(park_tabs, cdp_base, tuple(host_markers), park_url, label)
@@ -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
  }))