@m13v/s4l 1.7.4-rc.21 → 1.7.4-rc.22

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 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
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.4-rc.21",
3
- "installedAt": "2026-07-12T04:40:26.669Z"
2
+ "version": "1.7.4-rc.22",
3
+ "installedAt": "2026-07-12T19:48:21.172Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.4-rc.21",
5
+ "version": "1.7.4-rc.22",
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.21",
3
+ "version": "1.7.4-rc.22",
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.4-rc.21",
3
+ "version": "1.7.4-rc.22",
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",
@@ -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
- proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
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(cdp_url_override)
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
- REPO_DIR = os.path.expanduser("~/social-autoposter")
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
- "$_chrome_bin" \
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 "$HOME/social-autoposter/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
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 "$HOME/social-autoposter/scripts/restore_twitter_session.py" 2>&1 | sed 's/^/[restore] /' >&2 || true
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
- "$_chrome_bin" \
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 "$HOME/social-autoposter/scripts/restore_twitter_session.py" 2>&1 \
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.