@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.
- package/mcp/dist/index.js +571 -79
- package/mcp/dist/panel.html +2 -3
- package/mcp/dist/redditAuth.js +115 -0
- package/mcp/dist/runtime.js +83 -2
- package/mcp/dist/setup.js +142 -5
- package/mcp/dist/telemetry.js +13 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +32 -6
- package/mcp/menubar/s4l_menubar.py +42 -111
- package/mcp/menubar/s4l_state.py +54 -187
- package/mcp/package.json +1 -1
- package/mcp/shared/onboarding-ledger.cjs +63 -4
- package/package.json +1 -1
- package/scripts/autopilot_stall_watch.py +3 -6
- package/scripts/browser_mutex.py +316 -0
- package/scripts/claude_job.py +21 -0
- package/scripts/config.py +118 -0
- package/scripts/counterparty_history.py +44 -6
- package/scripts/engage_github.py +7 -3
- package/scripts/engage_reddit.py +9 -3
- package/scripts/engagement_signal_feedback.py +256 -0
- package/scripts/learned_preferences.py +37 -14
- package/scripts/memory_snapshot.py +45 -0
- package/scripts/merge_review_queue.py +67 -3
- package/scripts/post_reddit.py +289 -24
- package/scripts/reddit_browser.py +62 -63
- package/scripts/reddit_browser_fetch.py +32 -0
- package/scripts/release-mcpb.sh +17 -0
- package/scripts/reset-test-machine.sh +13 -7
- package/scripts/scan_twitter_thread_followups.py +7 -1
- package/scripts/schedule_state.py +1 -1
- package/scripts/setup_reddit_auth.py +925 -0
- package/scripts/snapshot.py +96 -4
- package/scripts/stats.py +37 -13
- package/scripts/test_no_silent_fallbacks.py +281 -0
- package/scripts/trigger_promo_lane_test.sh +91 -0
- package/scripts/twitter_browser.py +183 -342
- package/scripts/twitter_scan.py +75 -10
- package/scripts/watchdog_hung_runs.py +15 -9
- package/skill/lib/harness-common.sh +344 -0
- package/skill/lib/linkedin-backend.sh +61 -161
- package/skill/lib/reddit-backend.sh +39 -125
- package/skill/lib/twitter-backend.sh +51 -273
- package/skill/lock.sh +57 -0
- package/skill/run-reddit-search-launchd.sh +4 -1
- package/skill/run-reddit-search.sh +51 -2
- 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
|
|
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,
|
|
25
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|