@m13v/s4l 1.7.9-rc.1 → 1.7.9
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/version.json +2 -2
- package/mcp/manifest.json +8 -5
- package/mcp/menubar/s4l_browser_foreground.py +6 -8
- package/mcp/package.json +1 -1
- package/mcp-servers/browser-harness/server.py +201 -153
- package/package.json +1 -1
- package/scripts/install.sh +11 -3
- package/scripts/link_tail.py +270 -73
- package/scripts/release-mcpb.sh +84 -62
- package/scripts/twitter_gen_links.py +99 -22
- package/scripts/twitter_post_plan.py +80 -31
- package/skill/lib/browser-launch.sh +18 -0
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.9
|
|
5
|
+
"version": "1.7.9",
|
|
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": {
|
|
@@ -10,14 +10,17 @@
|
|
|
10
10
|
"email": "i@m13v.com",
|
|
11
11
|
"url": "https://s4l.ai"
|
|
12
12
|
},
|
|
13
|
+
"compatibility": {
|
|
14
|
+
"platforms": [
|
|
15
|
+
"darwin"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
13
18
|
"server": {
|
|
14
19
|
"type": "node",
|
|
15
20
|
"entry_point": "dist/index.js",
|
|
16
21
|
"mcp_config": {
|
|
17
|
-
"command": "${__dirname}/
|
|
18
|
-
"args": [
|
|
19
|
-
"${__dirname}/dist/index.js"
|
|
20
|
-
],
|
|
22
|
+
"command": "${__dirname}/launch.js",
|
|
23
|
+
"args": [],
|
|
21
24
|
"env": {
|
|
22
25
|
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
23
26
|
}
|
|
@@ -158,14 +158,12 @@ class _Worker(threading.Thread):
|
|
|
158
158
|
# unaffected (CDP is offscreen-raster + synthetic input; the occlusion
|
|
159
159
|
# flags keep hidden tabs painting).
|
|
160
160
|
# DEFAULT ON (user decision 2026-08-07, reverses the 2026-07-30 default-off
|
|
161
|
-
# call)
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
if os.environ.get("S4L_NO_HARNESS_HIDE"):
|
|
168
|
-
return
|
|
161
|
+
# call). ONE flag by design (2026-08-08 — the old S4L_NO_HARNESS_HIDE
|
|
162
|
+
# override was removed to end the double-flag confusion):
|
|
163
|
+
# S4L_HARNESS_HIDE unset / any value except "0" => hide active (default)
|
|
164
|
+
# S4L_HARNESS_HIDE=0 => disabled — the single
|
|
165
|
+
# off switch (e.g. to keep a harness window visible for a one-time
|
|
166
|
+
# interactive login, then set it back to 1 / unset it).
|
|
169
167
|
if os.environ.get("S4L_HARNESS_HIDE", "1") == "0":
|
|
170
168
|
return
|
|
171
169
|
pos = details.get("window_position") or ""
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.9
|
|
3
|
+
"version": "1.7.9",
|
|
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",
|
|
@@ -28,12 +28,14 @@ headed, e.g. when Xvfb is available).
|
|
|
28
28
|
from __future__ import annotations
|
|
29
29
|
|
|
30
30
|
import asyncio
|
|
31
|
+
import atexit
|
|
31
32
|
import json
|
|
32
33
|
import os
|
|
33
34
|
import shutil
|
|
34
35
|
import socket
|
|
35
36
|
import subprocess
|
|
36
37
|
import sys
|
|
38
|
+
import threading
|
|
37
39
|
import time
|
|
38
40
|
import urllib.request
|
|
39
41
|
import urllib.error
|
|
@@ -58,51 +60,16 @@ PROFILE_DIR = Path.home() / ".claude" / "browser-profiles" / PROFILE_NAME
|
|
|
58
60
|
PID_FILE = Path.home() / ".claude" / "browser-profiles" / f"{PROFILE_NAME}.chrome.pid"
|
|
59
61
|
LOG_FILE = Path.home() / ".claude" / "browser-profiles" / f"{PROFILE_NAME}.chrome.log"
|
|
60
62
|
MCP_LOG_FILE = Path.home() / ".claude" / "browser-profiles" / f"{PROFILE_NAME}.mcp.log"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
A .mcpb user with no system Chrome (e.g. only Arc installed) still gets a
|
|
68
|
-
working browser here because the runtime download lands in this cache. The
|
|
69
|
-
rev number (chromium-1208/1217/1223/...) changes per Playwright pin, and the
|
|
70
|
-
bundle is named "Google Chrome for Testing" on recent revs / "Chromium" on
|
|
71
|
-
older ones, so we glob rather than hardcode. macOS uses chrome-mac[-arm64];
|
|
72
|
-
Linux uses chrome-linux.
|
|
73
|
-
"""
|
|
74
|
-
cache = Path.home() / "Library" / "Caches" / "ms-playwright" # macOS
|
|
75
|
-
linux_cache = Path.home() / ".cache" / "ms-playwright" # Linux
|
|
76
|
-
patterns = [
|
|
77
|
-
# macOS, newer revs (arm64 + x64): "Google Chrome for Testing"
|
|
78
|
-
"chromium-*/chrome-mac*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
79
|
-
# macOS, older revs: "Chromium"
|
|
80
|
-
"chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium",
|
|
81
|
-
# Linux
|
|
82
|
-
"chromium-*/chrome-linux/chrome",
|
|
83
|
-
]
|
|
84
|
-
found: list[str] = []
|
|
85
|
-
for root in (cache, linux_cache):
|
|
86
|
-
if not root.exists():
|
|
87
|
-
continue
|
|
88
|
-
for pat in patterns:
|
|
89
|
-
for hit in root.glob(pat):
|
|
90
|
-
if hit.exists():
|
|
91
|
-
found.append(str(hit))
|
|
92
|
-
|
|
93
|
-
def _rev(p: str) -> int:
|
|
94
|
-
# Sort by the chromium-<rev> number so the newest install wins.
|
|
95
|
-
for part in Path(p).parts:
|
|
96
|
-
if part.startswith("chromium-") and part[len("chromium-"):].isdigit():
|
|
97
|
-
return int(part[len("chromium-"):])
|
|
98
|
-
return 0
|
|
99
|
-
|
|
100
|
-
return sorted(set(found), key=_rev, reverse=True)
|
|
63
|
+
# [bh_tab_event] attribution lines from the harness CLI's stderr. bh_run
|
|
64
|
+
# returns stderr to the calling agent and nothing else persists it, so the
|
|
65
|
+
# reddit/linkedin lanes had ZERO tab-event observability while twitter's
|
|
66
|
+
# cycle logs captured theirs (2026-07-31). One file per profile; correlate
|
|
67
|
+
# against [browser-foreground] in menubar.err.log.
|
|
68
|
+
TAB_EVENT_LOG = Path.home() / ".claude" / "browser-profiles" / f"{PROFILE_NAME}.tab-events.log"
|
|
101
69
|
|
|
102
70
|
|
|
103
71
|
def _detect_chrome_bin() -> str:
|
|
104
|
-
"""Find the Chrome binary on disk. Env override wins.
|
|
105
|
-
real is found (callers can then trigger an install fallback)."""
|
|
72
|
+
"""Find the Chrome binary on disk. Env override wins."""
|
|
106
73
|
env = os.environ.get("BH_CHROME_BIN")
|
|
107
74
|
if env and Path(env).exists():
|
|
108
75
|
return env
|
|
@@ -122,56 +89,14 @@ def _detect_chrome_bin() -> str:
|
|
|
122
89
|
if Path(p).exists():
|
|
123
90
|
return p
|
|
124
91
|
|
|
125
|
-
# The runtime's own bundled Chromium (ms-playwright cache). This is what
|
|
126
|
-
# makes setup work on a machine with no system Chrome installed.
|
|
127
|
-
for p in _playwright_chromium_bins():
|
|
128
|
-
return p
|
|
129
|
-
|
|
130
92
|
# Fall back to PATH lookup.
|
|
131
93
|
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
|
|
132
94
|
found = shutil.which(name)
|
|
133
95
|
if found:
|
|
134
96
|
return found
|
|
135
97
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _chrome_exists(p: str) -> bool:
|
|
140
|
-
return bool(p) and (Path(p).exists() or shutil.which(p) is not None)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _venv_python() -> str | None:
|
|
144
|
-
"""The runtime venv interpreter (has playwright). Mirrors runtime.ts:
|
|
145
|
-
<S4L_STATE_DIR|~/.social-autoposter-mcp>/runtime/.venv/bin/python3."""
|
|
146
|
-
state_dir = Path(os.environ.get("S4L_STATE_DIR", str(Path.home() / ".social-autoposter-mcp")))
|
|
147
|
-
if sys.platform == "win32":
|
|
148
|
-
cand = state_dir / "runtime" / ".venv" / "Scripts" / "python.exe"
|
|
149
|
-
else:
|
|
150
|
-
cand = state_dir / "runtime" / ".venv" / "bin" / "python3"
|
|
151
|
-
return str(cand) if cand.exists() else None
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _install_chromium() -> dict:
|
|
155
|
-
"""Last-ditch fallback when no Chromium is found anywhere: run
|
|
156
|
-
`playwright install chromium` so a fresh machine self-heals instead of
|
|
157
|
-
dead-ending at no_chrome_binary. Uses the runtime venv if present, otherwise
|
|
158
|
-
the interpreter running this server. The download lands in the shared
|
|
159
|
-
ms-playwright cache that _detect_chrome_bin() globs."""
|
|
160
|
-
py = _venv_python() or sys.executable
|
|
161
|
-
_log(f"no chromium found; attempting `playwright install chromium` via {py}")
|
|
162
|
-
try:
|
|
163
|
-
r = subprocess.run(
|
|
164
|
-
[py, "-m", "playwright", "install", "chromium"],
|
|
165
|
-
capture_output=True,
|
|
166
|
-
text=True,
|
|
167
|
-
timeout=int(os.environ.get("BH_CHROME_INSTALL_TIMEOUT_SEC", "600")),
|
|
168
|
-
)
|
|
169
|
-
except Exception as e: # noqa: BLE001
|
|
170
|
-
_log(f"chromium install failed to launch: {e}")
|
|
171
|
-
return {"ok": False, "python": py, "error": str(e)}
|
|
172
|
-
ok = r.returncode == 0
|
|
173
|
-
_log(f"chromium install exit={r.returncode}")
|
|
174
|
-
return {"ok": ok, "python": py, "exit": r.returncode, "out": (r.stdout + r.stderr)[-600:]}
|
|
98
|
+
# Last-resort default (matches the pre-2026-05-20 hardcoded value).
|
|
99
|
+
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
175
100
|
|
|
176
101
|
|
|
177
102
|
def _detect_browser_harness_bin() -> str:
|
|
@@ -209,6 +134,90 @@ HEADLESS = os.environ.get("BH_HEADLESS", _DEFAULT_HEADLESS) == "1"
|
|
|
209
134
|
RUNNING_AS_ROOT = (hasattr(os, "geteuid") and os.geteuid() == 0)
|
|
210
135
|
|
|
211
136
|
|
|
137
|
+
# --- Browser-session mutex (2026-07-16) ---------------------------------------
|
|
138
|
+
# Every Claude-driven tool call here (bh_run/bh_navigate/bh_screenshot, all
|
|
139
|
+
# funneling through _run_harness) used to be completely invisible to the
|
|
140
|
+
# Python-side locks (browser_mutex.BrowserMutex, used internally by
|
|
141
|
+
# twitter_browser.py/reddit_browser.py/linkedin_browser.py on every CDP
|
|
142
|
+
# connect). A live bh_run script and a concurrent Python fetch/post could both
|
|
143
|
+
# touch the same shared tab with zero coordination -- the exact mechanism
|
|
144
|
+
# behind the "second blank tab, then it crashes" symptom traced tonight.
|
|
145
|
+
#
|
|
146
|
+
# BH_LOCK_FILE (set per-platform by the *-harness-mcp.json config, matching
|
|
147
|
+
# the exact lock_file each platform's own BrowserMutex uses) and
|
|
148
|
+
# BH_LOCK_MODULE_DIR (pointing at the materialized package's scripts/ dir, so
|
|
149
|
+
# this reuses the ONE proven implementation instead of a fourth divergent
|
|
150
|
+
# copy) are both optional: if either is unset or the import fails, _MUTEX
|
|
151
|
+
# stays None and this file behaves exactly as it did before -- fail-open,
|
|
152
|
+
# never a new way for the server to break.
|
|
153
|
+
#
|
|
154
|
+
# Scope is deliberately PER TOOL CALL, not per-session: several pipelines
|
|
155
|
+
# (dm-outreach-reddit.sh, link-edit-reddit.sh) hold their own lease only
|
|
156
|
+
# during actual CDP work and release between posts on purpose, so a batch
|
|
157
|
+
# doesn't monopolize the browser for the ~30 min a full session can take.
|
|
158
|
+
# Holding this lock for the whole server lifetime would regress that. Per-call
|
|
159
|
+
# still closes the race that actually mattered: a live tool call stomping on
|
|
160
|
+
# (or being stomped by) a concurrent Python read/write of the same tab.
|
|
161
|
+
_MUTEX = None
|
|
162
|
+
_LOCK_FILE = os.environ.get("BH_LOCK_FILE", "").strip()
|
|
163
|
+
if _LOCK_FILE:
|
|
164
|
+
try:
|
|
165
|
+
_module_dir = os.environ.get("BH_LOCK_MODULE_DIR", "").strip()
|
|
166
|
+
if _module_dir and _module_dir not in sys.path:
|
|
167
|
+
sys.path.insert(0, _module_dir)
|
|
168
|
+
from browser_mutex import BrowserMutex # type: ignore
|
|
169
|
+
|
|
170
|
+
_MUTEX = BrowserMutex(
|
|
171
|
+
lock_file=os.path.expanduser(_LOCK_FILE),
|
|
172
|
+
label=f"{PROFILE_NAME} harness (MCP)",
|
|
173
|
+
)
|
|
174
|
+
except Exception as _e: # pragma: no cover - best-effort, never fatal
|
|
175
|
+
_MUTEX = None
|
|
176
|
+
# _log() isn't defined yet at this point in the file (module-level
|
|
177
|
+
# code runs top-to-bottom, before the Logging section below), so
|
|
178
|
+
# write directly to stderr instead of depending on definition order.
|
|
179
|
+
print(f"[browser-harness] lease disabled: import/construct failed ({_e!r})", file=sys.stderr)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _lock_acquire_for_call() -> bool:
|
|
183
|
+
"""True if the tab is ours (or no lock is configured). BrowserMutex.acquire()
|
|
184
|
+
sys.exit(1)s on contention timeout -- correct for the CLI entrypoints it was
|
|
185
|
+
designed for, wrong here: it would kill the whole MCP server (and the
|
|
186
|
+
Claude session using it) over one busy tool call. Caught and converted to
|
|
187
|
+
a normal 'busy, try again' result instead."""
|
|
188
|
+
if _MUTEX is None:
|
|
189
|
+
return True
|
|
190
|
+
try:
|
|
191
|
+
_MUTEX.acquire()
|
|
192
|
+
return True
|
|
193
|
+
except SystemExit:
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _lock_release_after_call() -> None:
|
|
198
|
+
if _MUTEX is not None:
|
|
199
|
+
try:
|
|
200
|
+
_MUTEX.release()
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _lock_heartbeat_loop(stop_event: threading.Event) -> None:
|
|
206
|
+
"""Background thread: refresh the lease every 20s while a call is in
|
|
207
|
+
flight, mirroring the old mcp_lock_proxy.py's proven heartbeat cadence
|
|
208
|
+
(30s) with a little headroom. Long-running bh_run scripts (up to
|
|
209
|
+
EXEC_TIMEOUT_SEC=300s by default) would otherwise risk the lease's own
|
|
210
|
+
300s/180s expiry firing mid-call and letting a peer reclaim it under us."""
|
|
211
|
+
while not stop_event.wait(20):
|
|
212
|
+
try:
|
|
213
|
+
_MUTEX.refresh()
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
atexit.register(_lock_release_after_call)
|
|
219
|
+
|
|
220
|
+
|
|
212
221
|
# --- Logging ---
|
|
213
222
|
|
|
214
223
|
def _log(msg: str) -> None:
|
|
@@ -332,53 +341,22 @@ def _build_chrome_cmd() -> list[str]:
|
|
|
332
341
|
cmd.append(f"--window-position={win_pos}")
|
|
333
342
|
cmd.append(f"--window-size={win_size}")
|
|
334
343
|
|
|
335
|
-
# Open a
|
|
336
|
-
|
|
337
|
-
# browser-harness creates a throwaway about:blank on every re-attach where
|
|
338
|
-
# is_real_page() is false (which about:blank is); launching blank feeds that
|
|
339
|
-
# loop, piling up orphan tabs that cleanup_harness_tabs then has to sweep.
|
|
340
|
-
# Landing URL is derived from the profile's platform; BH_LAUNCH_URL overrides.
|
|
341
|
-
cmd.append(_launch_url())
|
|
344
|
+
# Open a real tab so CDP has something to attach to immediately.
|
|
345
|
+
cmd.append("about:blank")
|
|
342
346
|
return cmd
|
|
343
347
|
|
|
344
348
|
|
|
345
|
-
def _launch_url() -> str:
|
|
346
|
-
"""Initial tab URL for the managed Chrome. MUST be a real http(s) page, not
|
|
347
|
-
about:blank, so the browser-harness daemon reuses it instead of spawning
|
|
348
|
-
throwaway blank tabs (see _build_chrome_cmd). Derived from PROFILE_NAME;
|
|
349
|
-
BH_LAUNCH_URL overrides for non-standard profiles."""
|
|
350
|
-
override = os.environ.get("BH_LAUNCH_URL", "").strip()
|
|
351
|
-
if override:
|
|
352
|
-
return override
|
|
353
|
-
name = PROFILE_NAME.lower()
|
|
354
|
-
if "linkedin" in name:
|
|
355
|
-
return "https://www.linkedin.com/feed/"
|
|
356
|
-
if "reddit" in name:
|
|
357
|
-
return "https://www.reddit.com/"
|
|
358
|
-
return "https://x.com"
|
|
359
|
-
|
|
360
|
-
|
|
361
349
|
def ensure_chrome() -> dict:
|
|
362
350
|
"""Make sure our managed Chrome is running on PORT. Idempotent."""
|
|
363
|
-
global CHROME_BIN
|
|
364
351
|
if _cdp_alive():
|
|
365
352
|
return {"status": "already_running", "pid": _read_pid(), "cdp": CDP_URL}
|
|
366
353
|
|
|
367
|
-
if not
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
install = _install_chromium()
|
|
374
|
-
CHROME_BIN = _detect_chrome_bin()
|
|
375
|
-
if not _chrome_exists(CHROME_BIN):
|
|
376
|
-
return {
|
|
377
|
-
"status": "no_chrome_binary",
|
|
378
|
-
"looked_for": CHROME_BIN or "(none found)",
|
|
379
|
-
"install_attempt": install,
|
|
380
|
-
"hint": "Auto-install of chromium failed. Set BH_CHROME_BIN to a Chrome/Chromium path, or run `playwright install chromium`.",
|
|
381
|
-
}
|
|
354
|
+
if not Path(CHROME_BIN).exists() and not shutil.which(CHROME_BIN):
|
|
355
|
+
return {
|
|
356
|
+
"status": "no_chrome_binary",
|
|
357
|
+
"looked_for": CHROME_BIN,
|
|
358
|
+
"hint": "Set BH_CHROME_BIN to your Chrome/Chromium path, or install google-chrome / chromium.",
|
|
359
|
+
}
|
|
382
360
|
|
|
383
361
|
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
384
362
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -402,6 +380,42 @@ def ensure_chrome() -> dict:
|
|
|
402
380
|
pass
|
|
403
381
|
time.sleep(0.5)
|
|
404
382
|
|
|
383
|
+
# SINGLETON GATE (2026-08-07): never spawn while ANY live process owns
|
|
384
|
+
# this profile — including Chromes started by the shell-side launcher,
|
|
385
|
+
# whose pids are not in our PID_FILE. Chrome's singleton handoff makes
|
|
386
|
+
# the EXISTING instance front itself (focus steal, plus a reopened blank
|
|
387
|
+
# window when it is tabless); the "Opening in existing browser session."
|
|
388
|
+
# lines in LOG_FILE are that event's fingerprint. A CDP probe that
|
|
389
|
+
# false-negatives under load must wait, not double-launch. Genuine
|
|
390
|
+
# recovery still works: _ensure_cdp_ready's stop_chrome() reaps the
|
|
391
|
+
# owner (via _port_owner_pids) before its retry, which makes this gate
|
|
392
|
+
# pass. Trailing space in the pattern keeps browser-harness from
|
|
393
|
+
# matching browser-harness-linkedin.
|
|
394
|
+
try:
|
|
395
|
+
_owners = subprocess.run(
|
|
396
|
+
["pgrep", "-f", "--", f"--user-data-dir={PROFILE_DIR} "],
|
|
397
|
+
capture_output=True, text=True, timeout=5,
|
|
398
|
+
).stdout.split()
|
|
399
|
+
except Exception:
|
|
400
|
+
_owners = []
|
|
401
|
+
if _owners:
|
|
402
|
+
_log(
|
|
403
|
+
f"skip launch: pid {_owners[0]} already owns {PROFILE_DIR}; "
|
|
404
|
+
"waiting for CDP instead of double-launching (singleton handoff "
|
|
405
|
+
"would front the existing window)"
|
|
406
|
+
)
|
|
407
|
+
_gate_deadline = time.time() + 15
|
|
408
|
+
while time.time() < _gate_deadline:
|
|
409
|
+
if _cdp_alive():
|
|
410
|
+
return {"status": "already_running", "pid": int(_owners[0]), "cdp": CDP_URL}
|
|
411
|
+
time.sleep(0.5)
|
|
412
|
+
return {
|
|
413
|
+
"status": "owner_alive_cdp_dead",
|
|
414
|
+
"pid": int(_owners[0]),
|
|
415
|
+
"cdp": CDP_URL,
|
|
416
|
+
"log": str(LOG_FILE),
|
|
417
|
+
}
|
|
418
|
+
|
|
405
419
|
cmd = _build_chrome_cmd()
|
|
406
420
|
|
|
407
421
|
log_fh = LOG_FILE.open("ab")
|
|
@@ -633,38 +647,72 @@ def _run_harness(script: str, timeout: int = EXEC_TIMEOUT_SEC) -> dict:
|
|
|
633
647
|
if cdp_err is not None:
|
|
634
648
|
return cdp_err
|
|
635
649
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
#
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
# Upstream browser-harness dropped the `-c <script>` flag and now reads the
|
|
642
|
-
# script from stdin only (heredoc style). Pass via stdin so we work against
|
|
643
|
-
# current upstream; the old `-c` form returns the usage banner and exits 1,
|
|
644
|
-
# which used to surface as "CDP not connected" on every fresh install.
|
|
645
|
-
try:
|
|
646
|
-
proc = subprocess.run(
|
|
647
|
-
[BROWSER_HARNESS_BIN],
|
|
648
|
-
input=script,
|
|
649
|
-
env=env,
|
|
650
|
-
capture_output=True,
|
|
651
|
-
text=True,
|
|
652
|
-
timeout=timeout,
|
|
653
|
-
)
|
|
654
|
-
except subprocess.TimeoutExpired as e:
|
|
650
|
+
# Hold the shared browser-session lease for exactly this call (see the
|
|
651
|
+
# mutex setup above) so a concurrent Python fetch/post can't stomp the
|
|
652
|
+
# tab this script is about to drive, and vice versa. Held only around the
|
|
653
|
+
# actual subprocess, not the CLI-existence/CDP-readiness checks above.
|
|
654
|
+
if not _lock_acquire_for_call():
|
|
655
655
|
return {
|
|
656
656
|
"ok": False,
|
|
657
|
-
"error":
|
|
658
|
-
|
|
659
|
-
|
|
657
|
+
"error": (
|
|
658
|
+
"browser busy: another process is actively using this harness's "
|
|
659
|
+
"tab right now (lease contended). Safe to retry shortly."
|
|
660
|
+
),
|
|
660
661
|
}
|
|
662
|
+
_stop_heartbeat = threading.Event()
|
|
663
|
+
_hb_thread = None
|
|
664
|
+
if _MUTEX is not None:
|
|
665
|
+
_hb_thread = threading.Thread(
|
|
666
|
+
target=_lock_heartbeat_loop, args=(_stop_heartbeat,), daemon=True
|
|
667
|
+
)
|
|
668
|
+
_hb_thread.start()
|
|
661
669
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
"
|
|
665
|
-
|
|
666
|
-
"
|
|
667
|
-
|
|
670
|
+
try:
|
|
671
|
+
env = os.environ.copy()
|
|
672
|
+
env["BU_CDP_URL"] = CDP_URL
|
|
673
|
+
# Make sure ~/.local/bin is on PATH (uv tools live there).
|
|
674
|
+
env["PATH"] = f"{Path.home()}/.local/bin:" + env.get("PATH", "")
|
|
675
|
+
|
|
676
|
+
# Upstream browser-harness dropped the `-c <script>` flag and now reads the
|
|
677
|
+
# script from stdin only (heredoc style). Pass via stdin so we work against
|
|
678
|
+
# current upstream; the old `-c` form returns the usage banner and exits 1,
|
|
679
|
+
# which used to surface as "CDP not connected" on every fresh install.
|
|
680
|
+
try:
|
|
681
|
+
proc = subprocess.run(
|
|
682
|
+
[BROWSER_HARNESS_BIN],
|
|
683
|
+
input=script,
|
|
684
|
+
env=env,
|
|
685
|
+
capture_output=True,
|
|
686
|
+
text=True,
|
|
687
|
+
timeout=timeout,
|
|
688
|
+
)
|
|
689
|
+
except subprocess.TimeoutExpired as e:
|
|
690
|
+
return {
|
|
691
|
+
"ok": False,
|
|
692
|
+
"error": f"browser-harness timed out after {timeout}s",
|
|
693
|
+
"stdout": (e.stdout or "") if isinstance(e.stdout, str) else "",
|
|
694
|
+
"stderr": (e.stderr or "") if isinstance(e.stderr, str) else "",
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
try:
|
|
698
|
+
events = [l for l in (proc.stderr or "").splitlines() if "[bh_tab_event]" in l]
|
|
699
|
+
if events:
|
|
700
|
+
with open(TAB_EVENT_LOG, "a") as f:
|
|
701
|
+
f.write("\n".join(events) + "\n")
|
|
702
|
+
except OSError:
|
|
703
|
+
pass
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
"ok": proc.returncode == 0,
|
|
707
|
+
"returncode": proc.returncode,
|
|
708
|
+
"stdout": proc.stdout,
|
|
709
|
+
"stderr": proc.stderr,
|
|
710
|
+
}
|
|
711
|
+
finally:
|
|
712
|
+
_stop_heartbeat.set()
|
|
713
|
+
if _hb_thread is not None:
|
|
714
|
+
_hb_thread.join(timeout=2)
|
|
715
|
+
_lock_release_after_call()
|
|
668
716
|
|
|
669
717
|
|
|
670
718
|
# --- MCP server ---
|
package/package.json
CHANGED
package/scripts/install.sh
CHANGED
|
@@ -32,7 +32,12 @@ log() { echo "[s4l-install] $*"; }
|
|
|
32
32
|
die() { echo "[s4l-install] ERROR: $*" >&2; exit 1; }
|
|
33
33
|
|
|
34
34
|
[ "$(uname -s)" = "Darwin" ] || die "macOS only for now."
|
|
35
|
-
|
|
35
|
+
# The bundle vendors both darwin-arm64 and darwin-x64 Node binaries (1.7.9+);
|
|
36
|
+
# pick the one matching this machine.
|
|
37
|
+
case "$(uname -m)" in
|
|
38
|
+
x86_64|amd64) NODE_ARCH="x64" ;;
|
|
39
|
+
*) NODE_ARCH="arm64" ;;
|
|
40
|
+
esac
|
|
36
41
|
|
|
37
42
|
URL="$STABLE_URL"
|
|
38
43
|
[ "${S4L_CHANNEL:-}" = "staging" ] && URL="$STAGING_URL"
|
|
@@ -45,7 +50,9 @@ log "downloading $URL"
|
|
|
45
50
|
mkdir -p "$TMP/app"
|
|
46
51
|
/usr/bin/unzip -q "$TMP/s4l.mcpb" -d "$TMP/app" || die "unzip failed"
|
|
47
52
|
[ -f "$TMP/app/dist/index.js" ] || die "bundle missing dist/index.js (bad download?)"
|
|
48
|
-
|
|
53
|
+
# Older bundles (pre-1.7.9) only vendor arm64; accept either so a staging box
|
|
54
|
+
# pinned to an old release still installs on Apple Silicon.
|
|
55
|
+
[ -x "$TMP/app/vendor/node-darwin-$NODE_ARCH/bin/node" ] || [ -x "$TMP/app/vendor/node-darwin-arm64/bin/node" ] || die "bundle missing vendored node for $NODE_ARCH"
|
|
49
56
|
|
|
50
57
|
mkdir -p "$STATE_DIR"
|
|
51
58
|
rm -rf "$APP_DIR.new"
|
|
@@ -55,7 +62,8 @@ mv "$APP_DIR.new" "$APP_DIR"
|
|
|
55
62
|
rm -rf "$APP_DIR.old"
|
|
56
63
|
log "installed bundle to $APP_DIR"
|
|
57
64
|
|
|
58
|
-
NODE="$APP_DIR/vendor/node-darwin
|
|
65
|
+
NODE="$APP_DIR/vendor/node-darwin-$NODE_ARCH/bin/node"
|
|
66
|
+
[ -x "$NODE" ] || NODE="$APP_DIR/vendor/node-darwin-arm64/bin/node"
|
|
59
67
|
SERVER="$APP_DIR/dist/index.js"
|
|
60
68
|
SAFE_PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
61
69
|
|