@m13v/s4l 1.6.197-rc.7

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 (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1314 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +497 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,364 @@
1
+ #!/bin/bash
2
+ # linkedin-backend.sh - LinkedIn pipeline browser bootstrap (linkedin-harness,
3
+ # mirrors twitter-backend.sh post the 2026-05-19 Twitter harness migration).
4
+ #
5
+ # Source this AFTER lock.sh, BEFORE any acquire_lock / browser pre-flight /
6
+ # claude -p subprocess calls. Sets these for the caller:
7
+ #
8
+ # MCP_CONFIG_FILE - claude -p --mcp-config path (linkedin-harness MCP)
9
+ # BROWSER_INSTRUCTIONS - prompt block describing the harness backend +
10
+ # its bh_run tool surface (inject at the TOP of any
11
+ # prompt that mentions browser_* tools)
12
+ #
13
+ # And exports (so Python subprocesses like linkedin_browser.py inherit them):
14
+ #
15
+ # LINKEDIN_CDP_URL - http://127.0.0.1:9556 (forces direct CDP attach,
16
+ # skipping ps-based agent-profile discovery)
17
+ #
18
+ # Provides these functions (names mirror twitter-backend for back-compat with
19
+ # the existing call shape used in run-linkedin.sh, stats-linkedin.sh,
20
+ # scan-linkedin-mentions.sh, dm-outreach-linkedin.sh, etc.):
21
+ #
22
+ # ensure_linkedin_browser_for_backend
23
+ # 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.
26
+ #
27
+ # defer_if_foreign_for_backend [log_file]
28
+ # No-op. Harness CDP supports multiple concurrent clients on the same
29
+ # Chrome (no SingletonLock fight), so foreign MCP wrappers never block
30
+ # us. Kept as a function only so callers don't have to change.
31
+ #
32
+ # IMPORTANT — LinkedIn anti-bot considerations (per CLAUDE.md):
33
+ # The 2026-04-17 ban was caused by Voyager API calls + permalink scrape loops
34
+ # (behavioral fingerprinting), NOT by the CDP-attach mechanism itself. The
35
+ # existing discover_linkedin_candidates.py and scrape_linkedin_comment_stats.py
36
+ # already CDP-attach without triggering bans, so the harness substrate is safe.
37
+ # What MUST stay forbidden inside any bh_run script targeting LinkedIn:
38
+ # - /voyager/api/* calls (Python, fetch(), page.evaluate())
39
+ # - Loops that open each post permalink to scrape reactions/comments
40
+ # - scrollBy combined with "Show more comments" / "Load earlier replies" clicks
41
+ # - Programmatic login flows (passive checks only; on checkpoint return early)
42
+
43
+ MCP_CONFIG_FILE="$HOME/.claude/browser-agent-configs/linkedin-harness-mcp.json"
44
+
45
+ # Per-host env override (written by bin/cli.js when installing on an AppMaker
46
+ # VM). On a Mac dev box this file does not exist, so the default below kicks in.
47
+ if [ -f "$HOME/.social-autoposter-env" ]; then
48
+ # shellcheck disable=SC1091
49
+ . "$HOME/.social-autoposter-env"
50
+ fi
51
+
52
+ # Tell linkedin_browser.py (and any other Python helper that honors this env
53
+ # var) to skip ps-based discovery and connect directly to the configured CDP
54
+ # endpoint. Default 9556 (Mac harness Chrome, separate port from Twitter's 9555).
55
+ export LINKEDIN_CDP_URL="${LINKEDIN_CDP_URL:-http://127.0.0.1:9556}"
56
+
57
+ # Resolve a Playwright-capable Python for the browser-path SERP search
58
+ # (discover_linkedin_candidates.py CDP-attaches to the harness Chrome via
59
+ # playwright.sync_api). The agent's bare `python3` resolves to whatever is
60
+ # first on PATH, which on this Mac is /opt/homebrew/bin/python3 (3.14) where
61
+ # Playwright is NOT installed -> ModuleNotFoundError. Playwright lives under
62
+ # /opt/homebrew/bin/python3.11 and /usr/bin/python3 (3.9). Pick the first
63
+ # interpreter that can actually import playwright.sync_api and export it so the
64
+ # Phase A browser prompt can shell out via "$LINKEDIN_DISCOVER_PYTHON" instead
65
+ # of the ambiguous bare "python3". Only the browser backend needs this; the
66
+ # unipile path uses the REST API and never imports Playwright.
67
+ if [ -z "${LINKEDIN_DISCOVER_PYTHON:-}" ]; then
68
+ for _li_py in /opt/homebrew/bin/python3.11 /usr/bin/python3 /opt/homebrew/bin/python3 python3; do
69
+ if command -v "$_li_py" >/dev/null 2>&1 && \
70
+ "$_li_py" -c 'from playwright.sync_api import sync_playwright' >/dev/null 2>&1; then
71
+ export LINKEDIN_DISCOVER_PYTHON="$_li_py"
72
+ break
73
+ fi
74
+ done
75
+ # Fallback: if none resolved, keep bare python3 so the failure is loud and
76
+ # obvious in the run log rather than silently substituting a wrong path.
77
+ export LINKEDIN_DISCOVER_PYTHON="${LINKEDIN_DISCOVER_PYTHON:-python3}"
78
+ fi
79
+
80
+ # Default harness URL - used by ensure_linkedin_browser_for_backend +
81
+ # cleanup_harness_tabs to decide whether we own this Chrome (and should
82
+ # launch/clean it) or whether it is externally managed (AppMaker, BYO).
83
+ _BH_LINKEDIN_DEFAULT_URL="http://127.0.0.1:9556"
84
+
85
+ BROWSER_INSTRUCTIONS=$(cat <<'BROWSER_HARNESS_EOF'
86
+ BROWSER BACKEND: linkedin-harness (browser-harness MCP, CDP-driven REAL Google Chrome on
87
+ port 9556, profile ~/.claude/browser-profiles/browser-harness-linkedin). The Chrome is
88
+ already logged in as Matthew Diakonov (i@m13v.com); cookies persist on disk.
89
+
90
+ You have ONE tool: mcp__linkedin-harness__bh_run(script). It runs arbitrary Python with
91
+ these helpers pre-imported:
92
+ new_tab(url), goto_url(url), wait_for_load(), page_info(),
93
+ capture_screenshot(), # returns path to PNG; Read it to see the page
94
+ click_at_xy(x, y), # coordinate click (viewport pixels)
95
+ js(expression), # page.evaluate-style; returns the result
96
+ type_text(text), # types into currently-focused element
97
+ press_key(key), # e.g. "Enter", "Tab", "Escape"
98
+ scroll(direction, amount), cdp(method, **params)
99
+
100
+ TAB HYGIENE (IMPORTANT): A placeholder tab ALWAYS already exists when you start
101
+ (pre-flight leaves exactly one tab open). REUSE IT: use goto_url() for your VERY FIRST
102
+ navigation as well as every subsequent one, so the existing tab is navigated in place.
103
+ Call new_tab() ONLY as a fallback when no usable tab exists (goto_url errors because
104
+ there is no active page) OR when you genuinely need a second tab open in parallel.
105
+ Opening a fresh tab on first navigation orphans the placeholder and leaks a tab every
106
+ cycle, which exhausts per-process Chrome resources.
107
+
108
+ LINKEDIN SAFETY (HARD RULES):
109
+ - NEVER call /voyager/api/* endpoints (Python, fetch(), js()). That is the internal
110
+ web-client backend and tripped the 2026-04-17 restriction.
111
+ - NEVER loop opening individual post permalinks to scrape reactions/comments.
112
+ - NEVER combine scrollBy() with clicks on "Show more comments" or "Load earlier replies".
113
+ - If a checkpoint / login / verify-you-are-human page appears, return SESSION_INVALID
114
+ immediately and stop. Do not attempt programmatic login.
115
+
116
+ TRANSLATION TABLE - wherever this prompt mentions a Playwright-style tool, do the
117
+ following with bh_run instead:
118
+
119
+ browser_navigate(url) -> Reuse the existing tab (default, incl. first nav):
120
+ bh_run('goto_url("URL"); wait_for_load()')
121
+ Fallback only if no tab exists / parallel tab needed:
122
+ bh_run('new_tab("URL"); wait_for_load()')
123
+ browser_snapshot -> bh_run('print(js("""..."""))') to read DOM as structured data,
124
+ OR bh_run('print(capture_screenshot())') + Read the PNG
125
+ browser_run_code(js) -> bh_run('print(js("""<the JS expression>"""))')
126
+ browser_click(ref=...) -> Find the element via selector, compute center coords from
127
+ getBoundingClientRect, then bh_run('click_at_xy(X, Y)')
128
+ browser_type(ref=..., text=...) -> Click the textbox first (click_at_xy), then bh_run('type_text("TEXT")')
129
+ browser_take_screenshot -> bh_run('print(capture_screenshot())') then Read the path
130
+ browser_press_key("Enter") -> bh_run('press_key("Enter")')
131
+
132
+ EXAMPLE - read recent activity comment count:
133
+ bh_run('''
134
+ goto_url("https://www.linkedin.com/in/me/recent-activity/comments/")
135
+ wait_for_load()
136
+ count = js("""
137
+ return document.querySelectorAll('[data-id^="urn:li:comment:"]').length;
138
+ """)
139
+ print(count)
140
+ ''')
141
+
142
+ VERIFY AFTER EVERY MUTATION by capturing a screenshot and reading the PNG, coordinate
143
+ clicks can miss; visual verification is the only reliable confirmation that the action took.
144
+ BROWSER_HARNESS_EOF
145
+ )
146
+
147
+ cleanup_harness_tabs() {
148
+ # Close every CDP "page" tab except one. Same pattern as twitter-backend,
149
+ # but scoped to the LinkedIn harness Chrome on port 9556.
150
+ #
151
+ # Health-check gate: 10s timeout + ONE retry; log skips so they are not silent.
152
+ local _probe="curl -sf --max-time 10 -o /dev/null http://127.0.0.1:9556/json/version"
153
+ if ! $_probe 2>/dev/null; then
154
+ sleep 1
155
+ if ! $_probe 2>/dev/null; then
156
+ echo "[$(date +%H:%M:%S)] cleanup_harness_tabs: SKIPPED (linkedin-harness CDP /json/version unreachable after 10s+retry)" >&2
157
+ return 0
158
+ fi
159
+ fi
160
+ # Reuse the same cleanup script as Twitter; it just iterates /json on the
161
+ # default port. Pass the port via env so a single script can serve both.
162
+ BH_CLEANUP_PORT=9556 python3 "$HOME/social-autoposter/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
163
+ }
164
+
165
+ # ===== Cross-pipeline whole-run lock (2026-05-30) =====
166
+ # Only ONE LinkedIn browser pipeline may drive the single linkedin-harness
167
+ # Chrome (port 9556) at a time: run-linkedin, engage-linkedin,
168
+ # dm-outreach-linkedin, audit-linkedin, engage-dm-replies-linkedin,
169
+ # stats-linkedin. Without this, two launchd-fired pipelines interleave (each
170
+ # releases the per-phase `linkedin-browser` FIFO lock between phases), so e.g.
171
+ # run-linkedin Phase B posts a comment while engage drives a SERP, yanking the
172
+ # same window back and forth and leaking tabs between reactive sweeps.
173
+ #
174
+ # Every browser pipeline funnels through ensure_linkedin_browser_for_backend
175
+ # before it touches Chrome, so acquiring here covers ALL of them without
176
+ # editing the (chflags-locked) top-level scripts. Semantics mirror
177
+ # run-linkedin.sh's existing singleton guard:
178
+ # - try once (mkdir), reclaim if the holder PID is dead
179
+ # - if a DIFFERENT live pipeline holds it -> exit 0 (skip this fire; the
180
+ # launchd job retries on its next cadence). No indefinite wait, so the
181
+ # ordering vs the per-phase FIFO `linkedin-browser` lock can't deadlock.
182
+ # - idempotent within a process via _LI_PIPELINE_LOCK_HELD so the SECOND
183
+ # phase-call (e.g. run-linkedin Phase B) does not block on a lock this
184
+ # same process already owns.
185
+ # No release trap on purpose: a finished pipeline's lock dir is reclaimed by
186
+ # the next pipeline's dead-PID check, exactly like the singleton guard. This
187
+ # avoids clobbering the parent scripts' EXIT/INT/TERM/HUP run_monitor traps.
188
+ _LI_PIPELINE_LOCK_DIR="/tmp/saps-linkedin-pipeline.lock"
189
+ _acquire_linkedin_pipeline_lock() {
190
+ # Already held by THIS process (re-entry across phases) -> proceed.
191
+ if [ "${_LI_PIPELINE_LOCK_HELD:-0}" = "1" ]; then
192
+ return 0
193
+ fi
194
+ local _who="${S4L_PIPELINE_NAME:-$(basename "${0:-linkedin-pipeline}")}"
195
+ # BAIL, don't wait. Reverted 2026-06-05 to the original behavior: if another
196
+ # LinkedIn pipeline already drives the 9556 Chrome, this fire exits 0 and
197
+ # launchd re-fires on its next cadence. The 2026-06-04/05 "wait + drop/retake
198
+ # browser lock" experiment starved the 15-min run-linkedin comment poster by
199
+ # making it queue (and risk a lock-ordering deadlock) behind stats/dm jobs.
200
+ # No indefinite wait, so the per-phase FIFO linkedin-browser lock can't
201
+ # deadlock against this coarse one-driver-per-Chrome lock.
202
+ while : ; do
203
+ if mkdir "$_LI_PIPELINE_LOCK_DIR" 2>/dev/null; then
204
+ echo "$$" > "$_LI_PIPELINE_LOCK_DIR/pid"
205
+ echo "$_who" > "$_LI_PIPELINE_LOCK_DIR/holder"
206
+ export _LI_PIPELINE_LOCK_HELD=1
207
+ echo "[$(date +%H:%M:%S)] linkedin-pipeline lock ACQUIRED by $_who (pid $$)" >&2
208
+ return 0
209
+ fi
210
+ local _h_pid _h_who
211
+ _h_pid="$(cat "$_LI_PIPELINE_LOCK_DIR/pid" 2>/dev/null || echo "")"
212
+ _h_who="$(cat "$_LI_PIPELINE_LOCK_DIR/holder" 2>/dev/null || echo "?")"
213
+ if [ -z "$_h_pid" ] || ! kill -0 "$_h_pid" 2>/dev/null; then
214
+ echo "[$(date +%H:%M:%S)] linkedin-pipeline lock: reclaiming stale lock (dead holder ${_h_who} pid ${_h_pid:-unknown})" >&2
215
+ rm -rf "$_LI_PIPELINE_LOCK_DIR"
216
+ continue
217
+ fi
218
+ echo "[$(date +%H:%M:%S)] linkedin-pipeline lock: held by ${_h_who} (pid ${_h_pid}); ${_who} exiting this fire to avoid two drivers on the 9556 Chrome" >&2
219
+ exit 0
220
+ done
221
+ }
222
+
223
+ _resolve_chrome_bin() {
224
+ # Auto-detect Chrome/Chromium so the same script launches the harness on
225
+ # macOS dev boxes AND Linux VMs. Override with BH_CHROME_BIN.
226
+ if [ -n "${BH_CHROME_BIN:-}" ] && [ -x "$BH_CHROME_BIN" ]; then
227
+ echo "$BH_CHROME_BIN"; return 0
228
+ fi
229
+ for _p in \
230
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
231
+ "/Applications/Chromium.app/Contents/MacOS/Chromium" \
232
+ "/usr/bin/google-chrome" "/usr/bin/google-chrome-stable" \
233
+ "/usr/bin/chromium" "/usr/bin/chromium-browser" "/snap/bin/chromium"
234
+ do
235
+ if [ -x "$_p" ]; then echo "$_p"; return 0; fi
236
+ done
237
+ for _n in google-chrome google-chrome-stable chromium chromium-browser; do
238
+ _which=$(command -v "$_n" 2>/dev/null) && [ -n "$_which" ] && { echo "$_which"; return 0; }
239
+ done
240
+ echo ""; return 1
241
+ }
242
+
243
+ ensure_linkedin_browser_for_backend() {
244
+ # AppMaker / BYO Chrome: LINKEDIN_CDP_URL points at something other than our
245
+ # default harness URL. Don't touch that browser; just probe it and bail.
246
+ if [ "${LINKEDIN_CDP_URL:-$_BH_LINKEDIN_DEFAULT_URL}" != "$_BH_LINKEDIN_DEFAULT_URL" ]; then
247
+ local _ext_url="${LINKEDIN_CDP_URL}"
248
+ if curl -sf --max-time 2 -o /dev/null "${_ext_url}/json/version" 2>/dev/null; then
249
+ echo "[$(date +%H:%M:%S)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
250
+ return 0
251
+ fi
252
+ echo "[$(date +%H:%M:%S)] ERROR: LINKEDIN_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host." >&2
253
+ return 1
254
+ fi
255
+ # Cross-pipeline whole-run lock: only one LinkedIn browser pipeline drives
256
+ # the 9556 harness Chrome at a time. Acquired here (the single chokepoint
257
+ # every browser pipeline calls) so it covers run/engage/dm/audit/stats
258
+ # without editing the locked top-level scripts. Skipped above for
259
+ # externally-managed (AppMaker/BYO) Chrome, which is not ours to serialize.
260
+ _acquire_linkedin_pipeline_lock
261
+ # Probe + launch harness Chrome on port 9556 if needed.
262
+ if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null; then
263
+ echo "[$(date +%H:%M:%S)] LinkedIn harness Chrome down on port 9556, launching..." >&2
264
+ local _chrome_bin
265
+ _chrome_bin=$(_resolve_chrome_bin)
266
+ if [ -z "$_chrome_bin" ]; then
267
+ echo "[$(date +%H:%M:%S)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
268
+ return 1
269
+ fi
270
+ # On Linux + no display, run headless. On root, add --no-sandbox.
271
+ local _extra=()
272
+ case "$(uname -s)" in
273
+ Linux)
274
+ _extra+=(--no-sandbox --disable-dev-shm-usage)
275
+ if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then
276
+ _extra+=(--headless=new --disable-gpu)
277
+ fi
278
+ ;;
279
+ Darwin)
280
+ # Default position captured 2026-05-26 from the user's
281
+ # secondary monitor; overridable via BH_LINKEDIN_WINDOW_POS.
282
+ _extra+=(--window-position="${BH_LINKEDIN_WINDOW_POS:-3814,-1050}")
283
+ _extra+=(--window-size="${BH_LINKEDIN_WINDOW_SIZE:-1024,1013}")
284
+ ;;
285
+ esac
286
+ # Self-heal (2026-06-03): reap any stale Chrome holding THIS profile dir
287
+ # but not answering CDP on our port, else the relaunch hands off via the
288
+ # SingletonLock and loops "failed to start within 12s". Exact-dir match
289
+ # (trailing space) so this never touches the twitter browser-harness
290
+ # profile. See twitter-backend.sh for the regression that motivated this.
291
+ local _prof_dir="$HOME/.claude/browser-profiles/browser-harness-linkedin"
292
+ local _stale_pids
293
+ _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
294
+ if [ -n "$_stale_pids" ] && ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null; then
295
+ echo "[$(date +%H:%M:%S)] CDP down but Chrome still holds $_prof_dir (pids: $(echo $_stale_pids | tr '\n' ' ')); reaping stale profile owner before relaunch" >&2
296
+ kill $_stale_pids 2>/dev/null || true
297
+ sleep 2
298
+ _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
299
+ [ -n "$_stale_pids" ] && { kill -9 $_stale_pids 2>/dev/null || true; sleep 1; }
300
+ rm -f "$_prof_dir/SingletonLock" "$_prof_dir/SingletonSocket" "$_prof_dir/SingletonCookie" 2>/dev/null || true
301
+ fi
302
+ "$_chrome_bin" \
303
+ --remote-debugging-port=9556 \
304
+ --user-data-dir="$HOME/.claude/browser-profiles/browser-harness-linkedin" \
305
+ --no-first-run --no-default-browser-check \
306
+ --disable-features=ChromeWhatsNewUI \
307
+ "${_extra[@]}" \
308
+ about:blank >/dev/null 2>&1 &
309
+ disown
310
+ for _i in 1 2 3 4 5 6 7 8 9 10 11 12; do
311
+ curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null && break
312
+ sleep 1
313
+ done
314
+ if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null; then
315
+ echo "[$(date +%H:%M:%S)] ERROR: LinkedIn harness Chrome failed to start within 12s" >&2
316
+ return 1
317
+ fi
318
+ echo "[$(date +%H:%M:%S)] LinkedIn harness Chrome up on port 9556" >&2
319
+ fi
320
+ # Always close leftover tabs from prior runs. Safe under acquire_lock
321
+ # "linkedin-browser" serialization.
322
+ cleanup_harness_tabs
323
+
324
+ # Per-run logout detection (2026-06-03). Every browser pipeline funnels
325
+ # through here before it touches LinkedIn, so this single call makes ANY
326
+ # pipeline trip the killswitch on its natural next fire if the harness
327
+ # Chrome has been logged out (999 / authwall / checkpoint), without editing
328
+ # the chflags-locked top-level scripts. detect-gate is a no-op when the
329
+ # killswitch is already active, and only ENGAGES on a CONCLUSIVE /feed/
330
+ # redirect to auth (infra hiccups -> proceed, so a flaky render never
331
+ # strands the pipeline). On a confirmed logout it engages the flag (which
332
+ # pauses every pipeline on its next fire + starts the 24h recovery clock)
333
+ # and returns 2, so we abort this fire instead of burning a Claude session
334
+ # on a dead session.
335
+ _linkedin_session_detect_gate
336
+ }
337
+
338
+ # Once-per-process guard mirrors _LI_PIPELINE_LOCK_HELD: run-linkedin.sh calls
339
+ # ensure_linkedin_browser_for_backend in both Phase A and Phase B, and we do not
340
+ # want two /feed/ probes per fire.
341
+ _linkedin_session_detect_gate() {
342
+ if [ "${_LI_SESSION_PROBED:-0}" = "1" ]; then
343
+ return 0
344
+ fi
345
+ export _LI_SESSION_PROBED=1
346
+ local _py="${LINKEDIN_DISCOVER_PYTHON:-python3}"
347
+ # `|| _rc=$?` so a nonzero exit (e.g. 2 = logged out) is "handled" and does
348
+ # not trip a caller's `set -e` before we inspect the code ourselves.
349
+ local _rc=0
350
+ "$_py" "$HOME/social-autoposter/scripts/linkedin_killswitch.py" detect-gate \
351
+ --cdp-url "${LINKEDIN_CDP_URL:-$_BH_LINKEDIN_DEFAULT_URL}" >&2 || _rc=$?
352
+ if [ "$_rc" = "2" ]; then
353
+ echo "[$(date +%H:%M:%S)] detect-gate tripped the LinkedIn killswitch; aborting this fire" >&2
354
+ return 1
355
+ fi
356
+ return 0
357
+ }
358
+
359
+ defer_if_foreign_for_backend() {
360
+ # Harness Chrome accepts multiple concurrent CDP clients on the same
361
+ # browser-harness-linkedin profile, so a foreign MCP wrapper cannot cause
362
+ # SingletonLock contention. Always return 1 (do not defer).
363
+ return 1
364
+ }
@@ -0,0 +1,48 @@
1
+ #!/bin/bash
2
+ # Portable platform detection for social-autoposter shell scripts.
3
+ # Source this file, then use: $PLATFORM, stat_mtime <path>, platform_notify <title> <msg>.
4
+
5
+ if [ -z "${PLATFORM:-}" ]; then
6
+ case "$(uname -s)" in
7
+ Darwin) PLATFORM=darwin ;;
8
+ Linux) PLATFORM=linux ;;
9
+ *) PLATFORM=unknown ;;
10
+ esac
11
+ fi
12
+
13
+ stat_mtime() {
14
+ local f="$1"
15
+ case "$PLATFORM" in
16
+ darwin) stat -f %m "$f" 2>/dev/null || echo 0 ;;
17
+ linux) stat -c %Y "$f" 2>/dev/null || echo 0 ;;
18
+ *) echo 0 ;;
19
+ esac
20
+ }
21
+
22
+ platform_notify() {
23
+ local title="$1"
24
+ local msg="$2"
25
+ case "$PLATFORM" in
26
+ darwin)
27
+ osascript -e "display notification \"$msg\" with title \"$title\" sound name \"Glass\"" 2>/dev/null || true
28
+ ;;
29
+ linux)
30
+ if command -v notify-send >/dev/null 2>&1; then
31
+ notify-send "$title" "$msg" 2>/dev/null || true
32
+ fi
33
+ ;;
34
+ esac
35
+ }
36
+
37
+ # Portable `gtimeout`: on macOS with Homebrew coreutils the binary is named
38
+ # gtimeout; on Linux GNU coreutils ships it as `timeout`. Define a function
39
+ # so every script can call `gtimeout <secs> <cmd>` regardless of platform.
40
+ if ! command -v gtimeout >/dev/null 2>&1; then
41
+ if command -v timeout >/dev/null 2>&1; then
42
+ gtimeout() { timeout "$@"; }
43
+ else
44
+ gtimeout() { "$@"; } # last-resort no-op wrapper
45
+ fi
46
+ fi
47
+
48
+ export PLATFORM
@@ -0,0 +1,234 @@
1
+ #!/bin/bash
2
+ # reddit-backend.sh - Reddit pipeline browser bootstrap (reddit-harness,
3
+ # mirrors twitter-backend.sh / linkedin-backend.sh).
4
+ #
5
+ # 2026-05-29 migration: Reddit's discovery path (reddit_tools.py) fetched
6
+ # Reddit's *.json via Python urllib, which Reddit began 403ing from residential
7
+ # IPs on 2026-05-28 (TLS-fingerprint + no-cookies block). Fetching the same
8
+ # JSON from inside a logged-in real-Chrome page returns 200. So the entire
9
+ # Reddit pipeline (discovery + posting) now rides a dedicated browser-harness
10
+ # Chrome on port 9557, profile ~/.claude/browser-profiles/reddit-harness
11
+ # (seeded from the existing logged-in ~/.claude/browser-profiles/reddit).
12
+ #
13
+ # Source this AFTER lock.sh, BEFORE any acquire_lock / browser pre-flight /
14
+ # claude -p subprocess calls. Sets these for the caller:
15
+ #
16
+ # MCP_CONFIG_FILE - claude -p --mcp-config path (reddit-harness MCP)
17
+ # BROWSER_INSTRUCTIONS - prompt block describing the harness backend +
18
+ # its bh_run tool surface (inject at the TOP of any
19
+ # prompt that mentions browser_* tools)
20
+ #
21
+ # And exports (so Python subprocesses like reddit_browser.py / reddit_tools.py
22
+ # inherit them):
23
+ #
24
+ # REDDIT_CDP_URL - http://127.0.0.1:9557 (forces direct CDP attach,
25
+ # skipping ps-based agent-profile discovery; also
26
+ # tells reddit_tools.py to fetch JSON via the browser)
27
+ #
28
+ # Provides these functions (names mirror twitter/linkedin-backend for the
29
+ # existing call shape in run-reddit-search.sh, run-reddit-threads.sh,
30
+ # engage-reddit.sh, dm-outreach-reddit.sh, link-edit-reddit.sh, etc.):
31
+ #
32
+ # ensure_reddit_browser_for_backend
33
+ # 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.
36
+ #
37
+ # defer_if_foreign_for_backend [log_file]
38
+ # No-op. Harness CDP supports multiple concurrent clients on the same
39
+ # Chrome (no SingletonLock fight), so foreign MCP wrappers never block us.
40
+
41
+ MCP_CONFIG_FILE="$HOME/.claude/browser-agent-configs/reddit-harness-mcp.json"
42
+
43
+ # Per-host env override (written by bin/cli.js when installing on an AppMaker
44
+ # VM). On a Mac dev box this file does not exist, so the default below kicks in.
45
+ if [ -f "$HOME/.social-autoposter-env" ]; then
46
+ # shellcheck disable=SC1091
47
+ . "$HOME/.social-autoposter-env"
48
+ fi
49
+
50
+ # Tell reddit_browser.py + reddit_tools.py (and any other Python helper that
51
+ # honors this env var) to skip ps-based discovery and connect directly to the
52
+ # configured CDP endpoint. Default 9557 (Mac harness Chrome, separate port from
53
+ # Twitter's 9555 and LinkedIn's 9556).
54
+ export REDDIT_CDP_URL="${REDDIT_CDP_URL:-http://127.0.0.1:9557}"
55
+
56
+ # Default harness URL - used by ensure_reddit_browser_for_backend +
57
+ # cleanup_harness_tabs to decide whether we own this Chrome (and should
58
+ # launch/clean it) or whether it is externally managed (AppMaker, BYO).
59
+ _BH_REDDIT_DEFAULT_URL="http://127.0.0.1:9557"
60
+
61
+ BROWSER_INSTRUCTIONS=$(cat <<'BROWSER_HARNESS_EOF'
62
+ BROWSER BACKEND: reddit-harness (browser-harness MCP, CDP-driven REAL Google Chrome on
63
+ port 9557, profile ~/.claude/browser-profiles/reddit-harness). The Chrome is already
64
+ logged in to Reddit; cookies persist on disk.
65
+
66
+ You have ONE tool: mcp__reddit-harness__bh_run(script). It runs arbitrary Python with
67
+ these helpers pre-imported:
68
+ new_tab(url), goto_url(url), wait_for_load(), page_info(),
69
+ capture_screenshot(), # returns path to PNG; Read it to see the page
70
+ click_at_xy(x, y), # coordinate click (viewport pixels)
71
+ js(expression), # page.evaluate-style; returns the result
72
+ type_text(text), # types into currently-focused element
73
+ press_key(key), # e.g. "Enter", "Tab", "Escape"
74
+ scroll(direction, amount), cdp(method, **params)
75
+
76
+ TAB HYGIENE (IMPORTANT): Reuse the SAME tab for sequential same-domain navigation.
77
+ Use new_tab() ONLY for the very first navigation OR when you need to keep an old tab
78
+ open in parallel. For each subsequent query / page / scan, use goto_url() so the
79
+ existing tab is reused. Opening a fresh tab for every query leaks tabs over time and
80
+ exhausts per-process Chrome resources.
81
+
82
+ REDDIT JSON FETCH (the whole point of this backend): Reddit 403s urllib/curl on
83
+ *.json from this IP, but same-origin fetch() from inside a logged-in reddit.com page
84
+ returns 200. To read any Reddit JSON endpoint:
85
+ bh_run('''
86
+ goto_url("https://www.reddit.com/")
87
+ wait_for_load()
88
+ body = js("""
89
+ return (async () => {
90
+ const r = await fetch("https://www.reddit.com/search.json?q=...&limit=25",
91
+ {credentials:"include", headers:{"Accept":"application/json"}});
92
+ return JSON.stringify({status:r.status, body: await r.text()});
93
+ })();
94
+ """)
95
+ print(body)
96
+ ''')
97
+
98
+ TRANSLATION TABLE - wherever this prompt mentions a Playwright-style tool, do the
99
+ following with bh_run instead:
100
+
101
+ browser_navigate(url) -> First navigation: bh_run('new_tab("URL"); wait_for_load()')
102
+ Subsequent navigations (same session): bh_run('goto_url("URL"); wait_for_load()')
103
+ browser_snapshot -> bh_run('print(js("""..."""))') to read DOM as structured data,
104
+ OR bh_run('print(capture_screenshot())') + Read the PNG
105
+ browser_run_code(js) -> bh_run('print(js("""<the JS expression>"""))')
106
+ browser_click(ref=...) -> Find the element via selector, compute center coords from
107
+ getBoundingClientRect, then bh_run('click_at_xy(X, Y)')
108
+ browser_type(ref=..., text=...) -> Click the textbox first (click_at_xy), then bh_run('type_text("TEXT")')
109
+ browser_take_screenshot -> bh_run('print(capture_screenshot())') then Read the path
110
+ browser_press_key("Enter") -> bh_run('press_key("Enter")')
111
+
112
+ VERIFY AFTER EVERY MUTATION by capturing a screenshot and reading the PNG, coordinate
113
+ clicks can miss; visual verification is the only reliable confirmation that the action took.
114
+ BROWSER_HARNESS_EOF
115
+ )
116
+
117
+ cleanup_harness_tabs() {
118
+ # Close every CDP "page" tab except one. Same pattern as twitter/linkedin
119
+ # backend, scoped to the Reddit harness Chrome on port 9557.
120
+ #
121
+ # Health-check gate: 10s timeout + ONE retry; log skips so they are not silent.
122
+ local _probe="curl -sf --max-time 10 -o /dev/null http://127.0.0.1:9557/json/version"
123
+ if ! $_probe 2>/dev/null; then
124
+ sleep 1
125
+ if ! $_probe 2>/dev/null; then
126
+ echo "[$(date +%H:%M:%S)] cleanup_harness_tabs: SKIPPED (reddit-harness CDP /json/version unreachable after 10s+retry)" >&2
127
+ return 0
128
+ fi
129
+ fi
130
+ BH_CLEANUP_PORT=9557 python3 "$HOME/social-autoposter/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
131
+ }
132
+
133
+ _resolve_chrome_bin() {
134
+ # Auto-detect Chrome/Chromium so the same script launches the harness on
135
+ # macOS dev boxes AND Linux VMs. Override with BH_CHROME_BIN.
136
+ if [ -n "${BH_CHROME_BIN:-}" ] && [ -x "$BH_CHROME_BIN" ]; then
137
+ echo "$BH_CHROME_BIN"; return 0
138
+ fi
139
+ for _p in \
140
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
141
+ "/Applications/Chromium.app/Contents/MacOS/Chromium" \
142
+ "/usr/bin/google-chrome" "/usr/bin/google-chrome-stable" \
143
+ "/usr/bin/chromium" "/usr/bin/chromium-browser" "/snap/bin/chromium"
144
+ do
145
+ if [ -x "$_p" ]; then echo "$_p"; return 0; fi
146
+ done
147
+ for _n in google-chrome google-chrome-stable chromium chromium-browser; do
148
+ _which=$(command -v "$_n" 2>/dev/null) && [ -n "$_which" ] && { echo "$_which"; return 0; }
149
+ done
150
+ echo ""; return 1
151
+ }
152
+
153
+ ensure_reddit_browser_for_backend() {
154
+ # AppMaker / BYO Chrome: REDDIT_CDP_URL points at something other than our
155
+ # default harness URL. Don't touch that browser; just probe it and bail.
156
+ if [ "${REDDIT_CDP_URL:-$_BH_REDDIT_DEFAULT_URL}" != "$_BH_REDDIT_DEFAULT_URL" ]; then
157
+ local _ext_url="${REDDIT_CDP_URL}"
158
+ if curl -sf --max-time 2 -o /dev/null "${_ext_url}/json/version" 2>/dev/null; then
159
+ echo "[$(date +%H:%M:%S)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
160
+ return 0
161
+ fi
162
+ echo "[$(date +%H:%M:%S)] ERROR: REDDIT_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host." >&2
163
+ return 1
164
+ fi
165
+ # Probe + launch harness Chrome on port 9557 if needed.
166
+ if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null; then
167
+ echo "[$(date +%H:%M:%S)] Reddit harness Chrome down on port 9557, launching..." >&2
168
+ local _chrome_bin
169
+ _chrome_bin=$(_resolve_chrome_bin)
170
+ if [ -z "$_chrome_bin" ]; then
171
+ echo "[$(date +%H:%M:%S)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
172
+ return 1
173
+ fi
174
+ # On Linux + no display, run headless. On root, add --no-sandbox.
175
+ local _extra=()
176
+ case "$(uname -s)" in
177
+ Linux)
178
+ _extra+=(--no-sandbox --disable-dev-shm-usage)
179
+ if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then
180
+ _extra+=(--headless=new --disable-gpu)
181
+ fi
182
+ ;;
183
+ Darwin)
184
+ # Default position = the Reddit browser's current off-screen
185
+ # spot (captured 2026-05-29); overridable via BH_REDDIT_WINDOW_POS.
186
+ _extra+=(--window-position="${BH_REDDIT_WINDOW_POS:-2131,-1032}")
187
+ _extra+=(--window-size="${BH_REDDIT_WINDOW_SIZE:-911,1016}")
188
+ ;;
189
+ esac
190
+ # Self-heal (2026-06-03): reap any stale Chrome holding THIS profile dir
191
+ # but not answering CDP on our port, else the relaunch hands off via the
192
+ # SingletonLock and loops "failed to start within 12s". Exact-dir match
193
+ # (trailing space) keeps this scoped to reddit-harness only. See
194
+ # twitter-backend.sh for the regression that motivated this.
195
+ local _prof_dir="$HOME/.claude/browser-profiles/reddit-harness"
196
+ local _stale_pids
197
+ _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
198
+ if [ -n "$_stale_pids" ] && ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null; then
199
+ echo "[$(date +%H:%M:%S)] CDP down but Chrome still holds $_prof_dir (pids: $(echo $_stale_pids | tr '\n' ' ')); reaping stale profile owner before relaunch" >&2
200
+ kill $_stale_pids 2>/dev/null || true
201
+ sleep 2
202
+ _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
203
+ [ -n "$_stale_pids" ] && { kill -9 $_stale_pids 2>/dev/null || true; sleep 1; }
204
+ rm -f "$_prof_dir/SingletonLock" "$_prof_dir/SingletonSocket" "$_prof_dir/SingletonCookie" 2>/dev/null || true
205
+ fi
206
+ "$_chrome_bin" \
207
+ --remote-debugging-port=9557 \
208
+ --user-data-dir="$HOME/.claude/browser-profiles/reddit-harness" \
209
+ --no-first-run --no-default-browser-check \
210
+ --disable-features=ChromeWhatsNewUI \
211
+ "${_extra[@]}" \
212
+ about:blank >/dev/null 2>&1 &
213
+ disown
214
+ for _i in 1 2 3 4 5 6 7 8 9 10 11 12; do
215
+ curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null && break
216
+ sleep 1
217
+ done
218
+ if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null; then
219
+ echo "[$(date +%H:%M:%S)] ERROR: Reddit harness Chrome failed to start within 12s" >&2
220
+ return 1
221
+ fi
222
+ echo "[$(date +%H:%M:%S)] Reddit harness Chrome up on port 9557" >&2
223
+ fi
224
+ # Always close leftover tabs from prior runs. Safe under acquire_lock
225
+ # "reddit-browser" serialization.
226
+ cleanup_harness_tabs
227
+ }
228
+
229
+ defer_if_foreign_for_backend() {
230
+ # Harness Chrome accepts multiple concurrent CDP clients on the same
231
+ # reddit-harness profile, so a foreign MCP wrapper cannot cause
232
+ # SingletonLock contention. Always return 1 (do not defer).
233
+ return 1
234
+ }