@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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.9-rc.1",
3
- "installedAt": "2026-08-07T20:23:25.293Z"
2
+ "version": "1.7.9",
3
+ "installedAt": "2026-08-10T22:28:09.611Z"
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.9-rc.1",
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}/vendor/node-darwin-arm64/bin/node",
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): 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).
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-rc.1",
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
- def _playwright_chromium_bins() -> list[str]:
64
- """Chromium binaries the runtime's `playwright install chromium` step drops
65
- into the shared ms-playwright cache, newest revision first.
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. Returns "" if nothing
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
- return ""
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 REAL http(s) page (NOT about:blank) so the harness daemon's
336
- # attach_first_page() finds an existing real tab and reuses it. Upstream
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 _chrome_exists(CHROME_BIN):
368
- # Re-detect (the runtime may have finished its chromium download since
369
- # this server booted), then auto-install as a last resort so a fresh
370
- # machine self-heals instead of dead-ending here.
371
- CHROME_BIN = _detect_chrome_bin()
372
- if not _chrome_exists(CHROME_BIN):
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
- env = os.environ.copy()
637
- env["BU_CDP_URL"] = CDP_URL
638
- # Make sure ~/.local/bin is on PATH (uv tools live there).
639
- env["PATH"] = f"{Path.home()}/.local/bin:" + env.get("PATH", "")
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": f"browser-harness timed out after {timeout}s",
658
- "stdout": (e.stdout or "") if isinstance(e.stdout, str) else "",
659
- "stderr": (e.stderr or "") if isinstance(e.stderr, str) else "",
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
- return {
663
- "ok": proc.returncode == 0,
664
- "returncode": proc.returncode,
665
- "stdout": proc.stdout,
666
- "stderr": proc.stderr,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.9-rc.1",
3
+ "version": "1.7.9",
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",
@@ -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
- [ "$(uname -m)" = "arm64" ] || die "Apple Silicon only for now (the bundle vendors a darwin-arm64 Node)."
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
- [ -x "$TMP/app/vendor/node-darwin-arm64/bin/node" ] || die "bundle missing vendored node"
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-arm64/bin/node"
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