@m13v/s4l 1.7.4-rc.21 → 1.7.4-rc.23
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 +7 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/cdp_ready_check.py +64 -0
- package/scripts/linkedin_killswitch.py +31 -0
- package/scripts/memory_snapshot.py +57 -0
- package/scripts/setup_twitter_auth.py +6 -1
- package/scripts/twitter_browser.py +64 -5
- package/scripts/twitter_post_plan.py +10 -1
- package/skill/lib/linkedin-backend.sh +12 -1
- package/skill/lib/twitter-backend.sh +107 -5
- package/skill/run-twitter-cycle.sh +27 -0
package/mcp/dist/index.js
CHANGED
|
@@ -163,6 +163,9 @@ function plistXml(opts) {
|
|
|
163
163
|
const schedule = opts.keepAlive
|
|
164
164
|
? `\t<key>KeepAlive</key>\n\t<true/>`
|
|
165
165
|
: `\t<key>StartInterval</key>\n\t<integer>${opts.intervalSecs}</integer>`;
|
|
166
|
+
const abandon = opts.abandonProcessGroup
|
|
167
|
+
? `\n\t<key>AbandonProcessGroup</key>\n\t<true/>`
|
|
168
|
+
: "";
|
|
166
169
|
// Background (cron/autopilot) runs get the same Chrome the interactive cycle
|
|
167
170
|
// uses, so a no-sudo ~/Applications install (which the shell's own resolver
|
|
168
171
|
// doesn't scan) is still found off-screen. Omitted when Chrome resolves via
|
|
@@ -188,7 +191,7 @@ function plistXml(opts) {
|
|
|
188
191
|
\t<array>
|
|
189
192
|
${args}
|
|
190
193
|
\t</array>
|
|
191
|
-
${schedule}
|
|
194
|
+
${schedule}${abandon}
|
|
192
195
|
\t<key>StandardOutPath</key>
|
|
193
196
|
\t<string>${opts.stdoutLog}</string>
|
|
194
197
|
\t<key>StandardErrorPath</key>
|
|
@@ -3500,6 +3503,9 @@ async function ensureQueueKickerInstalled() {
|
|
|
3500
3503
|
stdoutLog: path.join(logDir, "launchd-twitter-cycle-stdout.log"),
|
|
3501
3504
|
stderrLog: path.join(logDir, "launchd-twitter-cycle-stderr.log"),
|
|
3502
3505
|
extraEnv: kickerEnv(),
|
|
3506
|
+
// Don't let launchd reap the harness Chrome the cycle launches when the
|
|
3507
|
+
// kicker shell exits (2026-07-12 foreground-steal loop).
|
|
3508
|
+
abandonProcessGroup: true,
|
|
3503
3509
|
});
|
|
3504
3510
|
// Content-aware install: an existing box has the OLD kicker plist pointing at
|
|
3505
3511
|
// run-twitter-cycle.sh (no merge step). ensurePlist won't overwrite, so detect
|
package/mcp/dist/version.json
CHANGED
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.4-rc.
|
|
5
|
+
"version": "1.7.4-rc.23",
|
|
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": {
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.23",
|
|
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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Real CDP readiness probe for the harness Chrome.
|
|
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).
|
|
11
|
+
|
|
12
|
+
Usage: cdp_ready_check.py [CDP_URL] [TIMEOUT_MS]
|
|
13
|
+
|
|
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).
|
|
17
|
+
|
|
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.
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
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
|
|
30
|
+
t0 = time.time()
|
|
31
|
+
try:
|
|
32
|
+
from playwright.sync_api import sync_playwright
|
|
33
|
+
except Exception:
|
|
34
|
+
import urllib.request
|
|
35
|
+
try:
|
|
36
|
+
urllib.request.urlopen(f"{url}/json/version", timeout=3)
|
|
37
|
+
print(json.dumps({"ready": True, "mode": "http-only"}))
|
|
38
|
+
return 0
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(json.dumps({
|
|
41
|
+
"ready": False, "mode": "http-only", "error": str(e)[:120],
|
|
42
|
+
}))
|
|
43
|
+
return 1
|
|
44
|
+
try:
|
|
45
|
+
with sync_playwright() as p:
|
|
46
|
+
browser = p.chromium.connect_over_cdp(url, timeout=timeout_ms)
|
|
47
|
+
n_contexts = len(browser.contexts)
|
|
48
|
+
browser.close()
|
|
49
|
+
print(json.dumps({
|
|
50
|
+
"ready": True, "mode": "cdp", "contexts": n_contexts,
|
|
51
|
+
"elapsed_s": round(time.time() - t0, 2),
|
|
52
|
+
}))
|
|
53
|
+
return 0
|
|
54
|
+
except Exception as e:
|
|
55
|
+
print(json.dumps({
|
|
56
|
+
"ready": False, "mode": "cdp",
|
|
57
|
+
"elapsed_s": round(time.time() - t0, 2),
|
|
58
|
+
"error": str(e)[:200].replace("\n", " "),
|
|
59
|
+
}))
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
raise SystemExit(main())
|
|
@@ -884,6 +884,29 @@ def _hold_check_due_seconds():
|
|
|
884
884
|
return (datetime.now(timezone.utc) - due).total_seconds()
|
|
885
885
|
|
|
886
886
|
|
|
887
|
+
def _cadence_break_active():
|
|
888
|
+
"""True while scripts/linkedin_cadence.py has us in a scheduled break.
|
|
889
|
+
|
|
890
|
+
Reads the cadence state file directly (no import of linkedin_cadence, to
|
|
891
|
+
avoid a circular import — linkedin_cadence imports this module). Recovery
|
|
892
|
+
must never attempt a real login or hold-check re-probe during a break,
|
|
893
|
+
REGARDLESS of which signal put the killswitch file in place: an
|
|
894
|
+
already-active real block (e.g. login_redirect) at the moment a break
|
|
895
|
+
starts must NOT get its 24h auto-recovery clock honored mid-break. The
|
|
896
|
+
2026-07-12 incident (a real hard_block login attempt fired during an
|
|
897
|
+
active cadence break) is exactly what this guards against."""
|
|
898
|
+
path = os.path.expanduser(
|
|
899
|
+
os.environ.get(
|
|
900
|
+
"LINKEDIN_CADENCE_FILE", os.path.join(STATE_DIR, "linkedin_cadence.json")
|
|
901
|
+
)
|
|
902
|
+
)
|
|
903
|
+
try:
|
|
904
|
+
with open(path, "r") as f:
|
|
905
|
+
return json.load(f).get("phase") == "break"
|
|
906
|
+
except Exception:
|
|
907
|
+
return False
|
|
908
|
+
|
|
909
|
+
|
|
887
910
|
def _cmd_recover_check(args):
|
|
888
911
|
"""Gate for the hourly recovery job. Exits 0 when there is work to do and
|
|
889
912
|
prints the MODE on stdout so the shell knows which path to drive:
|
|
@@ -898,6 +921,14 @@ def _cmd_recover_check(args):
|
|
|
898
921
|
if not is_active():
|
|
899
922
|
print("recover-check: killswitch not active, nothing to recover", file=sys.stderr)
|
|
900
923
|
sys.exit(1)
|
|
924
|
+
if _cadence_break_active():
|
|
925
|
+
print(
|
|
926
|
+
"recover-check: cadence break active (scripts/linkedin_cadence.py); "
|
|
927
|
+
"suppressing ALL real recovery action (login/hold) regardless of "
|
|
928
|
+
"signal until the break ends, per 'stop all activity for 2 days'",
|
|
929
|
+
file=sys.stderr,
|
|
930
|
+
)
|
|
931
|
+
sys.exit(1)
|
|
901
932
|
payload = read() or {}
|
|
902
933
|
if payload.get("signal") == "scheduled_break":
|
|
903
934
|
print(
|
|
@@ -700,6 +700,8 @@ def build_summary() -> dict[str, Any]:
|
|
|
700
700
|
"menubar": menubar_status(),
|
|
701
701
|
"twitter_cycle": twitter_cycle_status(),
|
|
702
702
|
"draft_publish": draft_publish_wrapper_status(),
|
|
703
|
+
"chrome_relaunches": chrome_relaunch_status(),
|
|
704
|
+
"cdp_health": cdp_health_status(),
|
|
703
705
|
"process_count": len(rows),
|
|
704
706
|
"mem": {
|
|
705
707
|
"total_mb": total,
|
|
@@ -845,6 +847,25 @@ def reaper_status() -> dict[str, Any] | None:
|
|
|
845
847
|
return None
|
|
846
848
|
|
|
847
849
|
|
|
850
|
+
def cdp_health_status() -> dict[str, Any] | None:
|
|
851
|
+
"""Last CDP readiness verdict, carried on the heartbeat.
|
|
852
|
+
|
|
853
|
+
Written by skill/lib/twitter-backend.sh on every
|
|
854
|
+
ensure_twitter_browser_for_backend call: whether the harness Chrome
|
|
855
|
+
completed a REAL connect_over_cdp handshake (not just /json/version),
|
|
856
|
+
plus the action taken (ok / wedge_restart / relaunched / relaunch_failed /
|
|
857
|
+
external_wedged). Makes a wedged-Chrome episode (S4L-4H, 2026-07-11) a
|
|
858
|
+
one-query answer in installation_resource_samples instead of a Sentry +
|
|
859
|
+
local-log archaeology session. Best-effort."""
|
|
860
|
+
try:
|
|
861
|
+
p = REPO_DIR / "skill" / "logs" / "cdp-health.json"
|
|
862
|
+
data = json.loads(p.read_text())
|
|
863
|
+
data["age_sec"] = round(time.time() - p.stat().st_mtime, 1)
|
|
864
|
+
return data
|
|
865
|
+
except Exception:
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
|
|
848
869
|
def twitter_cycle_status() -> dict[str, Any] | None:
|
|
849
870
|
"""Tail of the newest twitter-cycle log, carried on the heartbeat.
|
|
850
871
|
|
|
@@ -898,6 +919,42 @@ def draft_publish_wrapper_status() -> dict[str, Any] | None:
|
|
|
898
919
|
return None
|
|
899
920
|
|
|
900
921
|
|
|
922
|
+
def chrome_relaunch_status() -> dict[str, Any] | None:
|
|
923
|
+
"""Harness-Chrome relaunch rate from skill/logs/chrome-relaunch-events.log.
|
|
924
|
+
|
|
925
|
+
The skill/lib/*-backend.sh launchers append one dated line per Chrome
|
|
926
|
+
launch. A healthy box launches Chrome rarely (boot, user quit, crash); a
|
|
927
|
+
high 24h count is the signature of the 2026-07-12 kill-respawn loop
|
|
928
|
+
(launchd reaping the kicker job's process group took Chrome with it every
|
|
929
|
+
cycle, and each relaunch stole the user's focus). Carried on the heartbeat
|
|
930
|
+
so the rate is queryable per-install WITHOUT the menubar foreground
|
|
931
|
+
observer, which only reports while the menubar process is alive."""
|
|
932
|
+
try:
|
|
933
|
+
p = REPO_DIR / "skill" / "logs" / "chrome-relaunch-events.log"
|
|
934
|
+
if not p.exists():
|
|
935
|
+
return None
|
|
936
|
+
now = time.time()
|
|
937
|
+
counts = {"1h": 0, "24h": 0}
|
|
938
|
+
last_line = None
|
|
939
|
+
for ln in _tail_lines(p, 400, approx_line_bytes=64):
|
|
940
|
+
ln = ln.strip()
|
|
941
|
+
if not ln:
|
|
942
|
+
continue
|
|
943
|
+
try:
|
|
944
|
+
ts = dt.datetime.strptime(ln.split()[0], "%Y-%m-%dT%H:%M:%SZ")
|
|
945
|
+
age = now - ts.replace(tzinfo=dt.timezone.utc).timestamp()
|
|
946
|
+
except (ValueError, IndexError):
|
|
947
|
+
continue
|
|
948
|
+
if age <= 3600:
|
|
949
|
+
counts["1h"] += 1
|
|
950
|
+
if age <= 86400:
|
|
951
|
+
counts["24h"] += 1
|
|
952
|
+
last_line = ln
|
|
953
|
+
return {"count_1h": counts["1h"], "count_24h": counts["24h"], "last": last_line}
|
|
954
|
+
except Exception:
|
|
955
|
+
return None
|
|
956
|
+
|
|
957
|
+
|
|
901
958
|
def _tail_lines(path: Path, n: int, approx_line_bytes: int = 4096) -> list[str]:
|
|
902
959
|
"""Return the last `n` lines of a possibly-large file without reading it all.
|
|
903
960
|
Reads a bounded tail window (n * approx_line_bytes) from the end. Best-effort."""
|
|
@@ -199,7 +199,12 @@ def _launch_chrome() -> bool:
|
|
|
199
199
|
cmd += ["--window-position=80,80", "--window-size=1100,900"]
|
|
200
200
|
cmd.append("about:blank")
|
|
201
201
|
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
202
|
-
|
|
202
|
+
# start_new_session: Chrome must not inherit this process's group. When the
|
|
203
|
+
# caller is a transient launchd job, launchd SIGKILLs the job's whole
|
|
204
|
+
# process group on exit, reaping Chrome with it; the next lane's relaunch
|
|
205
|
+
# then steals the user's focus (2026-07-12, same fix as *-backend.sh).
|
|
206
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
207
|
+
start_new_session=True)
|
|
203
208
|
try:
|
|
204
209
|
PID_FILE.write_text(str(proc.pid))
|
|
205
210
|
except OSError:
|
|
@@ -68,6 +68,15 @@ PREEMPT_KILL_WAIT = 5 # secs to wait for a preempted scan holder to die before
|
|
|
68
68
|
LOCK_ROLE = (os.environ.get("S4L_LOCK_ROLE") or "scan").strip() or "scan"
|
|
69
69
|
VIEWPORT = {"width": 911, "height": 1016}
|
|
70
70
|
|
|
71
|
+
# CDP connect ceiling. Playwright's connect_over_cdp default is 180s; a wedged
|
|
72
|
+
# harness Chrome (process alive, /json/version answering, websocket upgrade
|
|
73
|
+
# completing, but the browser loop never servicing the CDP handshake) made
|
|
74
|
+
# every reply attempt eat the full 3 minutes WHILE HOLDING the browser lock
|
|
75
|
+
# (S4L-4H, Karol 2026-07-11; identical wedge locally 2026-07-09). A localhost
|
|
76
|
+
# socket either completes the handshake in milliseconds or is wedged; 15s is
|
|
77
|
+
# generous headroom for a loaded box without feeding the lock-contention family.
|
|
78
|
+
CDP_CONNECT_TIMEOUT_MS = int(os.environ.get("S4L_CDP_CONNECT_TIMEOUT_MS") or "15000")
|
|
79
|
+
|
|
71
80
|
# Posting handle. Resolved at call time from AUTOPOSTER_TWITTER_HANDLE env
|
|
72
81
|
# var (set by per-account launchd/systemd units) or config.json
|
|
73
82
|
# accounts.twitter.handle. Returns None when neither source is set.
|
|
@@ -226,6 +235,7 @@ def find_twitter_cdp_port():
|
|
|
226
235
|
|
|
227
236
|
_LOCK_SESSION_ID = f"python:{os.getpid()}"
|
|
228
237
|
_LOCK_INHERITED = False
|
|
238
|
+
_LOCK_ACQUIRED_AT = 0.0
|
|
229
239
|
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
230
240
|
|
|
231
241
|
|
|
@@ -243,6 +253,19 @@ def _release_browser_lock():
|
|
|
243
253
|
lock = json.load(f)
|
|
244
254
|
if lock.get("session_id") == _LOCK_SESSION_ID:
|
|
245
255
|
os.remove(LOCK_FILE)
|
|
256
|
+
# Quantify hold time: long holds are what starve peers into the
|
|
257
|
+
# "locked by session ... waited 45s, giving up" family (S4L-B),
|
|
258
|
+
# and the 2026-07-09/11 wedge showed a hung CDP connect holding
|
|
259
|
+
# this lock for 3 minutes per attempt. >=5s filter keeps the
|
|
260
|
+
# healthy-path noise out of the logs.
|
|
261
|
+
if _LOCK_ACQUIRED_AT:
|
|
262
|
+
_held = time.time() - _LOCK_ACQUIRED_AT
|
|
263
|
+
if _held >= 5:
|
|
264
|
+
print(
|
|
265
|
+
f"[browser-lock] held {_held:.0f}s "
|
|
266
|
+
f"(role={LOCK_ROLE}, pid={os.getpid()})",
|
|
267
|
+
file=sys.stderr,
|
|
268
|
+
)
|
|
246
269
|
except (json.JSONDecodeError, OSError):
|
|
247
270
|
pass
|
|
248
271
|
|
|
@@ -392,7 +415,7 @@ def _acquire_browser_lock():
|
|
|
392
415
|
LIVE peers' locks (defect b) and was removed 2026-06-16. Dead holders are
|
|
393
416
|
reclaimed here instead. See docs/twitter_browser_lock.md.
|
|
394
417
|
"""
|
|
395
|
-
global _LOCK_SESSION_ID, _LOCK_INHERITED
|
|
418
|
+
global _LOCK_SESSION_ID, _LOCK_INHERITED, _LOCK_ACQUIRED_AT
|
|
396
419
|
deadline = time.time() + LOCK_WAIT_MAX
|
|
397
420
|
# Guarantee the lock dir exists so _try_take_lock's O_EXCL create can't fail
|
|
398
421
|
# for a missing-parent reason (which would otherwise spin the no-file path).
|
|
@@ -548,6 +571,7 @@ def _acquire_browser_lock():
|
|
|
548
571
|
sys.exit(1)
|
|
549
572
|
time.sleep(LOCK_POLL_INTERVAL)
|
|
550
573
|
continue
|
|
574
|
+
_LOCK_ACQUIRED_AT = time.time()
|
|
551
575
|
|
|
552
576
|
|
|
553
577
|
def _refresh_browser_lock():
|
|
@@ -559,6 +583,33 @@ def _refresh_browser_lock():
|
|
|
559
583
|
pass
|
|
560
584
|
|
|
561
585
|
|
|
586
|
+
def _cdp_diagnostics(cdp_url):
|
|
587
|
+
"""Cheap post-mortem probe after a failed CDP attach.
|
|
588
|
+
|
|
589
|
+
Distinguishes "Chrome down" (HTTP dead) from "Chrome wedged" (HTTP still
|
|
590
|
+
answering /json/version but the CDP session handshake never completing) in
|
|
591
|
+
the error JSON itself, so the Sentry event carries the diagnosis instead of
|
|
592
|
+
requiring log archaeology on the customer box (S4L-4H took exactly that).
|
|
593
|
+
"""
|
|
594
|
+
import urllib.request
|
|
595
|
+
diag = {"http_alive": False}
|
|
596
|
+
base = cdp_url if "://" in cdp_url else f"http://{cdp_url}"
|
|
597
|
+
try:
|
|
598
|
+
with urllib.request.urlopen(f"{base}/json/version", timeout=3) as r:
|
|
599
|
+
v = json.loads(r.read().decode())
|
|
600
|
+
diag["http_alive"] = True
|
|
601
|
+
diag["browser"] = v.get("Browser")
|
|
602
|
+
except Exception as e:
|
|
603
|
+
diag["version_error"] = str(e)[:120]
|
|
604
|
+
return diag
|
|
605
|
+
try:
|
|
606
|
+
with urllib.request.urlopen(f"{base}/json/list", timeout=3) as r:
|
|
607
|
+
diag["targets"] = len(json.loads(r.read().decode()))
|
|
608
|
+
except Exception as e:
|
|
609
|
+
diag["list_error"] = str(e)[:120]
|
|
610
|
+
return diag
|
|
611
|
+
|
|
612
|
+
|
|
562
613
|
def get_browser_and_page(playwright):
|
|
563
614
|
"""Connect to the running twitter-harness Chrome via CDP.
|
|
564
615
|
|
|
@@ -581,8 +632,11 @@ def get_browser_and_page(playwright):
|
|
|
581
632
|
|
|
582
633
|
cdp_url_override = os.environ.get("TWITTER_CDP_URL", "").strip()
|
|
583
634
|
if cdp_url_override:
|
|
635
|
+
_t0 = time.time()
|
|
584
636
|
try:
|
|
585
|
-
browser = playwright.chromium.connect_over_cdp(
|
|
637
|
+
browser = playwright.chromium.connect_over_cdp(
|
|
638
|
+
cdp_url_override, timeout=CDP_CONNECT_TIMEOUT_MS
|
|
639
|
+
)
|
|
586
640
|
contexts = browser.contexts
|
|
587
641
|
if contexts:
|
|
588
642
|
context = contexts[0]
|
|
@@ -601,16 +655,19 @@ def get_browser_and_page(playwright):
|
|
|
601
655
|
_release_browser_lock()
|
|
602
656
|
print(json.dumps({
|
|
603
657
|
"success": False,
|
|
604
|
-
"error": f"TWITTER_CDP_URL connect failed ({cdp_url_override}): {e}"
|
|
658
|
+
"error": f"TWITTER_CDP_URL connect failed ({cdp_url_override}): {e}",
|
|
659
|
+
"connect_elapsed_s": round(time.time() - _t0, 1),
|
|
660
|
+
"cdp_diag": _cdp_diagnostics(cdp_url_override),
|
|
605
661
|
}))
|
|
606
662
|
sys.exit(1)
|
|
607
663
|
|
|
608
664
|
cdp_port = find_twitter_cdp_port()
|
|
609
665
|
|
|
610
666
|
if cdp_port:
|
|
667
|
+
_t0 = time.time()
|
|
611
668
|
try:
|
|
612
669
|
browser = playwright.chromium.connect_over_cdp(
|
|
613
|
-
f"http://localhost:{cdp_port}"
|
|
670
|
+
f"http://localhost:{cdp_port}", timeout=CDP_CONNECT_TIMEOUT_MS
|
|
614
671
|
)
|
|
615
672
|
contexts = browser.contexts
|
|
616
673
|
if contexts:
|
|
@@ -625,7 +682,9 @@ def get_browser_and_page(playwright):
|
|
|
625
682
|
_release_browser_lock()
|
|
626
683
|
print(json.dumps({
|
|
627
684
|
"success": False,
|
|
628
|
-
"error": f"harness CDP attach failed (port {cdp_port}): {e}"
|
|
685
|
+
"error": f"harness CDP attach failed (port {cdp_port}): {e}",
|
|
686
|
+
"connect_elapsed_s": round(time.time() - _t0, 1),
|
|
687
|
+
"cdp_diag": _cdp_diagnostics(f"http://localhost:{cdp_port}"),
|
|
629
688
|
}))
|
|
630
689
|
sys.exit(1)
|
|
631
690
|
|
|
@@ -104,7 +104,16 @@ signal.signal(signal.SIGTERM, _on_sigterm)
|
|
|
104
104
|
# since both shell out to this script. Set before any child is spawned.
|
|
105
105
|
os.environ["S4L_LOCK_ROLE"] = "post"
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
# Resolve the repo root the way the rest of the pipeline does (run_claude.sh,
|
|
108
|
+
# identity.py): S4L_REPO_DIR when the caller sets it, else this script's own
|
|
109
|
+
# parent tree. The old hardcoded ~/social-autoposter pointed OUTSIDE the
|
|
110
|
+
# managed package on customer boxes, so every child spawned below
|
|
111
|
+
# (twitter_browser.py, log_post.py, ...) ran code auto-update could never
|
|
112
|
+
# reach (S4L-4H triage 2026-07-12, Karol's install).
|
|
113
|
+
REPO_DIR = (
|
|
114
|
+
os.environ.get("S4L_REPO_DIR")
|
|
115
|
+
or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
116
|
+
)
|
|
108
117
|
TWITTER_BROWSER = os.path.join(REPO_DIR, "scripts", "twitter_browser.py")
|
|
109
118
|
LOG_POST = os.path.join(REPO_DIR, "scripts", "log_post.py")
|
|
110
119
|
CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
|
|
@@ -289,6 +289,9 @@ ensure_linkedin_browser_for_backend() {
|
|
|
289
289
|
# Probe + launch harness Chrome on port 9556 if needed.
|
|
290
290
|
if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null; then
|
|
291
291
|
echo "[$(date +%H:%M:%S)] LinkedIn harness Chrome down on port 9556, launching..." >&2
|
|
292
|
+
# Dated relaunch stamp for central observability (see twitter-backend.sh).
|
|
293
|
+
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) port=9556" \
|
|
294
|
+
>> "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/logs/chrome-relaunch-events.log" 2>/dev/null || true
|
|
292
295
|
local _chrome_bin
|
|
293
296
|
_chrome_bin=$(_resolve_chrome_bin)
|
|
294
297
|
if [ -z "$_chrome_bin" ]; then
|
|
@@ -330,7 +333,15 @@ ensure_linkedin_browser_for_backend() {
|
|
|
330
333
|
# The occlusion/backgrounding flags matter: the window sits offscreen,
|
|
331
334
|
# and without them Chrome stops laying out SPA-rendered content, so
|
|
332
335
|
# every element measures 0x0 and clicks become impossible (2026-07-03).
|
|
333
|
-
|
|
336
|
+
# os.setsid: Chrome must escape THIS job's process group — launchd
|
|
337
|
+
# SIGKILLs a transient job's whole process group when the shell exits,
|
|
338
|
+
# and `disown` does not change the pgid, so Chrome died with every
|
|
339
|
+
# completed run and the next lane's relaunch stole the user's focus
|
|
340
|
+
# (2026-07-12; same fix as twitter-backend.sh).
|
|
341
|
+
"${S4L_PYTHON:-python3}" -c 'import os,sys
|
|
342
|
+
os.setsid()
|
|
343
|
+
os.execv(sys.argv[1], sys.argv[1:])' \
|
|
344
|
+
"$_chrome_bin" \
|
|
334
345
|
--remote-debugging-port=9556 \
|
|
335
346
|
--user-data-dir="$HOME/.claude/browser-profiles/browser-harness-linkedin" \
|
|
336
347
|
--no-first-run --no-default-browser-check \
|
|
@@ -31,6 +31,14 @@
|
|
|
31
31
|
|
|
32
32
|
MCP_CONFIG_FILE="$HOME/.claude/browser-agent-configs/twitter-harness-mcp.json"
|
|
33
33
|
|
|
34
|
+
# Repo root for the helper scripts this file shells out to, resolved from this
|
|
35
|
+
# file's own location (skill/lib/ -> two up), honoring S4L_REPO_DIR when the
|
|
36
|
+
# caller sets it. The old $HOME/social-autoposter hardcodes ran code OUTSIDE
|
|
37
|
+
# the managed package on customer boxes (unreachable by auto-update) and
|
|
38
|
+
# silently no-op'd where that directory didn't exist, so session restore and
|
|
39
|
+
# tab cleanup never actually ran on such installs (S4L-4H triage 2026-07-12).
|
|
40
|
+
_BH_REPO_DIR="${S4L_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
41
|
+
|
|
34
42
|
# Per-host env override (written by bin/cli.js when installing on an AppMaker
|
|
35
43
|
# VM, where the canonical browser is Chromium on port 9222 behind the SOAX
|
|
36
44
|
# residential proxy at 127.0.0.1:3003, NOT the harness Chrome on 9555). On a
|
|
@@ -134,7 +142,30 @@ cleanup_harness_tabs() {
|
|
|
134
142
|
return 0
|
|
135
143
|
fi
|
|
136
144
|
fi
|
|
137
|
-
python3 "$
|
|
145
|
+
python3 "$_BH_REPO_DIR/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_bh_cdp_ready() {
|
|
149
|
+
# Real CDP readiness: complete an actual connect_over_cdp handshake against
|
|
150
|
+
# the harness. /json/version alone is a liveness probe that a WEDGED Chrome
|
|
151
|
+
# still passes (S4L-4H); see scripts/cdp_ready_check.py. Prints the probe's
|
|
152
|
+
# one-line JSON verdict on stdout; exit status is the verdict.
|
|
153
|
+
"${S4L_PYTHON:-python3}" "$_BH_REPO_DIR/scripts/cdp_ready_check.py" \
|
|
154
|
+
"${1:-$_BH_DEFAULT_URL}" 8000 2>/dev/null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_bh_record_cdp_health() {
|
|
158
|
+
# Persist the latest readiness verdict where memory_snapshot.py picks it up
|
|
159
|
+
# (cdp_health block on the per-minute heartbeat sample), so a wedge and its
|
|
160
|
+
# restart are visible centrally in installation_resource_samples without
|
|
161
|
+
# SSHing the box. $1 = action tag, $2 = the probe's JSON verdict (may be
|
|
162
|
+
# empty). Best effort, never fails the caller.
|
|
163
|
+
printf '{"ts":"%s","url":"%s","action":"%s","verdict":%s}\n' \
|
|
164
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
165
|
+
"${TWITTER_CDP_URL:-$_BH_DEFAULT_URL}" \
|
|
166
|
+
"$1" \
|
|
167
|
+
"${2:-null}" \
|
|
168
|
+
> "$_BH_REPO_DIR/skill/logs/cdp-health.json" 2>/dev/null || true
|
|
138
169
|
}
|
|
139
170
|
|
|
140
171
|
_resolve_chrome_bin() {
|
|
@@ -165,21 +196,67 @@ ensure_twitter_browser_for_backend() {
|
|
|
165
196
|
if [ "${TWITTER_CDP_URL:-$_BH_DEFAULT_URL}" != "$_BH_DEFAULT_URL" ]; then
|
|
166
197
|
local _ext_url="${TWITTER_CDP_URL}"
|
|
167
198
|
if curl -sf --max-time 2 -o /dev/null "${_ext_url}/json/version" 2>/dev/null; then
|
|
199
|
+
# HTTP answers; verify the CDP handshake actually completes before
|
|
200
|
+
# handing this browser to the pipeline. We do NOT restart an
|
|
201
|
+
# externally-managed Chrome; fail fast so the cycle doesn't burn
|
|
202
|
+
# 180s per downstream attach against a wedged browser.
|
|
203
|
+
local _ext_verdict
|
|
204
|
+
if ! _ext_verdict=$(_bh_cdp_ready "$_ext_url"); then
|
|
205
|
+
echo "[$(date +%H:%M:%S)] ERROR: external Chrome at ${_ext_url} is WEDGED (/json/version answers but the CDP handshake never completes). Host must restart it (AppMaker /opt/startup.sh, etc)." >&2
|
|
206
|
+
echo "twitter_cdp_wedge: detected url=${_ext_url} action=none-external" >&2
|
|
207
|
+
_bh_record_cdp_health external_wedged "$_ext_verdict"
|
|
208
|
+
return 1
|
|
209
|
+
fi
|
|
210
|
+
_bh_record_cdp_health ok-external "$_ext_verdict"
|
|
168
211
|
echo "[$(date +%H:%M:%S)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
|
|
169
212
|
# Restore the Twitter login if the sandbox was substituted. AppMaker
|
|
170
213
|
# Hobby-tier sandboxes have a 1h TTL; on substitution /root is reseeded
|
|
171
214
|
# from /etc/skel-root and the harness profile (cookies) is wiped. This
|
|
172
215
|
# re-injects the stored session from social_accounts via the HTTP API.
|
|
173
216
|
# No-op when already logged in. Never blocks the cycle on failure.
|
|
174
|
-
python3 "$
|
|
217
|
+
python3 "$_BH_REPO_DIR/scripts/restore_twitter_session.py" 2>&1 | sed 's/^/[restore] /' >&2 || true
|
|
175
218
|
return 0
|
|
176
219
|
fi
|
|
177
220
|
echo "[$(date +%H:%M:%S)] ERROR: TWITTER_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host (AppMaker /opt/startup.sh, etc)." >&2
|
|
178
221
|
return 1
|
|
179
222
|
fi
|
|
180
|
-
# Probe + launch harness Chrome on port 9555 if needed.
|
|
223
|
+
# Probe + launch harness Chrome on port 9555 if needed. Two-stage probe:
|
|
224
|
+
# /json/version (liveness) then a real CDP handshake (readiness). A wedged
|
|
225
|
+
# Chrome passes the first and fails the second; handing it downstream made
|
|
226
|
+
# every attach eat Playwright's 180s default while holding the browser lock
|
|
227
|
+
# (S4L-4H), so it gets killed and relaunched here instead.
|
|
228
|
+
local _need_launch=0 _launch_reason=""
|
|
229
|
+
local _bh_prof_dir="$HOME/.claude/browser-profiles/browser-harness"
|
|
181
230
|
if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null; then
|
|
231
|
+
_need_launch=1; _launch_reason="http_down"
|
|
182
232
|
echo "[$(date +%H:%M:%S)] Harness Chrome down on port 9555, launching..." >&2
|
|
233
|
+
else
|
|
234
|
+
local _ready_verdict
|
|
235
|
+
if ! _ready_verdict=$(_bh_cdp_ready "$_BH_DEFAULT_URL"); then
|
|
236
|
+
_need_launch=1; _launch_reason="cdp_wedge"
|
|
237
|
+
echo "[$(date +%H:%M:%S)] Harness Chrome WEDGED on port 9555 (/json/version answers but the CDP handshake never completes: ${_ready_verdict:-no verdict}); killing and relaunching..." >&2
|
|
238
|
+
# Machine-greppable marker (same stderr-marker convention as
|
|
239
|
+
# twitter_access_gate; bin/server.js parses these).
|
|
240
|
+
echo "twitter_cdp_wedge: detected url=$_BH_DEFAULT_URL action=restart" >&2
|
|
241
|
+
_bh_record_cdp_health wedge_restart "$_ready_verdict"
|
|
242
|
+
local _wedge_pids
|
|
243
|
+
_wedge_pids=$(pgrep -f -- "--user-data-dir=$_bh_prof_dir " 2>/dev/null || true)
|
|
244
|
+
if [ -n "$_wedge_pids" ]; then
|
|
245
|
+
kill $_wedge_pids 2>/dev/null || true
|
|
246
|
+
sleep 2
|
|
247
|
+
_wedge_pids=$(pgrep -f -- "--user-data-dir=$_bh_prof_dir " 2>/dev/null || true)
|
|
248
|
+
[ -n "$_wedge_pids" ] && { kill -9 $_wedge_pids 2>/dev/null || true; sleep 1; }
|
|
249
|
+
rm -f "$_bh_prof_dir/SingletonLock" "$_bh_prof_dir/SingletonSocket" "$_bh_prof_dir/SingletonCookie" 2>/dev/null || true
|
|
250
|
+
fi
|
|
251
|
+
fi
|
|
252
|
+
fi
|
|
253
|
+
if [ "$_need_launch" = 1 ]; then
|
|
254
|
+
# Dated relaunch stamp for central observability: memory_snapshot.py
|
|
255
|
+
# counts these (chrome_relaunches block) so a kill-respawn loop like
|
|
256
|
+
# 2026-07-12's launchd pgroup reaping is visible per-install in Cloud
|
|
257
|
+
# Logging without depending on the menubar foreground observer.
|
|
258
|
+
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) port=9555 reason=${_launch_reason}" \
|
|
259
|
+
>> "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/logs/chrome-relaunch-events.log" 2>/dev/null || true
|
|
183
260
|
local _chrome_bin
|
|
184
261
|
_chrome_bin=$(_resolve_chrome_bin)
|
|
185
262
|
if [ -z "$_chrome_bin" ]; then
|
|
@@ -232,7 +309,18 @@ ensure_twitter_browser_for_backend() {
|
|
|
232
309
|
[ -n "$_stale_pids" ] && { kill -9 $_stale_pids 2>/dev/null || true; sleep 1; }
|
|
233
310
|
rm -f "$_prof_dir/SingletonLock" "$_prof_dir/SingletonSocket" "$_prof_dir/SingletonCookie" 2>/dev/null || true
|
|
234
311
|
fi
|
|
235
|
-
|
|
312
|
+
# os.setsid: Chrome must escape THIS job's process group. The kicker is a
|
|
313
|
+
# transient launchd job, and launchd SIGKILLs the job's whole process
|
|
314
|
+
# group the moment the shell exits (no AbandonProcessGroup) — `disown`
|
|
315
|
+
# does not change the pgid, so a plainly-backgrounded Chrome died on
|
|
316
|
+
# every cycle completion and the NEXT cycle's relaunch stole the user's
|
|
317
|
+
# focus (2026-07-12 root cause; the foreground-telemetry `cause:launched`
|
|
318
|
+
# loop). A new session makes Chrome survive its launcher regardless of
|
|
319
|
+
# which lane spawned it.
|
|
320
|
+
"${S4L_PYTHON:-python3}" -c 'import os,sys
|
|
321
|
+
os.setsid()
|
|
322
|
+
os.execv(sys.argv[1], sys.argv[1:])' \
|
|
323
|
+
"$_chrome_bin" \
|
|
236
324
|
--remote-debugging-port=9555 \
|
|
237
325
|
--user-data-dir="$HOME/.claude/browser-profiles/browser-harness" \
|
|
238
326
|
--no-first-run --no-default-browser-check \
|
|
@@ -248,9 +336,23 @@ ensure_twitter_browser_for_backend() {
|
|
|
248
336
|
done
|
|
249
337
|
if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null; then
|
|
250
338
|
echo "[$(date +%H:%M:%S)] ERROR: harness Chrome failed to start within 12s" >&2
|
|
339
|
+
_bh_record_cdp_health launch_failed null
|
|
340
|
+
return 1
|
|
341
|
+
fi
|
|
342
|
+
# Verify the fresh Chrome actually completes a CDP handshake before
|
|
343
|
+
# declaring victory; a relaunch that comes up wedged again should fail
|
|
344
|
+
# the cycle loudly, not feed 180s hangs downstream.
|
|
345
|
+
local _post_verdict
|
|
346
|
+
if ! _post_verdict=$(_bh_cdp_ready "$_BH_DEFAULT_URL"); then
|
|
347
|
+
echo "[$(date +%H:%M:%S)] ERROR: harness Chrome answers HTTP after relaunch but the CDP handshake is STILL failing: ${_post_verdict:-no verdict}" >&2
|
|
348
|
+
echo "twitter_cdp_wedge: detected url=$_BH_DEFAULT_URL action=relaunch_failed" >&2
|
|
349
|
+
_bh_record_cdp_health relaunch_failed "$_post_verdict"
|
|
251
350
|
return 1
|
|
252
351
|
fi
|
|
352
|
+
_bh_record_cdp_health relaunched "$_post_verdict"
|
|
253
353
|
echo "[$(date +%H:%M:%S)] Harness Chrome up on port 9555" >&2
|
|
354
|
+
else
|
|
355
|
+
_bh_record_cdp_health ok "${_ready_verdict:-null}"
|
|
254
356
|
fi
|
|
255
357
|
# Re-inject the stored X session if the harness Chrome is logged out — e.g. a
|
|
256
358
|
# keychain re-lock wiped Chrome's encrypted Cookies SQLite on this launch
|
|
@@ -259,7 +361,7 @@ ensure_twitter_browser_for_backend() {
|
|
|
259
361
|
# No-op when already logged in; never blocks the cycle on failure. Runs on
|
|
260
362
|
# both the freshly-launched and already-up paths so a mid-life logout heals.
|
|
261
363
|
TWITTER_CDP_URL="http://127.0.0.1:9555" \
|
|
262
|
-
python3 "$
|
|
364
|
+
python3 "$_BH_REPO_DIR/scripts/restore_twitter_session.py" 2>&1 \
|
|
263
365
|
| sed 's/^/[restore] /' >&2 || true
|
|
264
366
|
# Always close leftover tabs from prior runs. Safe under acquire_lock
|
|
265
367
|
# "twitter-browser" serialization (every caller of this function holds
|
|
@@ -883,6 +883,33 @@ print(cd//60, cons, cd, max(0, now-fs))' "$_GATE_FILE" "$_NOW" 2>/dev/null || ec
|
|
|
883
883
|
release_lock "twitter-browser" 2>/dev/null || true
|
|
884
884
|
exit 1
|
|
885
885
|
fi
|
|
886
|
+
# Probe-error guard (2026-07-12, S4L-4H): output lacking a "gated" verdict
|
|
887
|
+
# means the probe CRASHED (typically the CDP attach failed or timed out on a
|
|
888
|
+
# wedged Chrome) rather than returning a clean bill. The old code logged
|
|
889
|
+
# 'Pre-flight access OK: {"success": false, ...}' and marched a wedged browser
|
|
890
|
+
# into the scan. Heal once (ensure_twitter_browser_for_backend now does a real
|
|
891
|
+
# CDP readiness check and restarts a wedged harness Chrome), re-probe, and end
|
|
892
|
+
# the cycle if there is still no verdict.
|
|
893
|
+
if [ -z "$_ACCESS_OUT" ] || ! printf '%s' "$_ACCESS_OUT" | grep -q '"gated"'; then
|
|
894
|
+
log " Pre-flight access probe FAILED (no verdict; browser not driveable): $(printf '%s' "$_ACCESS_OUT" | tr '\n' ' ' | tr -s ' ' | sed 's/^ *//')"
|
|
895
|
+
echo "twitter_cdp_wedge: access_probe_no_verdict" >&2
|
|
896
|
+
ensure_twitter_browser_for_backend || true
|
|
897
|
+
_ACCESS_OUT=$(TWITTER_CDP_URL="${TWITTER_CDP_URL:-http://127.0.0.1:9555}" \
|
|
898
|
+
python3 "$REPO_DIR/scripts/twitter_access_check.py" --session-probe --wait-ms 12000 2>/dev/null)
|
|
899
|
+
if [ -z "$_ACCESS_OUT" ] || ! printf '%s' "$_ACCESS_OUT" | grep -q '"gated"'; then
|
|
900
|
+
log " Pre-flight access probe still has no verdict after harness heal; ending cycle."
|
|
901
|
+
echo "twitter_batches: ended $BATCH_ID"
|
|
902
|
+
release_lock "twitter-browser" 2>/dev/null || true
|
|
903
|
+
exit 1
|
|
904
|
+
fi
|
|
905
|
+
if printf '%s' "$_ACCESS_OUT" | grep -q '"gated": *true'; then
|
|
906
|
+
log " Recovered probe reports an access gate; ending cycle (backoff engages on the next firing)."
|
|
907
|
+
echo "twitter_batches: ended $BATCH_ID"
|
|
908
|
+
release_lock "twitter-browser" 2>/dev/null || true
|
|
909
|
+
exit 1
|
|
910
|
+
fi
|
|
911
|
+
log " Access probe recovered after harness heal."
|
|
912
|
+
fi
|
|
886
913
|
# Probe came back clean. If a backoff marker exists we were gated: record the
|
|
887
914
|
# recovery (how long the gate lasted, since first_seen) BEFORE deleting it, so
|
|
888
915
|
# the lift event + duration survive in the log even though the marker is gone.
|