@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,8 @@
1
1
  #!/bin/bash
2
2
  # twitter-backend.sh - Twitter pipeline browser bootstrap (harness-only since
3
- # 2026-05-19; the legacy twitter-agent Playwright MCP path was fully ripped out).
3
+ # 2026-05-19; the legacy twitter-agent Playwright MCP path was fully ripped
4
+ # out; the shared machinery lives in skill/lib/harness-common.sh since
5
+ # 2026-07-14).
4
6
  #
5
7
  # Source this AFTER lock.sh, BEFORE any acquire_lock / browser pre-flight /
6
8
  # claude -p subprocess calls. Sets these for the caller:
@@ -21,8 +23,9 @@
21
23
  #
22
24
  # ensure_twitter_browser_for_backend
23
25
  # Call AFTER acquire_lock "twitter-browser". Probes harness Chrome on
24
- # port 9555 and launches it idempotently if down, then cleans leftover
25
- # tabs from prior runs.
26
+ # port 9555 and launches it idempotently if down (wedge-aware, two-strike
27
+ # tolerant, focus-safe; see harness-common.sh), restores the X session if
28
+ # logged out, then cleans leftover tabs from prior runs.
26
29
  #
27
30
  # defer_if_foreign_for_backend [log_file]
28
31
  # No-op. Harness CDP supports multiple concurrent clients on the same
@@ -31,14 +34,6 @@
31
34
 
32
35
  MCP_CONFIG_FILE="$HOME/.claude/browser-agent-configs/twitter-harness-mcp.json"
33
36
 
34
- # Repo root for the helper scripts this file shells out to, resolved from this
35
- # file's own location (skill/lib/ -> two up), honoring S4L_REPO_DIR when the
36
- # caller sets it. The old $HOME/social-autoposter hardcodes ran code OUTSIDE
37
- # the managed package on customer boxes (unreachable by auto-update) and
38
- # silently no-op'd where that directory didn't exist, so session restore and
39
- # tab cleanup never actually ran on such installs (S4L-4H triage 2026-07-12).
40
- _BH_REPO_DIR="${S4L_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
41
-
42
37
  # Per-host env override (written by bin/cli.js when installing on an AppMaker
43
38
  # VM, where the canonical browser is Chromium on port 9222 behind the SOAX
44
39
  # residential proxy at 127.0.0.1:3003, NOT the harness Chrome on 9555). On a
@@ -54,10 +49,15 @@ fi
54
49
  # http://127.0.0.1:9222 via ~/.social-autoposter-env above.
55
50
  export TWITTER_CDP_URL="${TWITTER_CDP_URL:-http://127.0.0.1:9555}"
56
51
 
57
- # Default harness URL — used by ensure_twitter_browser_for_backend +
58
- # cleanup_harness_tabs to decide whether we own this Chrome (and should
52
+ # Default harness URL - used to decide whether we own this Chrome (and should
59
53
  # launch/clean it) or whether it is externally managed (AppMaker, BYO).
60
54
  _BH_DEFAULT_URL="http://127.0.0.1:9555"
55
+
56
+ # Shared engine: _BH_REPO_DIR, hc_ensure_browser, hc_cleanup_tabs,
57
+ # _resolve_chrome_bin, wedge detection, focus-safe launch.
58
+ # shellcheck disable=SC1091
59
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness-common.sh"
60
+
61
61
  # DEPRECATED 2026-06-26: this block is NO LONGER injected into any model prompt.
62
62
  # run-twitter-cycle.sh now sets TW_ENGINE_PREFIX="" — Phase 1 (query) and Phase 2b
63
63
  # (prep) are tool-free; the model drafts from inlined candidate context only, and all
@@ -123,277 +123,55 @@ BROWSER_HARNESS_EOF
123
123
  )
124
124
 
125
125
  cleanup_harness_tabs() {
126
- # Close every CDP "page" tab except one. Delegated to a standalone Python
127
- # script because bash 3.2 (what launchd uses) cannot parse a nested heredoc
128
- # inside a function body inside a sourced file. Inline form here broke every
129
- # launchd-fired twitter script on 2026-05-14 until this refactor.
130
- #
131
- # Health-check gate: 2026-05-16 the original `--max-time 2` was too strict.
132
- # When harness Chrome is busy (long scans, lock backups, CPU-pinned),
133
- # the /json/version probe times out, cleanup is silently skipped, and the
134
- # next scan's new_tab() leaks an orphan tab. Symptom: occasional
135
- # "closed 14/14 extra page tabs" cycles after several skips piled up.
136
- # Now: 10s timeout + ONE retry; log skips so they are not silent.
137
- local _probe="curl -sf --max-time 10 -o /dev/null http://127.0.0.1:9555/json/version"
138
- if ! $_probe 2>/dev/null; then
139
- sleep 1
140
- if ! $_probe 2>/dev/null; then
141
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] cleanup_harness_tabs: SKIPPED (harness CDP /json/version unreachable after 10s+retry)" >&2
142
- return 0
143
- fi
144
- fi
145
- python3 "$_BH_REPO_DIR/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
126
+ hc_cleanup_tabs 9555 twitter-harness
146
127
  }
147
128
 
129
+ # Back-compat alias: older sourced contexts referenced _bh_cdp_ready directly.
148
130
  _bh_cdp_ready() {
149
- # Real CDP readiness: complete an actual connect_over_cdp handshake against
150
- # the harness. /json/version alone is a liveness probe that a WEDGED Chrome
151
- # still passes (S4L-4H); see scripts/cdp_ready_check.py. Prints the probe's
152
- # one-line JSON verdict on stdout; exit status is the verdict.
153
- "${S4L_PYTHON:-python3}" "$_BH_REPO_DIR/scripts/cdp_ready_check.py" \
154
- "${1:-$_BH_DEFAULT_URL}" 8000 2>/dev/null
155
- }
156
-
157
- _bh_record_cdp_health() {
158
- # Persist the latest readiness verdict where memory_snapshot.py picks it up
159
- # (cdp_health block on the per-minute heartbeat sample), so a wedge and its
160
- # restart are visible centrally in installation_resource_samples without
161
- # SSHing the box. $1 = action tag, $2 = the probe's JSON verdict (may be
162
- # empty). Best effort, never fails the caller.
163
- printf '{"ts":"%s","url":"%s","action":"%s","verdict":%s}\n' \
164
- "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
165
- "${TWITTER_CDP_URL:-$_BH_DEFAULT_URL}" \
166
- "$1" \
167
- "${2:-null}" \
168
- > "$_BH_REPO_DIR/skill/logs/cdp-health.json" 2>/dev/null || true
131
+ hc_cdp_ready "${1:-$_BH_DEFAULT_URL}"
169
132
  }
170
133
 
171
- _resolve_chrome_bin() {
172
- # Auto-detect Chrome/Chromium so the same script launches the harness on
173
- # macOS dev boxes AND Linux VMs. Override with BH_CHROME_BIN.
174
- if [ -n "${BH_CHROME_BIN:-}" ] && [ -x "$BH_CHROME_BIN" ]; then
175
- echo "$BH_CHROME_BIN"; return 0
176
- fi
177
- for _p in \
178
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
179
- "/Applications/Chromium.app/Contents/MacOS/Chromium" \
180
- "/usr/bin/google-chrome" "/usr/bin/google-chrome-stable" \
181
- "/usr/bin/chromium" "/usr/bin/chromium-browser" "/snap/bin/chromium"
182
- do
183
- if [ -x "$_p" ]; then echo "$_p"; return 0; fi
184
- done
185
- for _n in google-chrome google-chrome-stable chromium chromium-browser; do
186
- _which=$(command -v "$_n" 2>/dev/null) && [ -n "$_which" ] && { echo "$_which"; return 0; }
187
- done
188
- echo ""; return 1
134
+ _tw_restore_session() {
135
+ # Re-inject the stored X session if the harness Chrome is logged out - e.g.
136
+ # a keychain re-lock wiped Chrome's encrypted Cookies SQLite on this launch
137
+ # (Gap B, 2026-06-02), or an AppMaker Hobby-tier sandbox substitution
138
+ # reseeded /root and wiped the profile. restore_twitter_session.py reads
139
+ # the keychain-independent local cookie mirror (written by connect_x) and
140
+ # injects via CDP. No-op when already logged in; never blocks the cycle on
141
+ # failure. Runs on both the freshly-launched and already-up paths so a
142
+ # mid-life logout heals.
143
+ python3 "$_BH_REPO_DIR/scripts/restore_twitter_session.py" 2>&1 \
144
+ | sed 's/^/[restore] /' >&2 || true
189
145
  }
190
146
 
191
147
  ensure_twitter_browser_for_backend() {
192
- # AppMaker / BYO Chrome: TWITTER_CDP_URL points at something other than our
193
- # default harness URL. Don't touch that browser; just probe it and bail.
194
- # The AppMaker bootstrap (and any future BYO setup) is responsible for
195
- # keeping the externally-managed Chrome alive.
196
- if [ "${TWITTER_CDP_URL:-$_BH_DEFAULT_URL}" != "$_BH_DEFAULT_URL" ]; then
197
- local _ext_url="${TWITTER_CDP_URL}"
198
- if curl -sf --max-time 2 -o /dev/null "${_ext_url}/json/version" 2>/dev/null; then
199
- # HTTP answers; verify the CDP handshake actually completes before
200
- # handing this browser to the pipeline. We do NOT restart an
201
- # externally-managed Chrome; fail fast so the cycle doesn't burn
202
- # 180s per downstream attach against a wedged browser.
203
- local _ext_verdict
204
- if ! _ext_verdict=$(_bh_cdp_ready "$_ext_url"); then
205
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: external Chrome at ${_ext_url} is WEDGED (/json/version answers but the CDP handshake never completes). Host must restart it (AppMaker /opt/startup.sh, etc)." >&2
206
- echo "twitter_cdp_wedge: detected url=${_ext_url} action=none-external" >&2
207
- _bh_record_cdp_health external_wedged "$_ext_verdict"
208
- return 1
209
- fi
210
- _bh_record_cdp_health ok-external "$_ext_verdict"
211
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
212
- # Restore the Twitter login if the sandbox was substituted. AppMaker
213
- # Hobby-tier sandboxes have a 1h TTL; on substitution /root is reseeded
214
- # from /etc/skel-root and the harness profile (cookies) is wiped. This
215
- # re-injects the stored session from social_accounts via the HTTP API.
216
- # No-op when already logged in. Never blocks the cycle on failure.
217
- python3 "$_BH_REPO_DIR/scripts/restore_twitter_session.py" 2>&1 | sed 's/^/[restore] /' >&2 || true
218
- return 0
219
- fi
220
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: TWITTER_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host (AppMaker /opt/startup.sh, etc)." >&2
221
- return 1
222
- fi
223
- # Probe + launch harness Chrome on port 9555 if needed. Two-stage probe:
224
- # /json/version (liveness) then a real CDP handshake (readiness). A wedged
225
- # Chrome passes the first and fails the second; handing it downstream made
226
- # every attach eat Playwright's 180s default while holding the browser lock
227
- # (S4L-4H), so it gets killed and relaunched here instead.
228
- local _need_launch=0 _launch_reason=""
229
- local _bh_prof_dir="$HOME/.claude/browser-profiles/browser-harness"
230
- if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null; then
231
- _need_launch=1; _launch_reason="http_down"
232
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Harness Chrome down on port 9555, launching..." >&2
233
- else
234
- local _ready_verdict
235
- # TWO-STRIKE gate (2026-07-13): one failed handshake is NOT proof of a
236
- # wedge. Under heavy system load (observed at loadavg ~18) a perfectly
237
- # healthy Chrome blows the 8s Playwright attach budget, and a
238
- # single-failure kill produced 3 false wedge-kills in one day on the
239
- # operator Mac — each one a relaunch flash plus an "Aw, Snap" corpse
240
- # tab from the SIGKILLed renderer. First failure: stamp a strike file
241
- # and proceed WITHOUT killing (a truly wedged Chrome makes the cycle's
242
- # own pre-flight fail loudly right after, so nothing silently hangs).
243
- # Second consecutive failure within 30 minutes: genuinely wedged, kill
244
- # + relaunch. A passing check clears any stale strike.
245
- local _strike="/tmp/s4l_cdp_wedge_strike_9555"
246
- if ! _ready_verdict=$(_bh_cdp_ready "$_BH_DEFAULT_URL"); then
247
- if [ -z "$(find "$_strike" -mmin -30 2>/dev/null)" ]; then
248
- touch "$_strike"
249
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Harness Chrome failed the CDP handshake check (${_ready_verdict:-no verdict}); first strike — NOT killing (loaded-machine tolerance). Second consecutive failure within 30m triggers the wedge heal." >&2
250
- echo "twitter_cdp_wedge: detected url=$_BH_DEFAULT_URL action=first_strike" >&2
251
- _bh_record_cdp_health wedge_first_strike "$_ready_verdict"
252
- # Fall through WITHOUT killing: session restore + tab cleanup
253
- # below still run.
254
- else
255
- rm -f "$_strike" 2>/dev/null || true
256
- _need_launch=1; _launch_reason="cdp_wedge"
257
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Harness Chrome WEDGED on port 9555 (second consecutive handshake failure: ${_ready_verdict:-no verdict}); killing and relaunching..." >&2
258
- # Machine-greppable marker (same stderr-marker convention as
259
- # twitter_access_gate; bin/server.js parses these).
260
- echo "twitter_cdp_wedge: detected url=$_BH_DEFAULT_URL action=restart" >&2
261
- _bh_record_cdp_health wedge_restart "$_ready_verdict"
262
- local _wedge_pids
263
- _wedge_pids=$(pgrep -f -- "--user-data-dir=$_bh_prof_dir " 2>/dev/null || true)
264
- if [ -n "$_wedge_pids" ]; then
265
- kill $_wedge_pids 2>/dev/null || true
266
- sleep 2
267
- _wedge_pids=$(pgrep -f -- "--user-data-dir=$_bh_prof_dir " 2>/dev/null || true)
268
- [ -n "$_wedge_pids" ] && { kill -9 $_wedge_pids 2>/dev/null || true; sleep 1; }
269
- rm -f "$_bh_prof_dir/SingletonLock" "$_bh_prof_dir/SingletonSocket" "$_bh_prof_dir/SingletonCookie" 2>/dev/null || true
270
- fi
271
- fi
272
- else
273
- # Healthy handshake — clear any earlier strike so isolated blips
274
- # never accumulate into a kill.
275
- rm -f "$_strike" 2>/dev/null || true
276
- fi
277
- fi
278
- if [ "$_need_launch" = 1 ]; then
279
- # Dated relaunch stamp for central observability: memory_snapshot.py
280
- # counts these (chrome_relaunches block) so a kill-respawn loop like
281
- # 2026-07-12's launchd pgroup reaping is visible per-install in Cloud
282
- # Logging without depending on the menubar foreground observer.
283
- echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) port=9555 reason=${_launch_reason}" \
284
- >> "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/logs/chrome-relaunch-events.log" 2>/dev/null || true
285
- local _chrome_bin
286
- _chrome_bin=$(_resolve_chrome_bin)
287
- if [ -z "$_chrome_bin" ]; then
288
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
289
- return 1
290
- fi
291
- # On Linux + no display, run headless. On root, add --no-sandbox.
292
- # Window-position/size only meaningful on macOS multi-monitor; skip
293
- # elsewhere so we don't hide the window off-screen on single-display
294
- # Linux VMs.
295
- local _extra=()
296
- case "$(uname -s)" in
297
- Linux)
298
- _extra+=(--no-sandbox --disable-dev-shm-usage)
299
- if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then
300
- _extra+=(--headless=new --disable-gpu)
301
- fi
302
- ;;
303
- Darwin)
304
- _extra+=(--window-position="${BH_WINDOW_POS:-3042,-1032}")
305
- _extra+=(--window-size="${BH_WINDOW_SIZE:-1024,1013}")
306
- ;;
307
- esac
308
- # --password-store=basic + --use-mock-keychain: encrypt the cookie store
309
- # with Chrome's fixed obfuscation key instead of the macOS Keychain
310
- # ("Chrome Safe Storage"). Without this, a keychain lock/re-lock leaves
311
- # Chrome unable to decrypt its Cookies SQLite on the next launch, so it
312
- # discards the session and the harness comes up logged out. With it, the
313
- # x.com cookies persist + decrypt across restarts natively, no
314
- # re-injection needed. Matches the flags the Playwright browser agents
315
- # already use. (Root-cause persistence fix, 2026-06-02; the cookie
316
- # mirror + restore_twitter_session.py remain as the safety net.)
317
- # Self-heal (2026-06-03): if a Chrome already holds THIS profile dir but
318
- # is not answering CDP on our port, a fresh launch hands off to it via
319
- # Chrome's SingletonLock and exits without ever binding our port — the
320
- # old "failed to start within 12s" loop (8h Twitter outage overnight
321
- # 2026-06-02/03, root cause: a server.py regression that dropped
322
- # BH_PROFILE_NAME and collapsed the linkedin/twitter harness profiles
323
- # onto this one, stranding an orphan on 9556). Reap the stale owner of
324
- # our EXACT profile dir (trailing space in the pattern so browser-harness
325
- # never matches browser-harness-linkedin) before relaunching.
326
- local _prof_dir="$HOME/.claude/browser-profiles/browser-harness"
327
- local _stale_pids
328
- _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
329
- if [ -n "$_stale_pids" ] && ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null; then
330
- 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
331
- kill $_stale_pids 2>/dev/null || true
332
- sleep 2
333
- _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
334
- [ -n "$_stale_pids" ] && { kill -9 $_stale_pids 2>/dev/null || true; sleep 1; }
335
- rm -f "$_prof_dir/SingletonLock" "$_prof_dir/SingletonSocket" "$_prof_dir/SingletonCookie" 2>/dev/null || true
336
- fi
337
- # Spawn via the SHARED launcher (skill/lib/browser-launch.sh): clean-
338
- # exit stamp (no Aw-Snap restore), `open -n -g` on macOS (no focus
339
- # steal, out of this job's pgroup), setsid exec fallback elsewhere.
340
- # Do NOT hand-roll launch logic per backend.
341
- # shellcheck disable=SC1091
342
- source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/browser-launch.sh"
343
- launch_harness_chrome "$_chrome_bin" "$_prof_dir" \
344
- --remote-debugging-port=9555 \
345
- --user-data-dir="$HOME/.claude/browser-profiles/browser-harness" \
346
- --no-first-run --no-default-browser-check \
347
- --password-store=basic --use-mock-keychain \
348
- --disable-features=ChromeWhatsNewUI,CalculateNativeWinOcclusion \
349
- --disable-backgrounding-occluded-windows \
350
- "${_extra[@]}" \
351
- "${BH_LAUNCH_URL:-https://x.com}"
352
- for _i in 1 2 3 4 5 6 7 8 9 10 11 12; do
353
- curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null && break
354
- sleep 1
355
- done
356
- if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null; then
357
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: harness Chrome failed to start within 12s" >&2
358
- _bh_record_cdp_health launch_failed null
359
- return 1
360
- fi
361
- # Verify the fresh Chrome actually completes a CDP handshake before
362
- # declaring victory; a relaunch that comes up wedged again should fail
363
- # the cycle loudly, not feed 180s hangs downstream.
364
- local _post_verdict
365
- if ! _post_verdict=$(_bh_cdp_ready "$_BH_DEFAULT_URL"); then
366
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: harness Chrome answers HTTP after relaunch but the CDP handshake is STILL failing: ${_post_verdict:-no verdict}" >&2
367
- echo "twitter_cdp_wedge: detected url=$_BH_DEFAULT_URL action=relaunch_failed" >&2
368
- _bh_record_cdp_health relaunch_failed "$_post_verdict"
369
- return 1
370
- fi
371
- _bh_record_cdp_health relaunched "$_post_verdict"
372
- echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Harness Chrome up on port 9555" >&2
373
- else
374
- _bh_record_cdp_health ok "${_ready_verdict:-null}"
375
- fi
376
- # Re-inject the stored X session if the harness Chrome is logged out — e.g. a
377
- # keychain re-lock wiped Chrome's encrypted Cookies SQLite on this launch
378
- # (Gap B, 2026-06-02). restore_twitter_session.py reads the keychain-
379
- # independent local cookie mirror (written by connect_x) and injects via CDP.
380
- # No-op when already logged in; never blocks the cycle on failure. Runs on
381
- # both the freshly-launched and already-up paths so a mid-life logout heals.
382
- TWITTER_CDP_URL="http://127.0.0.1:9555" \
383
- python3 "$_BH_REPO_DIR/scripts/restore_twitter_session.py" 2>&1 \
384
- | sed 's/^/[restore] /' >&2 || true
385
- # Always close leftover tabs from prior runs. Safe under acquire_lock
386
- # "twitter-browser" serialization (every caller of this function holds
387
- # that lock), so we will not race with another active twitter run.
388
- cleanup_harness_tabs
148
+ # --password-store=basic + --use-mock-keychain: encrypt the cookie store
149
+ # with Chrome's fixed obfuscation key instead of the macOS Keychain
150
+ # ("Chrome Safe Storage"). Without this, a keychain lock/re-lock leaves
151
+ # Chrome unable to decrypt its Cookies SQLite on the next launch, so it
152
+ # discards the session and the harness comes up logged out. (Root-cause
153
+ # persistence fix, 2026-06-02; _tw_restore_session remains the safety net.)
154
+ #
155
+ # HC_HEALTH_FILE keeps the legacy cdp-health.json name that
156
+ # memory_snapshot.py reads; HC_WEDGE_MARKER resolves to twitter_cdp_wedge,
157
+ # the stderr marker bin/server.js greps. Do not rename either.
158
+ HC_PLATFORM=twitter \
159
+ HC_PORT=9555 \
160
+ HC_PROFILE_DIR="$HOME/.claude/browser-profiles/browser-harness" \
161
+ HC_DEFAULT_URL="$_BH_DEFAULT_URL" \
162
+ HC_CDP_URL="${TWITTER_CDP_URL:-$_BH_DEFAULT_URL}" \
163
+ HC_LAUNCH_URL="${BH_LAUNCH_URL:-https://x.com}" \
164
+ HC_WINDOW_POS="${BH_WINDOW_POS:-3042,-1032}" \
165
+ HC_WINDOW_SIZE="${BH_WINDOW_SIZE:-1024,1013}" \
166
+ HC_EXTRA_FLAGS="--password-store=basic --use-mock-keychain" \
167
+ HC_HEALTH_FILE="$_BH_REPO_DIR/skill/logs/cdp-health.json" \
168
+ HC_POST_LAUNCH_HOOK=_tw_restore_session \
169
+ HC_EXTERNAL_OK_HOOK=_tw_restore_session \
170
+ hc_ensure_browser
389
171
  }
390
172
 
391
173
  defer_if_foreign_for_backend() {
392
- # Harness Chrome accepts multiple concurrent CDP clients on the same
393
- # browser-harness profile, so a foreign MCP wrapper (Fazm Dev / IDE)
394
- # cannot cause the SingletonLock contention that historically blocked
395
- # the twitter-agent profile. Always return 1 (do not defer).
396
- return 1
174
+ hc_defer_if_foreign
397
175
  }
398
176
 
399
177
  # --- browser-harness `-c` capability self-heal (added 2026-06-02) -----------
package/skill/lock.sh CHANGED
@@ -709,3 +709,60 @@ release_lock() {
709
709
  done
710
710
  _SA_LOCK_DIRS=(${new_stack[@]+"${new_stack[@]}"})
711
711
  }
712
+
713
+ # ---- whole-run pipeline singleton (generalized 2026-07-14) ------------------
714
+ # acquire_pipeline_singleton NAME
715
+ #
716
+ # One-driver-per-resource lock for an ENTIRE pipeline run, generalized from
717
+ # linkedin-backend.sh's _acquire_linkedin_pipeline_lock (2026-05-30; read the
718
+ # incident comments there for why every semantic below is load-bearing):
719
+ # - try once (mkdir /tmp/s4l-<NAME>-pipeline.lock); NO waiting/queueing (the
720
+ # 2026-06-04/05 wait experiment starved the comment poster).
721
+ # - reclaim when the recorded holder pid is dead.
722
+ # - re-entrant per top-level script: an env flag survives same-process
723
+ # re-calls, and a holder-pid==$$ check survives subshell/pipe call sites
724
+ # (where the env export is lost but $$ is still the script pid).
725
+ # - a DIFFERENT live pipeline holds it -> return the RESERVED SKIP CODE 78.
726
+ # Callers MUST check for 78 in the PARENT shell and exit 0 (skip this
727
+ # fire; launchd re-fires on cadence). `exit` inside this function only
728
+ # kills the caller's subshell (2026-07-06 incident), and `kill -TERM $$`
729
+ # is neutered by lock.sh's TERM trap, so the rc contract is the ONLY
730
+ # mechanism that works.
731
+ # - NO release trap on purpose: the next pipeline's dead-pid check reclaims
732
+ # a finished run's dir, and adding a trap would clobber the parent
733
+ # scripts' EXIT/INT/TERM/HUP run_monitor traps.
734
+ acquire_pipeline_singleton() {
735
+ local _ps_name="${1:?pipeline singleton name}"
736
+ local _ps_dir="/tmp/s4l-${_ps_name}-pipeline.lock"
737
+ local _ps_flag_var="_S4L_PIPE_LOCK_HELD_$(printf '%s' "$_ps_name" | tr -c 'A-Za-z0-9' '_')"
738
+ # Already held by THIS process (re-entry across phases) -> proceed.
739
+ if [ "$(eval "printf '%s' \"\${${_ps_flag_var}:-0}\"")" = "1" ]; then
740
+ return 0
741
+ fi
742
+ local _ps_who="${S4L_PIPELINE_NAME:-$(basename "${0:-${_ps_name}-pipeline}")}"
743
+ while : ; do
744
+ if mkdir "$_ps_dir" 2>/dev/null; then
745
+ echo "$$" > "$_ps_dir/pid"
746
+ echo "$_ps_who" > "$_ps_dir/holder"
747
+ eval "export ${_ps_flag_var}=1"
748
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ${_ps_name}-pipeline lock ACQUIRED by $_ps_who (pid $$)" >&2
749
+ return 0
750
+ fi
751
+ local _ps_h_pid _ps_h_who
752
+ _ps_h_pid="$(cat "$_ps_dir/pid" 2>/dev/null || echo "")"
753
+ _ps_h_who="$(cat "$_ps_dir/holder" 2>/dev/null || echo "?")"
754
+ # Re-entry when the acquiring call ran in a subshell/pipe: the env flag
755
+ # was lost with that subshell, but the recorded holder pid is still OURS.
756
+ if [ "$_ps_h_pid" = "$$" ]; then
757
+ eval "export ${_ps_flag_var}=1"
758
+ return 0
759
+ fi
760
+ if [ -z "$_ps_h_pid" ] || ! kill -0 "$_ps_h_pid" 2>/dev/null; then
761
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ${_ps_name}-pipeline lock: reclaiming stale lock (dead holder ${_ps_h_who} pid ${_ps_h_pid:-unknown})" >&2
762
+ rm -rf "$_ps_dir"
763
+ continue
764
+ fi
765
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ${_ps_name}-pipeline lock: held by ${_ps_h_who} (pid ${_ps_h_pid}); ${_ps_who} skipping this fire (rc=78)" >&2
766
+ return 78
767
+ done
768
+ }
@@ -23,7 +23,10 @@
23
23
  # the brief post-phase windows, and post_reddit.py's already_posted filter
24
24
  # prevents double-posting across overlapping cycles.
25
25
 
26
- REPO_DIR="$HOME/social-autoposter"
26
+ # Honor S4L_REPO_DIR (set by the MCP-managed plist on customer boxes, where the
27
+ # repo is the materialized package copy, not ~/social-autoposter). The fallback
28
+ # keeps the operator Mac's hand-built plist working unchanged.
29
+ REPO_DIR="${S4L_REPO_DIR:-$HOME/social-autoposter}"
27
30
  LOG_DIR="$REPO_DIR/skill/logs"
28
31
  mkdir -p "$LOG_DIR"
29
32
 
@@ -36,9 +36,13 @@ done <<EOF_ENV
36
36
  $(env | grep '^SAPS_' | cut -d= -f1 | sed 's/$/=/')
37
37
  EOF_ENV
38
38
 
39
- [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
39
+ # Honor S4L_REPO_DIR (customer boxes run the materialized package copy, not
40
+ # ~/social-autoposter; the MCP-managed launchd plist bakes S4L_REPO_DIR in).
41
+ # Fallback keeps the operator Mac's hand-built plist working unchanged.
42
+ REPO_DIR="${S4L_REPO_DIR:-$HOME/social-autoposter}"
43
+
44
+ [ -f "$REPO_DIR/.env" ] && source "$REPO_DIR/.env"
40
45
 
41
- REPO_DIR="$HOME/social-autoposter"
42
46
  LOG_DIR="$REPO_DIR/skill/logs"
43
47
  mkdir -p "$LOG_DIR"
44
48
  LOG_FILE="$LOG_DIR/run-reddit-search-$(date +%Y-%m-%d_%H%M%S).log"
@@ -206,6 +210,43 @@ trap '_sa_emit_run_summary_oneshot; _sa_release_lease_oneshot; _sa_release_locks
206
210
  BATCH_ID="rdcycle-$(date +%Y%m%d-%H%M%S)"
207
211
  log "Cycle batch_id=$BATCH_ID"
208
212
 
213
+ # --- Draft-only mode (2026-07-14, mirrors run-draft-and-publish.sh) ---------
214
+ # The ONE mode flag (scripts/s4l_mode.py draft-only, stdout 1/0) now governs
215
+ # Reddit exactly like the X cycle: when ON, both lanes stop after the draft
216
+ # phase and merge their drafted decisions into the menu-bar review cards
217
+ # (merge_review_queue.py --reddit-plan); posting then happens one approved
218
+ # card at a time via the plugin's post_drafts tool, which reconstructs a
219
+ # one-decision plan and reuses `post_reddit.py --phase post` unchanged. When
220
+ # OFF (operator opt-out), the lanes post autonomously below as always.
221
+ # Fail-safe default is 1 (draft-only) — same posture as the X wrapper.
222
+ DRAFT_ONLY_FLAG="$(python3 "$REPO_DIR/scripts/s4l_mode.py" draft-only 2>/dev/null || echo 1)"
223
+ log "Draft-only flag: $DRAFT_ONLY_FLAG"
224
+
225
+ # Posting-active defer (2026-07-14, mirrors run-draft-and-publish.sh): an
226
+ # MCP-approval poster mid-drain owns the ONE shared harness tab; running the
227
+ # cycle's discovery/prefetch now would navigate the tab out from under it and
228
+ # false-positive the poster's comment-form check. The backend-level defer
229
+ # hook can't cover THIS script (its ensure failure falls back to
230
+ # ensure_browser_healthy and proceeds), so check explicitly and skip the
231
+ # whole fire; launchd re-fires in 15 minutes. Stale flags (heartbeat older
232
+ # than 120s) never block.
233
+ _S4L_PA_FILE="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/reddit-posting-active.json"
234
+ if [ -f "$_S4L_PA_FILE" ]; then
235
+ _S4L_PA_AGE=$(( $(date +%s) - $(stat -f %m "$_S4L_PA_FILE" 2>/dev/null || echo 0) ))
236
+ if [ "$_S4L_PA_AGE" -lt 120 ]; then
237
+ log "Reddit posting-active (age ${_S4L_PA_AGE}s); skipping this cycle fire."
238
+ exit 0
239
+ fi
240
+ fi
241
+
242
+ # Merge a drafted plan into the review cards (draft-only lanes). $1 = plan
243
+ # file, $2 = lane label. merge_review_queue consumes (deletes) the plan file.
244
+ _merge_reddit_drafts_to_cards() {
245
+ local _plan_file="$1" _lane="$2"
246
+ log "$_lane lane: draft-only ON; merging drafted decision(s) into review cards (no autonomous post)."
247
+ python3 "$REPO_DIR/scripts/merge_review_queue.py" --reddit-plan "$_plan_file" 2>&1 | tee -a "$LOG_FILE" || true
248
+ }
249
+
209
250
  # Export the same id as SA_CYCLE_ID so every Claude session spawned downstream
210
251
  # (post_reddit.py → run_claude(), run_claude.sh → claude -p, log_claude_session.py)
211
252
  # stamps claude_sessions.cycle_id with this cycle. Without this, concurrent
@@ -343,6 +384,10 @@ fi
343
384
  # entire batch (~30 min for 10 rows) while peers sat blocked. The pre-flight
344
385
  # at the top of this script already did the one-shot ensure_browser_healthy
345
386
  # work; per-row acquires inside Python handle the rest.
387
+ if [ "$HAS_SALVAGE" = "1" ] && [ "$DRAFT_ONLY_FLAG" = "1" ]; then
388
+ _merge_reddit_drafts_to_cards "$SALVAGE_DRAFT_FILE" "Salvage"
389
+ HAS_SALVAGE=0
390
+ fi
346
391
  if [ "$HAS_SALVAGE" = "1" ]; then
347
392
  log "Salvage lane: posting $SALVAGE_COUNT candidate(s) (per-row reddit-browser lease)..."
348
393
 
@@ -466,6 +511,10 @@ fi
466
511
  # Same per-row lease pattern as the salvage block above (see comment there for
467
512
  # rationale). The lease is acquired/released around each post_via_cdp call
468
513
  # inside `_post_iteration`, NOT around the whole --phase post invocation.
514
+ if [ "$HAS_DISCOVER" = "1" ] && [ "$DRAFT_ONLY_FLAG" = "1" ]; then
515
+ _merge_reddit_drafts_to_cards "$DISCOVER_DRAFT_FILE" "Discover"
516
+ HAS_DISCOVER=0
517
+ fi
469
518
  if [ "$HAS_DISCOVER" = "1" ]; then
470
519
  log "Discover lane: posting $SURVIVORS survivor(s) (per-row reddit-browser lease)..."
471
520