@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.
- package/mcp/dist/index.js +256 -92
- package/mcp/dist/telemetry.js +2 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +144 -18
- package/mcp/menubar/s4l_card_canvas.py +135 -14
- package/mcp/menubar/s4l_menubar.py +216 -73
- package/mcp/menubar/s4l_state.py +45 -130
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +35 -1
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/draft_prompt_core.py +100 -17
- package/scripts/engagement_styles.py +135 -98
- package/scripts/merge_review_queue.py +192 -129
- package/scripts/platform_strike_events.py +54 -5
- package/scripts/post_reddit.py +10 -0
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/reap_stale_claude_sessions.py +7 -6
- package/scripts/reddit_ban_check.py +220 -0
- package/scripts/reddit_browser.py +24 -0
- package/scripts/release-mcpb.sh +38 -7
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/store_patch.py +111 -0
- package/scripts/strike_alert.py +161 -12
- package/scripts/test_no_silent_fallbacks.py +11 -0
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/skill/archive-old-logs.sh +24 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-twitter-cycle.sh +146 -66
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
-
#
|
|
1137
|
-
#
|
|
1138
|
-
#
|
|
1139
|
-
#
|
|
1140
|
-
#
|
|
1141
|
-
#
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
2
|
+
"""Raw-websocket CDP readiness probe for the harness Chrome.
|
|
3
3
|
|
|
4
|
-
Exit 0 when a
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
liveness
|
|
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
|
-
|
|
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
|
-
|
|
55
|
+
import websocket # websocket-client, present in the owned runtime venv
|
|
33
56
|
except Exception:
|
|
34
|
-
import urllib.request
|
|
35
57
|
try:
|
|
36
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
100
|
+
_ws_call(t["webSocketDebuggerUrl"], "Page.reload", 8.0)
|
|
101
|
+
revived += 1
|
|
69
102
|
except Exception:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
pass
|
|
75
|
-
browser.close()
|
|
103
|
+
pass
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
76
107
|
out = {
|
|
77
|
-
"ready": True, "mode": "
|
|
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": "
|
|
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
|
}))
|