@m13v/s4l 1.7.5-rc.2 → 1.7.5-rc.21

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.
Files changed (48) hide show
  1. package/mcp/dist/index.js +571 -79
  2. package/mcp/dist/panel.html +2 -3
  3. package/mcp/dist/redditAuth.js +115 -0
  4. package/mcp/dist/runtime.js +83 -2
  5. package/mcp/dist/setup.js +142 -5
  6. package/mcp/dist/telemetry.js +13 -3
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +1 -1
  9. package/mcp/menubar/s4l_card.py +32 -6
  10. package/mcp/menubar/s4l_menubar.py +42 -111
  11. package/mcp/menubar/s4l_state.py +54 -187
  12. package/mcp/package.json +1 -1
  13. package/mcp/shared/onboarding-ledger.cjs +63 -4
  14. package/package.json +1 -1
  15. package/scripts/autopilot_stall_watch.py +3 -6
  16. package/scripts/browser_mutex.py +316 -0
  17. package/scripts/claude_job.py +21 -0
  18. package/scripts/config.py +118 -0
  19. package/scripts/counterparty_history.py +44 -6
  20. package/scripts/engage_github.py +7 -3
  21. package/scripts/engage_reddit.py +9 -3
  22. package/scripts/engagement_signal_feedback.py +256 -0
  23. package/scripts/learned_preferences.py +37 -14
  24. package/scripts/memory_snapshot.py +45 -0
  25. package/scripts/merge_review_queue.py +67 -3
  26. package/scripts/post_reddit.py +289 -24
  27. package/scripts/reddit_browser.py +62 -63
  28. package/scripts/reddit_browser_fetch.py +32 -0
  29. package/scripts/release-mcpb.sh +17 -0
  30. package/scripts/reset-test-machine.sh +13 -7
  31. package/scripts/scan_twitter_thread_followups.py +7 -1
  32. package/scripts/schedule_state.py +1 -1
  33. package/scripts/setup_reddit_auth.py +925 -0
  34. package/scripts/snapshot.py +96 -4
  35. package/scripts/stats.py +37 -13
  36. package/scripts/test_no_silent_fallbacks.py +281 -0
  37. package/scripts/trigger_promo_lane_test.sh +91 -0
  38. package/scripts/twitter_browser.py +183 -342
  39. package/scripts/twitter_scan.py +75 -10
  40. package/scripts/watchdog_hung_runs.py +15 -9
  41. package/skill/lib/harness-common.sh +344 -0
  42. package/skill/lib/linkedin-backend.sh +61 -161
  43. package/skill/lib/reddit-backend.sh +39 -125
  44. package/skill/lib/twitter-backend.sh +51 -273
  45. package/skill/lock.sh +57 -0
  46. package/skill/run-reddit-search-launchd.sh +4 -1
  47. package/skill/run-reddit-search.sh +51 -2
  48. package/skill/run-twitter-cycle.sh +44 -21
@@ -1,6 +1,7 @@
1
1
  #!/bin/bash
2
2
  # linkedin-backend.sh - LinkedIn pipeline browser bootstrap (linkedin-harness,
3
- # mirrors twitter-backend.sh post the 2026-05-19 Twitter harness migration).
3
+ # mirrors twitter-backend.sh post the 2026-05-19 Twitter harness migration;
4
+ # the shared machinery lives in skill/lib/harness-common.sh since 2026-07-14).
4
5
  #
5
6
  # Source this AFTER lock.sh, BEFORE any acquire_lock / browser pre-flight /
6
7
  # claude -p subprocess calls. Sets these for the caller:
@@ -21,8 +22,10 @@
21
22
  #
22
23
  # ensure_linkedin_browser_for_backend
23
24
  # Call AFTER acquire_lock "linkedin-browser". Probes harness Chrome on
24
- # port 9556 and launches it idempotently if down, then cleans leftover
25
- # tabs from prior runs.
25
+ # port 9556 and launches it idempotently if down (wedge-aware, focus-safe;
26
+ # see harness-common.sh), then cleans leftover tabs from prior runs.
27
+ # Also acquires the cross-pipeline whole-run lock (rc=78 skip code) and
28
+ # runs the per-run logout detect-gate.
26
29
  #
27
30
  # defer_if_foreign_for_backend [log_file]
28
31
  # No-op. Harness CDP supports multiple concurrent clients on the same
@@ -54,12 +57,6 @@ fi
54
57
  # endpoint. Default 9556 (Mac harness Chrome, separate port from Twitter's 9555).
55
58
  export LINKEDIN_CDP_URL="${LINKEDIN_CDP_URL:-http://127.0.0.1:9556}"
56
59
 
57
- # Repo root for helper scripts. Honors S4L_REPO_DIR (managed installs run from
58
- # ~/.social-autoposter-mcp/repo/package, NOT ~/social-autoposter); a $HOME
59
- # hardcode here silently no-ops on customer boxes (same bug twitter-backend.sh
60
- # fixed; see its header comment).
61
- _BH_REPO_DIR="${S4L_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
62
-
63
60
  # Resolve a Playwright-capable Python for the browser-path SERP search
64
61
  # (discover_linkedin_candidates.py CDP-attaches to the harness Chrome via
65
62
  # playwright.sync_api). The agent's bare `python3` resolves to whatever is
@@ -83,11 +80,15 @@ if [ -z "${LINKEDIN_DISCOVER_PYTHON:-}" ]; then
83
80
  export LINKEDIN_DISCOVER_PYTHON="${LINKEDIN_DISCOVER_PYTHON:-python3}"
84
81
  fi
85
82
 
86
- # Default harness URL - used by ensure_linkedin_browser_for_backend +
87
- # cleanup_harness_tabs to decide whether we own this Chrome (and should
83
+ # Default harness URL - used to decide whether we own this Chrome (and should
88
84
  # launch/clean it) or whether it is externally managed (AppMaker, BYO).
89
85
  _BH_LINKEDIN_DEFAULT_URL="http://127.0.0.1:9556"
90
86
 
87
+ # Shared engine: _BH_REPO_DIR, hc_ensure_browser, hc_cleanup_tabs,
88
+ # _resolve_chrome_bin, wedge detection, focus-safe launch.
89
+ # shellcheck disable=SC1091
90
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness-common.sh"
91
+
91
92
  BROWSER_INSTRUCTIONS=$(cat <<'BROWSER_HARNESS_EOF'
92
93
  BROWSER BACKEND: linkedin-harness (browser-harness MCP, CDP-driven REAL Google Chrome on
93
94
  port 9556, profile ~/.claude/browser-profiles/browser-harness-linkedin). The Chrome is
@@ -152,21 +153,7 @@ BROWSER_HARNESS_EOF
152
153
  )
153
154
 
154
155
  cleanup_harness_tabs() {
155
- # Close every CDP "page" tab except one. Same pattern as twitter-backend,
156
- # but scoped to the LinkedIn harness Chrome on port 9556.
157
- #
158
- # Health-check gate: 10s timeout + ONE retry; log skips so they are not silent.
159
- local _probe="curl -sf --max-time 10 -o /dev/null http://127.0.0.1:9556/json/version"
160
- if ! $_probe 2>/dev/null; then
161
- sleep 1
162
- if ! $_probe 2>/dev/null; then
163
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] cleanup_harness_tabs: SKIPPED (linkedin-harness CDP /json/version unreachable after 10s+retry)" >&2
164
- return 0
165
- fi
166
- fi
167
- # Reuse the same cleanup script as Twitter; it just iterates /json on the
168
- # default port. Pass the port via env so a single script can serve both.
169
- BH_CLEANUP_PORT=9556 python3 "$_BH_REPO_DIR/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
156
+ hc_cleanup_tabs 9556 linkedin-harness
170
157
  }
171
158
 
172
159
  # ===== Cross-pipeline whole-run lock (2026-05-30) =====
@@ -210,6 +197,24 @@ cleanup_harness_tabs() {
210
197
  # real pipelines never set this and share the one /tmp path.
211
198
  _LI_PIPELINE_LOCK_DIR="${_LI_PIPELINE_LOCK_DIR:-/tmp/s4l-linkedin-pipeline.lock}"
212
199
  _acquire_linkedin_pipeline_lock() {
200
+ # 2026-07-14: the normal path delegates to lock.sh's
201
+ # acquire_pipeline_singleton — the GENERALIZED version of this exact
202
+ # function (same /tmp/s4l-linkedin-pipeline.lock dir, same rc=78 contract,
203
+ # same dead-holder reclaim and $$-re-entrancy), so any platform can now
204
+ # request one-driver-per-Chrome semantics. The inline body below survives
205
+ # ONLY for the test-override path (_LI_PIPELINE_LOCK_DIR pointing at a
206
+ # scratch dir) and for a caller that somehow sourced this backend without
207
+ # lock.sh (documented sourcing order says lock.sh comes first).
208
+ if declare -F acquire_pipeline_singleton >/dev/null 2>&1 \
209
+ && [ "$_LI_PIPELINE_LOCK_DIR" = "/tmp/s4l-linkedin-pipeline.lock" ]; then
210
+ if [ "${_LI_PIPELINE_LOCK_HELD:-0}" = "1" ]; then
211
+ return 0
212
+ fi
213
+ acquire_pipeline_singleton linkedin
214
+ local _li_rc=$?
215
+ [ "$_li_rc" = "0" ] && export _LI_PIPELINE_LOCK_HELD=1
216
+ return $_li_rc
217
+ fi
213
218
  # Already held by THIS process (re-entry across phases) -> proceed.
214
219
  if [ "${_LI_PIPELINE_LOCK_HELD:-0}" = "1" ]; then
215
220
  return 0
@@ -251,141 +256,21 @@ _acquire_linkedin_pipeline_lock() {
251
256
  done
252
257
  }
253
258
 
254
- _resolve_chrome_bin() {
255
- # Auto-detect Chrome/Chromium so the same script launches the harness on
256
- # macOS dev boxes AND Linux VMs. Override with BH_CHROME_BIN.
257
- if [ -n "${BH_CHROME_BIN:-}" ] && [ -x "$BH_CHROME_BIN" ]; then
258
- echo "$BH_CHROME_BIN"; return 0
259
- fi
260
- for _p in \
261
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
262
- "/Applications/Chromium.app/Contents/MacOS/Chromium" \
263
- "/usr/bin/google-chrome" "/usr/bin/google-chrome-stable" \
264
- "/usr/bin/chromium" "/usr/bin/chromium-browser" "/snap/bin/chromium"
265
- do
266
- if [ -x "$_p" ]; then echo "$_p"; return 0; fi
267
- done
268
- for _n in google-chrome google-chrome-stable chromium chromium-browser; do
269
- _which=$(command -v "$_n" 2>/dev/null) && [ -n "$_which" ] && { echo "$_which"; return 0; }
270
- done
271
- echo ""; return 1
272
- }
273
-
274
- ensure_linkedin_browser_for_backend() {
275
- # AppMaker / BYO Chrome: LINKEDIN_CDP_URL points at something other than our
276
- # default harness URL. Don't touch that browser; just probe it and bail.
277
- if [ "${LINKEDIN_CDP_URL:-$_BH_LINKEDIN_DEFAULT_URL}" != "$_BH_LINKEDIN_DEFAULT_URL" ]; then
278
- local _ext_url="${LINKEDIN_CDP_URL}"
279
- if curl -sf --max-time 2 -o /dev/null "${_ext_url}/json/version" 2>/dev/null; then
280
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
281
- return 0
282
- fi
283
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: LINKEDIN_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host." >&2
284
- return 1
285
- fi
286
- # Cross-pipeline whole-run lock: only one LinkedIn browser pipeline drives
287
- # the 9556 harness Chrome at a time. Acquired here (the single chokepoint
288
- # every browser pipeline calls). Skipped above for externally-managed
289
- # (AppMaker/BYO) Chrome, which is not ours to serialize. A held-by-peer
290
- # lock returns the reserved skip code 78; propagate it so the CALLER's
291
- # parent shell can exit 0 (an exit here would only kill the subshell most
292
- # call sites wrap around this function; see the 2026-07-06 incident note
293
- # above the lock function).
294
- _acquire_linkedin_pipeline_lock || return $?
295
- # Probe + launch harness Chrome on port 9556 if needed.
296
- if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null; then
297
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] LinkedIn harness Chrome down on port 9556, launching..." >&2
298
- # Dated relaunch stamp for central observability (see twitter-backend.sh).
299
- echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) port=9556" \
300
- >> "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/logs/chrome-relaunch-events.log" 2>/dev/null || true
301
- local _chrome_bin
302
- _chrome_bin=$(_resolve_chrome_bin)
303
- if [ -z "$_chrome_bin" ]; then
304
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
305
- return 1
306
- fi
307
- # On Linux + no display, run headless. On root, add --no-sandbox.
308
- local _extra=()
309
- case "$(uname -s)" in
310
- Linux)
311
- _extra+=(--no-sandbox --disable-dev-shm-usage)
312
- if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then
313
- _extra+=(--headless=new --disable-gpu)
314
- fi
315
- ;;
316
- Darwin)
317
- # Default position captured 2026-05-26 from the user's
318
- # secondary monitor; overridable via BH_LINKEDIN_WINDOW_POS.
319
- _extra+=(--window-position="${BH_LINKEDIN_WINDOW_POS:-3814,-1050}")
320
- _extra+=(--window-size="${BH_LINKEDIN_WINDOW_SIZE:-1024,1013}")
321
- ;;
322
- esac
323
- # Self-heal (2026-06-03): reap any stale Chrome holding THIS profile dir
324
- # but not answering CDP on our port, else the relaunch hands off via the
325
- # SingletonLock and loops "failed to start within 12s". Exact-dir match
326
- # (trailing space) so this never touches the twitter browser-harness
327
- # profile. See twitter-backend.sh for the regression that motivated this.
328
- local _prof_dir="$HOME/.claude/browser-profiles/browser-harness-linkedin"
329
- local _stale_pids
330
- _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
331
- if [ -n "$_stale_pids" ] && ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null; then
332
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] CDP down but Chrome still holds $_prof_dir (pids: $(echo $_stale_pids | tr '\n' ' ')); reaping stale profile owner before relaunch" >&2
333
- kill $_stale_pids 2>/dev/null || true
334
- sleep 2
335
- _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
336
- [ -n "$_stale_pids" ] && { kill -9 $_stale_pids 2>/dev/null || true; sleep 1; }
337
- rm -f "$_prof_dir/SingletonLock" "$_prof_dir/SingletonSocket" "$_prof_dir/SingletonCookie" 2>/dev/null || true
338
- fi
339
- # The occlusion/backgrounding flags matter: the window sits offscreen,
340
- # and without them Chrome stops laying out SPA-rendered content, so
341
- # every element measures 0x0 and clicks become impossible (2026-07-03).
342
- # Spawn via the SHARED launcher (skill/lib/browser-launch.sh) — see
343
- # that file for the clean-exit stamp + no-focus-steal + detach
344
- # rationale. Do NOT hand-roll launch logic per backend.
345
- # shellcheck disable=SC1091
346
- source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/browser-launch.sh"
347
- launch_harness_chrome "$_chrome_bin" "$_prof_dir" \
348
- --remote-debugging-port=9556 \
349
- --user-data-dir="$HOME/.claude/browser-profiles/browser-harness-linkedin" \
350
- --no-first-run --no-default-browser-check \
351
- --disable-features=ChromeWhatsNewUI,CalculateNativeWinOcclusion \
352
- --disable-backgrounding-occluded-windows \
353
- --disable-renderer-backgrounding \
354
- --disable-background-timer-throttling \
355
- "${_extra[@]}" \
356
- about:blank
357
- for _i in 1 2 3 4 5 6 7 8 9 10 11 12; do
358
- curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null && break
359
- sleep 1
360
- done
361
- if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null; then
362
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: LinkedIn harness Chrome failed to start within 12s" >&2
363
- return 1
364
- fi
365
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] LinkedIn harness Chrome up on port 9556" >&2
366
- fi
367
- # Always close leftover tabs from prior runs. Safe under acquire_lock
368
- # "linkedin-browser" serialization.
369
- cleanup_harness_tabs
370
-
371
- # Per-run logout detection (2026-06-03). Every browser pipeline funnels
372
- # through here before it touches LinkedIn, so this single call makes ANY
373
- # pipeline trip the killswitch on its natural next fire if the harness
374
- # Chrome has been logged out (999 / authwall / checkpoint), without editing
375
- # the chflags-locked top-level scripts. detect-gate is a no-op when the
376
- # killswitch is already active, and only ENGAGES on a CONCLUSIVE /feed/
377
- # redirect to auth (infra hiccups -> proceed, so a flaky render never
378
- # strands the pipeline). On a confirmed logout it engages the flag (which
379
- # pauses every pipeline on its next fire + starts the 24h recovery clock)
380
- # and returns 2, so we abort this fire instead of burning a Claude session
381
- # on a dead session.
382
- _linkedin_session_detect_gate
383
- }
384
-
385
259
  # Once-per-process guard mirrors _LI_PIPELINE_LOCK_HELD: run-linkedin.sh calls
386
260
  # ensure_linkedin_browser_for_backend in both Phase A and Phase B, and we do not
387
261
  # want two /feed/ probes per fire.
388
262
  _linkedin_session_detect_gate() {
263
+ # Per-run logout detection (2026-06-03). Every browser pipeline funnels
264
+ # through ensure_linkedin_browser_for_backend before it touches LinkedIn,
265
+ # so this single call makes ANY pipeline trip the killswitch on its natural
266
+ # next fire if the harness Chrome has been logged out (999 / authwall /
267
+ # checkpoint), without editing the chflags-locked top-level scripts.
268
+ # detect-gate is a no-op when the killswitch is already active, and only
269
+ # ENGAGES on a CONCLUSIVE /feed/ redirect to auth (infra hiccups ->
270
+ # proceed, so a flaky render never strands the pipeline). On a confirmed
271
+ # logout it engages the flag (which pauses every pipeline on its next fire
272
+ # + starts the 24h recovery clock) and returns 2, so we abort this fire
273
+ # instead of burning a Claude session on a dead session.
389
274
  if [ "${_LI_SESSION_PROBED:-0}" = "1" ]; then
390
275
  return 0
391
276
  fi
@@ -403,9 +288,24 @@ _linkedin_session_detect_gate() {
403
288
  return 0
404
289
  }
405
290
 
291
+ ensure_linkedin_browser_for_backend() {
292
+ # --disable-renderer-backgrounding + --disable-background-timer-throttling:
293
+ # LinkedIn's SPA stops laying out in a backgrounded renderer even with the
294
+ # occlusion flags, so elements measure 0x0 (2026-07-03 fix).
295
+ HC_PLATFORM=linkedin \
296
+ HC_PORT=9556 \
297
+ HC_PROFILE_DIR="$HOME/.claude/browser-profiles/browser-harness-linkedin" \
298
+ HC_DEFAULT_URL="$_BH_LINKEDIN_DEFAULT_URL" \
299
+ HC_CDP_URL="${LINKEDIN_CDP_URL:-$_BH_LINKEDIN_DEFAULT_URL}" \
300
+ HC_LAUNCH_URL="about:blank" \
301
+ HC_WINDOW_POS="${BH_LINKEDIN_WINDOW_POS:-3814,-1050}" \
302
+ HC_WINDOW_SIZE="${BH_LINKEDIN_WINDOW_SIZE:-1024,1013}" \
303
+ HC_EXTRA_FLAGS="--disable-renderer-backgrounding --disable-background-timer-throttling" \
304
+ HC_PRE_LAUNCH_HOOK=_acquire_linkedin_pipeline_lock \
305
+ HC_POST_CLEANUP_HOOK=_linkedin_session_detect_gate \
306
+ hc_ensure_browser
307
+ }
308
+
406
309
  defer_if_foreign_for_backend() {
407
- # Harness Chrome accepts multiple concurrent CDP clients on the same
408
- # browser-harness-linkedin profile, so a foreign MCP wrapper cannot cause
409
- # SingletonLock contention. Always return 1 (do not defer).
410
- return 1
310
+ hc_defer_if_foreign
411
311
  }
@@ -1,6 +1,7 @@
1
1
  #!/bin/bash
2
2
  # reddit-backend.sh - Reddit pipeline browser bootstrap (reddit-harness,
3
- # mirrors twitter-backend.sh / linkedin-backend.sh).
3
+ # mirrors twitter-backend.sh / linkedin-backend.sh; the shared machinery
4
+ # lives in skill/lib/harness-common.sh since 2026-07-14).
4
5
  #
5
6
  # 2026-05-29 migration: Reddit's discovery path (reddit_tools.py) fetched
6
7
  # Reddit's *.json via Python urllib, which Reddit began 403ing from residential
@@ -31,8 +32,8 @@
31
32
  #
32
33
  # ensure_reddit_browser_for_backend
33
34
  # Call AFTER acquire_lock "reddit-browser". Probes harness Chrome on
34
- # port 9557 and launches it idempotently if down, then cleans leftover
35
- # tabs from prior runs.
35
+ # port 9557 and launches it idempotently if down (wedge-aware, focus-safe;
36
+ # see harness-common.sh), then cleans leftover tabs from prior runs.
36
37
  #
37
38
  # defer_if_foreign_for_backend [log_file]
38
39
  # No-op. Harness CDP supports multiple concurrent clients on the same
@@ -53,17 +54,15 @@ fi
53
54
  # Twitter's 9555 and LinkedIn's 9556).
54
55
  export REDDIT_CDP_URL="${REDDIT_CDP_URL:-http://127.0.0.1:9557}"
55
56
 
56
- # Repo root for helper scripts. Honors S4L_REPO_DIR (managed installs run from
57
- # ~/.social-autoposter-mcp/repo/package, NOT ~/social-autoposter); a $HOME
58
- # hardcode here silently no-ops on customer boxes (same bug twitter-backend.sh
59
- # fixed; see its header comment).
60
- _BH_REPO_DIR="${S4L_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
61
-
62
- # Default harness URL - used by ensure_reddit_browser_for_backend +
63
- # cleanup_harness_tabs to decide whether we own this Chrome (and should
57
+ # Default harness URL - used to decide whether we own this Chrome (and should
64
58
  # launch/clean it) or whether it is externally managed (AppMaker, BYO).
65
59
  _BH_REDDIT_DEFAULT_URL="http://127.0.0.1:9557"
66
60
 
61
+ # Shared engine: _BH_REPO_DIR, hc_ensure_browser, hc_cleanup_tabs,
62
+ # _resolve_chrome_bin, wedge detection, focus-safe launch.
63
+ # shellcheck disable=SC1091
64
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness-common.sh"
65
+
67
66
  BROWSER_INSTRUCTIONS=$(cat <<'BROWSER_HARNESS_EOF'
68
67
  BROWSER BACKEND: reddit-harness (browser-harness MCP, CDP-driven REAL Google Chrome on
69
68
  port 9557, profile ~/.claude/browser-profiles/reddit-harness). The Chrome is already
@@ -121,127 +120,42 @@ BROWSER_HARNESS_EOF
121
120
  )
122
121
 
123
122
  cleanup_harness_tabs() {
124
- # Close every CDP "page" tab except one. Same pattern as twitter/linkedin
125
- # backend, scoped to the Reddit harness Chrome on port 9557.
126
- #
127
- # Health-check gate: 10s timeout + ONE retry; log skips so they are not silent.
128
- local _probe="curl -sf --max-time 10 -o /dev/null http://127.0.0.1:9557/json/version"
129
- if ! $_probe 2>/dev/null; then
130
- sleep 1
131
- if ! $_probe 2>/dev/null; then
132
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] cleanup_harness_tabs: SKIPPED (reddit-harness CDP /json/version unreachable after 10s+retry)" >&2
133
- return 0
134
- fi
135
- fi
136
- BH_CLEANUP_PORT=9557 python3 "$_BH_REPO_DIR/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
123
+ hc_cleanup_tabs 9557 reddit-harness
137
124
  }
138
125
 
139
- _resolve_chrome_bin() {
140
- # Auto-detect Chrome/Chromium so the same script launches the harness on
141
- # macOS dev boxes AND Linux VMs. Override with BH_CHROME_BIN.
142
- if [ -n "${BH_CHROME_BIN:-}" ] && [ -x "$BH_CHROME_BIN" ]; then
143
- echo "$BH_CHROME_BIN"; return 0
126
+ _reddit_defer_if_posting() {
127
+ # Posting-active defer (2026-07-14, mirrors twitter's posting-active flag):
128
+ # a poster mid-drain owns the ONE shared harness tab; a pipeline that
129
+ # grabs it mid-post navigates the tab out from under the poster and the
130
+ # comment-form check false-positives as account_blocked_in_sub. When the
131
+ # flag is fresh (heartbeat < 120s; post_reddit.py re-stamps per row), skip
132
+ # this fire via the reserved code 78 — the launchd job re-fires on its
133
+ # next cadence. A stale flag never blocks (a killed poster must not wedge
134
+ # every reddit pipeline).
135
+ local _f="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/reddit-posting-active.json"
136
+ [ -f "$_f" ] || return 0
137
+ local _age
138
+ _age=$(( $(date +%s) - $(stat -f %m "$_f" 2>/dev/null || stat -c %Y "$_f" 2>/dev/null || echo 0) ))
139
+ if [ "$_age" -lt 120 ]; then
140
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] reddit posting-active (age ${_age}s); skipping this fire (rc=78) so the poster keeps the tab" >&2
141
+ return 78
144
142
  fi
145
- for _p in \
146
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
147
- "/Applications/Chromium.app/Contents/MacOS/Chromium" \
148
- "/usr/bin/google-chrome" "/usr/bin/google-chrome-stable" \
149
- "/usr/bin/chromium" "/usr/bin/chromium-browser" "/snap/bin/chromium"
150
- do
151
- if [ -x "$_p" ]; then echo "$_p"; return 0; fi
152
- done
153
- for _n in google-chrome google-chrome-stable chromium chromium-browser; do
154
- _which=$(command -v "$_n" 2>/dev/null) && [ -n "$_which" ] && { echo "$_which"; return 0; }
155
- done
156
- echo ""; return 1
143
+ return 0
157
144
  }
158
145
 
159
146
  ensure_reddit_browser_for_backend() {
160
- # AppMaker / BYO Chrome: REDDIT_CDP_URL points at something other than our
161
- # default harness URL. Don't touch that browser; just probe it and bail.
162
- if [ "${REDDIT_CDP_URL:-$_BH_REDDIT_DEFAULT_URL}" != "$_BH_REDDIT_DEFAULT_URL" ]; then
163
- local _ext_url="${REDDIT_CDP_URL}"
164
- if curl -sf --max-time 2 -o /dev/null "${_ext_url}/json/version" 2>/dev/null; then
165
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
166
- return 0
167
- fi
168
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: REDDIT_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host." >&2
169
- return 1
170
- fi
171
- # Probe + launch harness Chrome on port 9557 if needed.
172
- if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null; then
173
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Reddit harness Chrome down on port 9557, launching..." >&2
174
- local _chrome_bin
175
- _chrome_bin=$(_resolve_chrome_bin)
176
- if [ -z "$_chrome_bin" ]; then
177
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
178
- return 1
179
- fi
180
- # On Linux + no display, run headless. On root, add --no-sandbox.
181
- local _extra=()
182
- case "$(uname -s)" in
183
- Linux)
184
- _extra+=(--no-sandbox --disable-dev-shm-usage)
185
- if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then
186
- _extra+=(--headless=new --disable-gpu)
187
- fi
188
- ;;
189
- Darwin)
190
- # Default position = the Reddit browser's current off-screen
191
- # spot (captured 2026-05-29); overridable via BH_REDDIT_WINDOW_POS.
192
- _extra+=(--window-position="${BH_REDDIT_WINDOW_POS:-2131,-1032}")
193
- _extra+=(--window-size="${BH_REDDIT_WINDOW_SIZE:-911,1016}")
194
- ;;
195
- esac
196
- # Self-heal (2026-06-03): reap any stale Chrome holding THIS profile dir
197
- # but not answering CDP on our port, else the relaunch hands off via the
198
- # SingletonLock and loops "failed to start within 12s". Exact-dir match
199
- # (trailing space) keeps this scoped to reddit-harness only. See
200
- # twitter-backend.sh for the regression that motivated this.
201
- local _prof_dir="$HOME/.claude/browser-profiles/reddit-harness"
202
- local _stale_pids
203
- _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
204
- if [ -n "$_stale_pids" ] && ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null; then
205
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] CDP down but Chrome still holds $_prof_dir (pids: $(echo $_stale_pids | tr '\n' ' ')); reaping stale profile owner before relaunch" >&2
206
- kill $_stale_pids 2>/dev/null || true
207
- sleep 2
208
- _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
209
- [ -n "$_stale_pids" ] && { kill -9 $_stale_pids 2>/dev/null || true; sleep 1; }
210
- rm -f "$_prof_dir/SingletonLock" "$_prof_dir/SingletonSocket" "$_prof_dir/SingletonCookie" 2>/dev/null || true
211
- fi
212
- # Spawn via the SHARED launcher (skill/lib/browser-launch.sh): clean-
213
- # exit stamp (no Aw-Snap restore), `open -n -g` on macOS (no focus
214
- # steal, out of this job's pgroup), setsid exec fallback elsewhere.
215
- # This backend previously used a bare exec and had NONE of those
216
- # protections (2026-07-13 consolidation).
217
- # shellcheck disable=SC1091
218
- source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/browser-launch.sh"
219
- launch_harness_chrome "$_chrome_bin" "$_prof_dir" \
220
- --remote-debugging-port=9557 \
221
- --user-data-dir="$HOME/.claude/browser-profiles/reddit-harness" \
222
- --no-first-run --no-default-browser-check \
223
- --disable-features=ChromeWhatsNewUI,CalculateNativeWinOcclusion \
224
- --disable-backgrounding-occluded-windows \
225
- "${_extra[@]}" \
226
- about:blank
227
- for _i in 1 2 3 4 5 6 7 8 9 10 11 12; do
228
- curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null && break
229
- sleep 1
230
- done
231
- if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null; then
232
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: Reddit harness Chrome failed to start within 12s" >&2
233
- return 1
234
- fi
235
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Reddit harness Chrome up on port 9557" >&2
236
- fi
237
- # Always close leftover tabs from prior runs. Safe under acquire_lock
238
- # "reddit-browser" serialization.
239
- cleanup_harness_tabs
147
+ HC_PLATFORM=reddit \
148
+ HC_PORT=9557 \
149
+ HC_PROFILE_DIR="$HOME/.claude/browser-profiles/reddit-harness" \
150
+ HC_DEFAULT_URL="$_BH_REDDIT_DEFAULT_URL" \
151
+ HC_CDP_URL="${REDDIT_CDP_URL:-$_BH_REDDIT_DEFAULT_URL}" \
152
+ HC_LAUNCH_URL="about:blank" \
153
+ HC_WINDOW_POS="${BH_REDDIT_WINDOW_POS:-2131,-1032}" \
154
+ HC_WINDOW_SIZE="${BH_REDDIT_WINDOW_SIZE:-911,1016}" \
155
+ HC_PRE_LAUNCH_HOOK=_reddit_defer_if_posting \
156
+ hc_ensure_browser
240
157
  }
241
158
 
242
159
  defer_if_foreign_for_backend() {
243
- # Harness Chrome accepts multiple concurrent CDP clients on the same
244
- # reddit-harness profile, so a foreign MCP wrapper cannot cause
245
- # SingletonLock contention. Always return 1 (do not defer).
246
- return 1
160
+ hc_defer_if_foreign
247
161
  }