@m13v/s4l 1.7.8 → 1.7.9-rc.2
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 +21 -8
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -5
- package/mcp/menubar/s4l_browser_foreground.py +8 -4
- package/mcp/package.json +1 -1
- package/mcp-servers/browser-harness/server.py +201 -153
- package/package.json +1 -1
- package/scripts/claude_job.py +30 -8
- package/skill/lib/browser-launch.sh +18 -0
package/mcp/dist/index.js
CHANGED
|
@@ -3636,14 +3636,27 @@ function writeDraftProvider(provider, setBy) {
|
|
|
3636
3636
|
console.error(`[draft-provider] ${provider} (${setBy})`);
|
|
3637
3637
|
}
|
|
3638
3638
|
tool("queue_setup", {
|
|
3639
|
-
|
|
3640
|
-
description
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
"
|
|
3646
|
-
"
|
|
3639
|
+
// Title/description are host-aware (2026-08-06): an agent reads the tool
|
|
3640
|
+
// description FIRST, so it must tell the truth for the host it's on.
|
|
3641
|
+
// Historic name kept for fleet compatibility; conceptually this tool is
|
|
3642
|
+
// "arm the drafting engine" — Claude hosts get queue-worker task specs,
|
|
3643
|
+
// Codex/ChatGPT hosts get the provider stamped with nothing to schedule.
|
|
3644
|
+
title: s4lHost() === "codex"
|
|
3645
|
+
? "Arm the draft autopilot"
|
|
3646
|
+
: "Get autopilot scheduled-task specs",
|
|
3647
|
+
description: s4lHost() === "codex"
|
|
3648
|
+
? "Arms the hands-free draft autopilot on this machine. On this host there is NOTHING to " +
|
|
3649
|
+
"schedule: drafting runs inline through this app's engine, so ONE call completes autopilot " +
|
|
3650
|
+
"setup (it records the draft engine choice and returns no tasks). Do NOT create any " +
|
|
3651
|
+
"scheduled task or automation. The pipeline itself is kicked by launchd jobs this server " +
|
|
3652
|
+
"installs. Use this as the final onboarding step."
|
|
3653
|
+
: "Returns the scheduled task that runs the hands-free draft autopilot on this machine " +
|
|
3654
|
+
"(s4l-worker, the universal queue worker). For EACH returned task, call the host tool " +
|
|
3655
|
+
"create_scheduled_task with its taskId, cronExpression, prompt, and notifyOnCompletion " +
|
|
3656
|
+
"VERBATIM (do not edit the prompt — it contains exact local paths; notifyOnCompletion MUST be " +
|
|
3657
|
+
"false or the per-minute worker spams a notification every run). The task drains the local job queue that the " +
|
|
3658
|
+
"real pipeline feeds (all job types); the pipeline itself is kicked by launchd jobs this server " +
|
|
3659
|
+
"installs. Use this as the final onboarding step instead of the old per-type worker tasks.",
|
|
3647
3660
|
inputSchema: {},
|
|
3648
3661
|
}, async () => {
|
|
3649
3662
|
// Codex/ChatGPT host: there is no scheduled-task worker at all. Drafting
|
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.
|
|
5
|
+
"version": "1.7.9-rc.2",
|
|
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": {
|
|
@@ -72,10 +72,6 @@
|
|
|
72
72
|
"name": "client_event",
|
|
73
73
|
"description": "Log a lightweight client UI event"
|
|
74
74
|
},
|
|
75
|
-
{
|
|
76
|
-
"name": "queue_setup",
|
|
77
|
-
"description": "Get autopilot scheduled-task specs"
|
|
78
|
-
},
|
|
79
75
|
{
|
|
80
76
|
"name": "dashboard",
|
|
81
77
|
"description": "S4L dashboard"
|
|
@@ -157,12 +157,16 @@ class _Worker(threading.Thread):
|
|
|
157
157
|
# focus back to the app the user was in. Screenshots/clicks are
|
|
158
158
|
# unaffected (CDP is offscreen-raster + synthetic input; the occlusion
|
|
159
159
|
# flags keep hidden tabs painting).
|
|
160
|
-
# DEFAULT
|
|
161
|
-
#
|
|
162
|
-
#
|
|
160
|
+
# DEFAULT ON (user decision 2026-08-07, reverses the 2026-07-30 default-off
|
|
161
|
+
# call): the guard hides an offscreen harness that steals the foreground
|
|
162
|
+
# unless explicitly opted out. Two knobs, by design:
|
|
163
|
+
# S4L_NO_HARNESS_HIDE -> HARD override, force off, checked FIRST so it
|
|
164
|
+
# always wins (this is the one-time interactive-login escape hatch).
|
|
165
|
+
# S4L_HARNESS_HIDE=0 -> SOFT opt-out for a machine that wants it off.
|
|
166
|
+
# Unset or any non-"0" value => hide is active (the new default).
|
|
163
167
|
if os.environ.get("S4L_NO_HARNESS_HIDE"):
|
|
164
168
|
return
|
|
165
|
-
if os.environ.get("S4L_HARNESS_HIDE")
|
|
169
|
+
if os.environ.get("S4L_HARNESS_HIDE", "1") == "0":
|
|
166
170
|
return
|
|
167
171
|
pos = details.get("window_position") or ""
|
|
168
172
|
try:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.9-rc.2",
|
|
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/claude_job.py
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
claude_job.py —
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
claude_job.py — THE DRAFT-ENGINE SEAM: every pure text->JSON drafting turn in
|
|
4
|
+
the pipeline lands here, and <state_dir>/draft-provider.json decides which
|
|
5
|
+
engine answers it (see scripts/draft_provider.py; design + verified facts in
|
|
6
|
+
docs/codex-port-design.md):
|
|
7
|
+
|
|
8
|
+
claude-desktop-queue (default) enqueue to a file queue drained by the
|
|
9
|
+
Claude Desktop s4l-worker scheduled task — the
|
|
10
|
+
original design, and the only option when Claude
|
|
11
|
+
Desktop is the box's sole authenticated engine.
|
|
12
|
+
claude-p answer inline via the real `claude -p` (_run_claude_p;
|
|
13
|
+
schema INLINED — the CLI parses --json-schema as JSON,
|
|
14
|
+
a file path breaks it).
|
|
15
|
+
codex-exec answer inline via the ChatGPT-app-bundled `codex exec`
|
|
16
|
+
(_run_codex_exec; gpt-5.6-sol default, schema rides
|
|
17
|
+
the prompt — OpenAI strict schema mode rejects our
|
|
18
|
+
lenient schemas).
|
|
19
|
+
|
|
20
|
+
All three return byte-identical claude `--output-format json` envelopes and
|
|
21
|
+
the same 0/1/79 exit semantics, so callers can't tell them apart. The queue
|
|
22
|
+
machinery below (next/result, worker notes, heartbeats) only runs for the
|
|
23
|
+
default provider; the inline providers skip it entirely, which also means no
|
|
24
|
+
warm-session leaks and no app-open requirement on those paths.
|
|
6
25
|
|
|
7
26
|
The deterministic pipeline never calls `claude` directly; every invocation goes
|
|
8
27
|
through scripts/run_claude.sh. For script tags mapped in TAG_TO_TYPE below,
|
|
@@ -461,19 +480,22 @@ def _bump_drain_timeout() -> None:
|
|
|
461
480
|
|
|
462
481
|
|
|
463
482
|
# --------------------------------------------------------------------------- #
|
|
464
|
-
#
|
|
483
|
+
# Worker self-reap (2026-06-27; DEFAULT ON since 2026-07-04 / v1.6.202) #
|
|
465
484
|
# --------------------------------------------------------------------------- #
|
|
466
485
|
# A scheduled-task worker turn finishes its one queue iteration but Claude
|
|
467
486
|
# Desktop keeps the agent-mode `claude` process warm (`--input-format
|
|
468
487
|
# stream-json`), so finished workers pile up and leak RAM. The launchd reaper
|
|
469
|
-
# (reap_stale_claude_sessions.py) is the GUARANTEE that bounds this. This
|
|
488
|
+
# (reap_stale_claude_sessions.py) is the GUARANTEE that bounds this. This
|
|
470
489
|
# path is a faster, source-side trim: once THIS worker is provably done (no work
|
|
471
490
|
# to do, or its result is already on disk), terminate OUR OWN session so it never
|
|
472
|
-
# becomes part of the standing pool.
|
|
473
|
-
# set,
|
|
491
|
+
# becomes part of the standing pool. It shipped opt-in (dormant unless
|
|
492
|
+
# S4L_WORKER_SELF_REAP was set), which meant it ran nowhere; when Desktop
|
|
493
|
+
# 1.18286.0 changed the cmdline shape the signature-fragile reaper missed too
|
|
494
|
+
# and boxes leaked (Karol, 53 piled workers). Since 2026-07-04 it is ON by
|
|
495
|
+
# default; opt OUT with S4L_WORKER_SELF_REAP=0 (see _self_reap_enabled).
|
|
474
496
|
#
|
|
475
497
|
# Safety properties:
|
|
476
|
-
# *
|
|
498
|
+
# * Respects the env kill switch (S4L_WORKER_SELF_REAP=0 disables it).
|
|
477
499
|
# * Only ever targets a process in OUR OWN ancestry that matches the reaper's
|
|
478
500
|
# worker signature (claude-code agent-mode session). The producer side
|
|
479
501
|
# (run-twitter-cycle.sh -> python) has no such ancestor, so a misplaced call
|
|
@@ -28,6 +28,24 @@
|
|
|
28
28
|
launch_harness_chrome() {
|
|
29
29
|
local _bl_chrome_bin="$1"; shift
|
|
30
30
|
local _bl_prof_dir="$1"; shift
|
|
31
|
+
# SINGLETON GATE (2026-08-07): if a live Chrome already owns this profile,
|
|
32
|
+
# NEVER exec another launch. A duplicate launch does not create a second
|
|
33
|
+
# instance — Chrome's singleton handoff makes the EXISTING instance FRONT
|
|
34
|
+
# ITSELF (and reopen a blank window when it has none): the reproduced root
|
|
35
|
+
# cause of the residual focus steals / "browser restarted on a blank page"
|
|
36
|
+
# reports. The handoff's "Opening in existing browser session." goes to
|
|
37
|
+
# stderr that `open` discards, so it never appeared in any log. Probes can
|
|
38
|
+
# false-negative under load; process liveness is the authority. Callers
|
|
39
|
+
# that genuinely need a relaunch kill the owner first
|
|
40
|
+
# (_hc_reap_profile_owners), which makes this gate pass. Trailing space in
|
|
41
|
+
# the pattern keeps browser-harness from matching browser-harness-linkedin
|
|
42
|
+
# (same convention as the reaper).
|
|
43
|
+
local _bl_owner
|
|
44
|
+
_bl_owner=$(pgrep -f -- "--user-data-dir=$_bl_prof_dir " 2>/dev/null | head -1)
|
|
45
|
+
if [ -n "$_bl_owner" ]; then
|
|
46
|
+
echo "[browser-launch] SKIP: pid $_bl_owner already owns $_bl_prof_dir; a duplicate launch would singleton-handoff and front the existing window" >&2
|
|
47
|
+
return 0
|
|
48
|
+
fi
|
|
31
49
|
"${S4L_PYTHON:-python3}" -c 'import json, os, sys
|
|
32
50
|
p = os.path.join(sys.argv[1], "Default", "Preferences")
|
|
33
51
|
try:
|