@m13v/s4l 1.6.197-rc.10

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 +1336 -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 +513 -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,201 @@
1
+ #!/usr/bin/env bash
2
+ # link-edit-reddit.sh — Edit high-performing Reddit comments to append a project link.
3
+ # Splits out from the legacy engage.sh Phase D so a single platform failure
4
+ # (e.g. LinkedIn hang) no longer blocks Reddit.
5
+ # Called by launchd (com.m13v.social-link-edit-reddit) every 6 hours.
6
+
7
+ set -euo pipefail
8
+
9
+ # Cycle ID for cross-cycle cost accounting (see run-reddit-search.sh / engage-reddit.sh
10
+ # for the same pattern). Stamps claude_sessions.cycle_id via env inheritance.
11
+ BATCH_ID="${BATCH_ID:-lerd-$(date +%Y%m%d-%H%M%S)}"
12
+ export BATCH_ID
13
+ export SA_CYCLE_ID="$BATCH_ID"
14
+
15
+ # Pipeline lock at top. We DO NOT acquire reddit-browser at the bash level
16
+ # anymore — claude itself acquires it per-post via
17
+ # scripts/reddit_browser_lock.py, only around the actual MCP browser
18
+ # operations (~15-60s per post). This unblocks peer reddit pipelines
19
+ # (engage-reddit, dm-replies-reddit, post-reddit) during the long
20
+ # generate_page.py / WebFetch / DB phases of each post.
21
+ source "$(dirname "$0")/lock.sh"
22
+ # reddit-harness backend (2026-05-29). Sets MCP_CONFIG_FILE, BROWSER_INSTRUCTIONS,
23
+ # exports REDDIT_CDP_URL=:9557, provides ensure_reddit_browser_for_backend.
24
+ source "$(dirname "$0")/lib/reddit-backend.sh"
25
+ acquire_lock "link-edit-reddit" 5400
26
+
27
+ # Load secrets
28
+ # shellcheck source=/dev/null
29
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
30
+
31
+ REPO_DIR="$HOME/social-autoposter"
32
+ SKILL_FILE="$REPO_DIR/SKILL.md"
33
+ LOG_DIR="$REPO_DIR/skill/logs"
34
+ # HTTP-only lane (2026-06-01): all reads/writes go through the s4l.ai API via
35
+ # scripts/link_edit_helper.py. No DATABASE_URL, no psql, no fallback.
36
+ LE_HELPER="$REPO_DIR/scripts/link_edit_helper.py"
37
+
38
+ mkdir -p "$LOG_DIR"
39
+ LOG_FILE="$LOG_DIR/link-edit-reddit-$(date +%Y-%m-%d_%H%M%S).log"
40
+
41
+ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
42
+
43
+ RUN_START=$(date +%s)
44
+ log "=== Reddit Link Edit Run: $(date) ==="
45
+
46
+ # A/B gate: per-post deterministic coin flip for the page-gen lane. Mirrors
47
+ # scripts/twitter_gen_links.py's TWITTER_PAGE_GEN_RATE behavior. 0.30 means
48
+ # ~30% of eligible posts hit the full seo/generate_page.py pipeline; the
49
+ # other ~70% fall through to the project's homepage with
50
+ # link_source='plain_url_ab_skip'. Per-post hash via Postgres hashtext() so
51
+ # the same post stays in the same lane across cron retries — without that
52
+ # we'd risk shipping two different lanes for the same post on consecutive
53
+ # runs and burn budget. Tunable via env var so cadence sweeps don't need
54
+ # code changes. 0.0 disables page-gen; 1.0 restores 100% page-gen.
55
+ # DEFAULT 0.0: Reddit no longer generates custom SEO pages — every eligible
56
+ # post goes through the wrap-an-existing-link route (homepage + /r/<code>).
57
+ LINK_EDIT_REDDIT_PAGE_GEN_RATE="${LINK_EDIT_REDDIT_PAGE_GEN_RATE:-0.0}"
58
+ PAGE_GEN_RATE_PCT=$(python3 -c "v=float('$LINK_EDIT_REDDIT_PAGE_GEN_RATE'); v=max(0.0,min(1.0,v)); print(int(round(v*100)))")
59
+ log "A/B gate: LINK_EDIT_REDDIT_PAGE_GEN_RATE=$LINK_EDIT_REDDIT_PAGE_GEN_RATE (page_gen_lane='page_gen' on ~${PAGE_GEN_RATE_PCT}% of eligible posts; rest go to plain_url_ab_skip)"
60
+
61
+ EDITABLE=$(python3 "$LE_HELPER" eligible --platform reddit --min-upvotes-exclusive 1 --page-gen-rate-pct "$PAGE_GEN_RATE_PCT" --order upvotes 2>/dev/null || echo "")
62
+
63
+ if [ "$EDITABLE" = "null" ] || [ -z "$EDITABLE" ]; then
64
+ log "No Reddit posts eligible for link edit"
65
+ python3 "$REPO_DIR/scripts/log_run.py" --script "link_edit_reddit" --posted 0 --skipped 0 --failed 0 --cost 0 --elapsed $(( $(date +%s) - RUN_START ))
66
+ exit 0
67
+ fi
68
+
69
+ EDITABLE_COUNT=$(echo "$EDITABLE" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "?")
70
+ log "Reddit: $EDITABLE_COUNT posts eligible for link edit"
71
+
72
+ PROMPT_FILE=$(mktemp)
73
+ cat > "$PROMPT_FILE" <<PROMPT_EOF
74
+ You are the Social Autoposter Reddit link-edit bot.
75
+
76
+ Read $SKILL_FILE for the full workflow. Execute the Reddit link-edit phase only.
77
+
78
+ $BROWSER_INSTRUCTIONS
79
+
80
+ CRITICAL - Browser agent rule: ONLY use the browser tool described in the BROWSER BACKEND block above (mcp__reddit-harness__bh_run). NEVER use generic mcp__playwright-extension__*, mcp__isolated-browser__*, mcp__reddit-agent__*, or mcp__macos-use__* tools. If a bh_run call is blocked or times out, wait 30s and retry (up to 3 times). If still blocked, skip that post.
81
+
82
+ CRITICAL: This is a single-shot run. NEVER call ScheduleWakeup, CronCreate, CronDelete, CronList, EnterPlanMode, EnterWorktree, or any deferred-execution / scheduling tool. You MUST complete or skip every post in this one run; do not defer work to "a future run". If you hit a hard block, mark the post SKIPPED via step 9 and move on to the next post.
83
+
84
+ EXECUTION MODEL — STRICT SEQUENTIAL, NO BATCHING (read this twice):
85
+ - Process posts ONE AT A TIME. Run the FULL chain (steps 1 → 8) end-to-end for post N before reading post N+1.
86
+ - NEVER batch generate_page.py calls in parallel. NEVER spawn page-gen subprocesses with double-fork (\`nohup ... &\`, \`disown\`, \`setsid\`, \`os.fork\`). NEVER write a polling loop that watches /tmp/seo_*.json. Each generate_page.py is a foreground call that you wait for synchronously.
87
+ - Step 8's DB UPDATE for post N MUST complete before you start step 1 for post N+1. This protects interim work: if the run is killed mid-stream, the first N-1 posts are fully shipped and only ONE post is in flight at most.
88
+ - The reddit-browser lock is NOT held by the parent shell. You acquire/release it explicitly per post (steps 6.5 and 7.5), so peer reddit pipelines can use the browser during your generate_page.py / WebFetch / DB phases.
89
+
90
+ Reddit posts eligible for editing:
91
+ $EDITABLE
92
+
93
+ Process ALL of them SEQUENTIALLY (one at a time, full chain per post). For each post:
94
+ 1. Read ~/social-autoposter/config.json to get the projects list.
95
+ 2. Pick the project whose topics are the CLOSEST match to thread_title + our_content.
96
+ a. First check the project_name column. If it is set AND its topics/description fit the thread, use it.
97
+ b. If project_name is set but CLEARLY does not fit the thread (e.g. Cyrano tagged to a law firm billing thread), treat it as a bad upstream tag and scan config.json for a project that DOES fit. If you find one, use that project instead and also run: python3 ~/social-autoposter/scripts/link_edit_helper.py set-project --post-id POST_ID --project "BETTER_PROJECT" so the correction is persisted.
98
+ c. If project_name is NOT set, match by topics. Be generous: if the thread touches agents, automation, desktop, memory, or anything related to the project descriptions, it's a match.
99
+ d. ONLY if no project in config.json fits at all, mark it skipped (see step 9) and move on. Frame it as recommending a cool tool you've come across, NOT as something you built.
100
+ 3. PAGE-GEN LANE GATE — read the post's \`page_gen_lane\` field (set deterministically by the pipeline; do NOT override).
101
+ - If \`page_gen_lane == "ab_skip"\`: SKIP the full SEO page generation entirely. Set LINK_URL = the matched project's homepage from config.json (the \`website\` field) and LINK_SOURCE="plain_url_ab_skip". Continue to step 4. The /r/<code> short-link wrap in step 5 still mints attribution on the project's own domain, so we get click data for this lane to compare against seo_page lane CTR.
102
+ - If \`page_gen_lane == "page_gen"\` AND the matched project has a landing_pages config: continue to step 3a below.
103
+ - If \`page_gen_lane == "page_gen"\` BUT the matched project has NO landing_pages config: skip page-gen, set LINK_URL = project homepage, LINK_SOURCE="plain_url_no_lp", continue to step 4.
104
+
105
+ 3a. If the matched project has a landing_pages config (with repo, base_url), generate a fresh SEO page for this thread by delegating to the unified generator:
106
+ a. Decide a SHORT keyword phrase (3-6 words) that captures what page would help this thread's audience. Think SEO intent, not headline copy. Examples: "local ai agent", "macos accessibility automation", "self hosted llm inference".
107
+ b. Derive a URL slug from the keyword: lowercase, kebab-case, alphanumeric and hyphens only, max 50 chars. Examples: "local-ai-agent", "macos-accessibility-automation".
108
+ c. Run the unified SEO page generator (it loads the @m13v/seo-components palette, picks content type, builds the page, commits, pushes, verifies the live URL, and writes the seo_keywords row that surfaces in the dashboard activity feed). The generator's prompt has its own model-driven Step 0 reuse-or-redirect decision: if an existing page on the site already serves this keyword's intent, the generator will consolidate (308 redirect this slug to the existing page) instead of building a duplicate. Trust that decision; do not pre-filter for reuse here. Use the Bash tool:
109
+ python3 ~/social-autoposter/seo/generate_page.py --product PROJECT_NAME --keyword "KEYWORD_PHRASE" --slug "url-slug" --trigger reddit
110
+ This call can take 10-40 minutes per page (Cloud Run staging-then-tag deploys on mk0r are the slow end). The final stdout is a JSON object; parse it. On success it contains "success": true and "page_url": "https://...". On failure it contains "success": false and "error": "...".
111
+ d. If success, set LINK_URL = the \`page_url\` from the JSON output and LINK_SOURCE="seo_page".
112
+ e. If failure (success: false in the JSON), fall back GRACEFULLY (mirrors the Twitter pipeline behavior in scripts/twitter_gen_links.py): set LINK_URL = the project's homepage from config.json (the \`website\` field for the matched project) and set LINK_SOURCE="plain_url_fallback:<reason>" where <reason> is a SHORT snake_case tag derived from the JSON error string (preferred values: timeout, no_page_url, deploy_failed, build_failed, push_failed; otherwise pick a sensible 1-3 word snake_case summary). Do NOT skip the post; continue to step 4. The short-link wrap in step 5 will still mint a /r/<code> on the project's own domain, so click attribution works on the homepage URL too.
113
+ 4. Write the link sentence as a CONTEXTUAL BRIDGE, not a citation or footnote. This is a structured 4-step sub-task; do NOT shortcut it.
114
+ a. Re-read our_content (the comment we already left on this Reddit thread). Identify the SINGLE strongest claim, mechanism, or specific number we said in that comment (examples: "auto-rephrasing on revisit", "the 81 number on the rubric", "scoring distractor quality", "200ms p95 latency", "structured output across nested tool calls"). Pick ONE concrete thing, not a category.
115
+ b. Read the landing page at LINK_URL (use WebFetch on LINK_URL). Identify the SPECIFIC mechanism on the page that delivers the thing from step 4a (one feature, one capability, named in the page's own language).
116
+ c. Write 1-2 sentences (max ~35 words) that explicitly bridge: comment-claim → product mechanism → URL. The sentence MUST name at least ONE concrete capability inline (verb + noun, e.g. "scores against a rubric and rephrases the same fact on revisit"); NOT a category ("a tool for studying", "something for this"). The URL is the last token; nothing after it.
117
+ d. Voice depends on the matched project's \`voice_relationship\` field in config.json (read it before drafting):
118
+ - voice_relationship == "first_party": Frame as our own creation. Bridge form: "fwiw the [specific-thing-from-4a] is from [PROJECT_NAME], a thing I built that [specific-mechanism-from-4b], LINK_URL". Concrete first_party examples (do NOT copy verbatim; rewrite to match each post's actual claim):
119
+ * "fwiw the 81 number is from a rubric we trained on med-school decks, scores factual correctness, distractor quality, and rephrases on revisit so you can't pattern-match the stem, https://studyly.io/t/anki-card-generator-for-medical-school"
120
+ * "we shipped this for the rephrase-on-revisit problem specifically, it scores against a 4-axis rubric and re-asks the same fact in different stems, https://studyly.io/t/active-recall-question-generator"
121
+ - voice_relationship == "third_party": for accounts that are not the project's maker. Cite the project's mechanism directly. Bridge form: "fwiw [PROJECT_NAME] handles the [specific-thing-from-4a] via [specific-mechanism-from-4b], LINK_URL". Do NOT use "I built" / "we shipped" / "we made", and do NOT use "found this" / "came across this". Stay matter-of-fact and concrete.
122
+ e. BANNED phrasing (rejects the bridge structure, costs CTR):
123
+ - Bare citation with no mechanism: "fwiw the X number is from PROJECT, URL" with no named mechanism.
124
+ - Generic verbs with no object: "a tool for exactly this", "does this" (no concrete mechanism named).
125
+ - "Click here", "check it out", "give it a try" (Reddit downvotes pitchy CTAs).
126
+ - For voice_relationship=third_party only: "I built" / "we shipped" / "we made" (ownership claims on a project you don't own).
127
+ - For voice_relationship=first_party only: "I found this", "there's a tool", "came across this", "saw this manual", "found this guide" (pretends we're a neutral commenter pointing at someone else's project).
128
+ f. SELF-REVIEW before posting: read your sentence aloud. Does it (1) reference the comment's specific claim, (2) name a concrete product mechanism, and (3) end on the URL? If any of the three is missing, rewrite. If after one rewrite it still doesn't pass all three checks, write a fresh sentence from scratch.
129
+ 5. URL-WRAP THE LINK TEXT for click attribution. This MUST run for every LINK_SOURCE (seo_page, plain_url_fallback:*, plain_url_no_lp). The wrap helper accepts homepage URLs and mints a /r/<code> on the project's own domain. Run:
130
+ python3 ~/social-autoposter/scripts/dm_short_links.py wrap-post-text \\
131
+ --text "YOUR_LINK_SENTENCE_WITH_URL" \\
132
+ --platform reddit \\
133
+ --project PROJECT_NAME
134
+ PROJECT_NAME must be the EXACT \`name\` field from config.json (case-sensitive; e.g. "fazm" lowercase, "Cyrano", "WhatsApp MCP"). Parse the JSON output. Use \`text\` (URL replaced with /r/<code>) as the FINAL LINK_TEXT for steps 6 and 7. Keep \`minted_session\` for step 8. If wrap returns ok=false, log the error and skip this post (do NOT post a raw URL).
135
+ 6. Append the wrapped LINK_TEXT to our_content with a blank line separator.
136
+ 6.5. ACQUIRE the reddit-browser lock NOW (just before any reddit-agent browser call). This is the ONLY moment you may touch the browser:
137
+ LOCK_OUT=\$(python3 ~/social-autoposter/scripts/reddit_browser_lock.py acquire --timeout 600 2>&1)
138
+ - If stdout starts with "OK", proceed to step 7.
139
+ - If "BUSY", a peer reddit pipeline owns the browser and didn't release within 10 min. Treat as a TRANSIENT skip (step 10B): leave link_edited_at NULL, log the reason, move on to the NEXT post. Do NOT call step 7 without the lock — collisions on the same chrome profile crash both runs.
140
+ - If "ERROR", same handling as BUSY: TRANSIENT skip, leave link_edited_at NULL, move on.
141
+ 7. Navigate to old.reddit.com comment permalink via the reddit-agent browser. Click "edit", append the wrapped link text to the existing content, save, verify.
142
+ 7.5. RELEASE the reddit-browser lock IMMEDIATELY after the edit confirms or fails. This is mandatory — failing to release it blocks every other reddit pipeline:
143
+ python3 ~/social-autoposter/scripts/reddit_browser_lock.py release
144
+ Run this even if step 7 raised, errored, or you're skipping the post. Wrap step 7 in a way that step 7.5 ALWAYS executes (mental try/finally). The release is idempotent and safe to call multiple times.
145
+ 8. After each successful edit, update the DB (via the HTTP API helper; pass link_source so we can A/B compare seo_page vs plain_url_fallback:* vs plain_url_no_lp click-through rates, same as Twitter does in scripts/twitter_gen_links.py) and backfill short-link attribution. THIS MUST RUN BEFORE YOU START THE NEXT POST:
146
+ python3 ~/social-autoposter/scripts/link_edit_helper.py mark-edited --post-id POST_ID --content "LINK_TEXT" --source "LINK_SOURCE"
147
+ python3 ~/social-autoposter/scripts/dm_short_links.py backfill-post --minted-session MINTED_SESSION --post-id POST_ID
148
+ 9. COMMITMENT GUARDRAILS (never violate these):
149
+ - NEVER suggest, offer, or agree to calls, meetings, demos, or video chats.
150
+ - NEVER promise to share links, files, or resources you don't have right now. Only share links from config.json projects (plus any new landing page you just deployed).
151
+ - NEVER offer to DM or send anything outside the comment.
152
+ - NEVER make time-bound promises.
153
+ 10. SKIP HANDLING — two classes:
154
+ A. PERMANENT skips (no project match, comment not found, removed by moderation, bad URL, post deleted, project has no landing_pages and no website at all): mark so it won't be retried.
155
+ python3 ~/social-autoposter/scripts/link_edit_helper.py mark-skipped --post-id POST_ID --reason "REASON"
156
+ B. TRANSIENT skips (single_run_capacity_exceeded, batch budget exhausted, you ran out of time, reddit-agent locked by peer, anything that would resolve on retry): DO NOT stamp link_edited_at. Leave both link_edited_at and link_edit_content NULL so the next 6h cron picks it up again.
157
+ Only annotate the reason in a comment / log line; never write to the DB for transient skips.
158
+ If unsure which class a skip falls into, treat it as TRANSIENT (default to retry, not to swallow). Stamping link_edited_at is permanent — once set, the post is excluded from future eligibility queries forever.
159
+ PROMPT_EOF
160
+
161
+ # NOTE: We do NOT acquire reddit-browser at the bash level. Claude itself
162
+ # acquires/releases it per post via scripts/reddit_browser_lock.py
163
+ # (steps 6.5 and 7.5 in the prompt). This keeps the lock held only during
164
+ # the actual ~15-60s reddit-agent browser ops per post, not the full
165
+ # 90-min run. Peer pipelines (engage-reddit, dm-replies-reddit,
166
+ # post-reddit) can use the profile during our generate_page.py / WebFetch
167
+ # / DB phases.
168
+ #
169
+ # Pre-flight: ensure the profile isn't wedged by a prior crashed run.
170
+ # Unified lock (2026-05-10): brief Python acquire+release so the orphan-Chrome
171
+ # sweep happens once before claude starts. Python acquire honors expires_at,
172
+ # so a TTL-stale-but-PID-alive holder gets reclaimed automatically instead of
173
+ # blocking us for the full bash timeout.
174
+ log "Pre-flight: sweep orphan reddit-agent Chrome / playwright-mcp before handing off to claude..."
175
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 60 --ttl 30 2>&1 | tee -a "$LOG_FILE" || \
176
+ log "WARNING: reddit_browser_lock.py pre-flight acquire failed; proceeding (claude will retry per-post)."
177
+ if ! ensure_reddit_browser_for_backend 2>&1 | tee -a "$LOG_FILE"; then
178
+ log "WARNING: reddit-harness bootstrap failed; falling back to ensure_browser_healthy reddit"
179
+ ensure_browser_healthy "reddit"
180
+ fi
181
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
182
+
183
+ gtimeout 5400 "$REPO_DIR/scripts/run_claude.sh" "link-edit-reddit" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" --disallowed-tools "ScheduleWakeup,CronCreate,CronDelete,CronList,EnterPlanMode,EnterWorktree" --output-format stream-json --verbose -p "$(cat "$PROMPT_FILE")" 2>&1 | tee -a "$LOG_FILE" || log "WARNING: Reddit link-edit claude exited with code $?"
184
+ rm -f "$PROMPT_FILE"
185
+
186
+ # Belt-and-suspenders: if claude exited without releasing the lock (e.g.
187
+ # crashed mid-edit before reaching step 7.5), free it now so peer
188
+ # pipelines aren't stuck behind a phantom holder. release_lock checks
189
+ # the lock_dir and rm-rf's it; safe even if claude already released.
190
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
191
+
192
+ EDITED=$(python3 "$LE_HELPER" edited-count --platform reddit 2>/dev/null || echo "0")
193
+ log "Reddit link-edit complete. Total reddit posts edited (all-time): $EDITED"
194
+
195
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
196
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START" --scripts "link-edit-reddit" 2>/dev/null || echo "0.0000")
197
+ python3 "$REPO_DIR/scripts/log_run.py" --script "link_edit_reddit" --posted 0 --skipped 0 --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED"
198
+
199
+ find "$LOG_DIR" -name "link-edit-reddit-*.log" -mtime +7 -delete 2>/dev/null || true
200
+
201
+ log "=== Reddit link-edit complete: $(date) ==="
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env bash
2
+ # linkedin-presence.sh - read-only LinkedIn session presence pass.
3
+ #
4
+ # Purpose:
5
+ # Run a bounded, auditable browser pass in the real linkedin-harness Chrome.
6
+ # It only views first-party LinkedIn surfaces and performs small scroll passes.
7
+ # It does not like, follow, connect, message, comment, expand comments, or open
8
+ # post permalinks.
9
+ #
10
+ # This is intentionally a Claude/harness pipeline, not a Python CDP action
11
+ # helper, so it stays inside the same LinkedIn browser-action boundary as the
12
+ # rest of the repo.
13
+
14
+ set -euo pipefail
15
+
16
+ REPO_DIR="$HOME/social-autoposter"
17
+ LOG_DIR="$REPO_DIR/skill/logs"
18
+ mkdir -p "$LOG_DIR"
19
+ LOG_FILE="$LOG_DIR/linkedin-presence-$(date +%Y-%m-%d_%H%M%S).log"
20
+ RUN_START=$(date +%s)
21
+ BATCH_ID="lipres-$(date +%Y%m%d_%H%M%S)-$$"
22
+ export SA_CYCLE_ID="$BATCH_ID"
23
+ export S4L_PIPELINE_NAME="linkedin-presence"
24
+
25
+ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
26
+
27
+ DRY_RUN="${LINKEDIN_PRESENCE_DRY_RUN:-0}"
28
+
29
+ if [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ] && [ "$DRY_RUN" != "1" ]; then
30
+ log "LINKEDIN_KILLSWITCH active. Skipping LinkedIn presence pass."
31
+ exit 0
32
+ elif [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
33
+ log "DRY_RUN: ignoring active LINKEDIN_KILLSWITCH for validation."
34
+ fi
35
+
36
+ # Optional local kill switch for operators who want the plist loaded but dormant.
37
+ if [ "${LINKEDIN_PRESENCE_ENABLED:-1}" = "0" ]; then
38
+ log "LINKEDIN_PRESENCE_ENABLED=0. Skipping."
39
+ exit 0
40
+ fi
41
+
42
+ # The launchd timer is fixed; vary each actual pass inside the script. Skipped
43
+ # passes do not write run_monitor rows, so the dashboard history only shows real
44
+ # browser activity.
45
+ RUN_PCT="${LINKEDIN_PRESENCE_RUN_PCT:-65}"
46
+ if [ "$DRY_RUN" != "1" ]; then
47
+ ROLL=$(( RANDOM % 100 ))
48
+ if [ "$ROLL" -ge "$RUN_PCT" ]; then
49
+ log "Presence pass skipped by schedule jitter (roll=$ROLL threshold=$RUN_PCT)."
50
+ exit 0
51
+ fi
52
+ JITTER_MAX="${LINKEDIN_PRESENCE_JITTER_MAX_SEC:-900}"
53
+ if [ "$JITTER_MAX" -gt 0 ]; then
54
+ JITTER=$(( RANDOM % (JITTER_MAX + 1) ))
55
+ log "Sleeping ${JITTER}s before presence pass."
56
+ sleep "$JITTER"
57
+ fi
58
+ fi
59
+
60
+ MODE_ROLL=$(( RANDOM % 4 ))
61
+ case "$MODE_ROLL" in
62
+ 0) MODE="feed"; TARGET_URL="https://www.linkedin.com/feed/" ;;
63
+ 1) MODE="notifications"; TARGET_URL="https://www.linkedin.com/notifications/" ;;
64
+ 2) MODE="messaging"; TARGET_URL="https://www.linkedin.com/messaging/" ;;
65
+ *) MODE="profile"; TARGET_URL="https://www.linkedin.com/in/me/" ;;
66
+ esac
67
+
68
+ SCROLLS=$(( 1 + (RANDOM % 3) ))
69
+ DWELL_A=$(( 2 + (RANDOM % 4) ))
70
+ DWELL_B=$(( 2 + (RANDOM % 4) ))
71
+ DWELL_C=$(( 2 + (RANDOM % 4) ))
72
+ SCROLL_A=$(( 420 + (RANDOM % 260) ))
73
+ SCROLL_B=$(( 420 + (RANDOM % 260) ))
74
+ SCROLL_C=$(( 420 + (RANDOM % 260) ))
75
+
76
+ log "=== LinkedIn Presence Run: $(date) (batch=$BATCH_ID mode=$MODE scrolls=$SCROLLS) ==="
77
+
78
+ # shellcheck source=/dev/null
79
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
80
+
81
+ source "$REPO_DIR/skill/lock.sh"
82
+ source "$REPO_DIR/skill/lib/linkedin-backend.sh"
83
+
84
+ PROMPT_FILE="$(mktemp -t saps-linkedin-presence.XXXXXX)"
85
+ cleanup() {
86
+ rm -f "$PROMPT_FILE" 2>/dev/null || true
87
+ rm -f "$HOME/.claude/linkedin-agent-lock.json" 2>/dev/null || true
88
+ if declare -f _sa_release_locks >/dev/null 2>&1; then
89
+ _sa_release_locks || true
90
+ fi
91
+ }
92
+ trap cleanup EXIT INT TERM HUP
93
+
94
+ cat > "$PROMPT_FILE" <<PROMPT_EOF
95
+ You are running a read-only LinkedIn presence pass for Social Autoposter.
96
+
97
+ $BROWSER_INSTRUCTIONS
98
+
99
+ Task:
100
+ - Mode: $MODE
101
+ - URL: $TARGET_URL
102
+ - Scroll passes: $SCROLLS
103
+ - Scroll amounts, in order: $SCROLL_A, $SCROLL_B, $SCROLL_C
104
+ - Dwell seconds, in order: $DWELL_A, $DWELL_B, $DWELL_C
105
+
106
+ Hard rules:
107
+ - Use only mcp__linkedin-harness__bh_run.
108
+ - Do not post, comment, react, like, repost, follow, connect, send messages, or submit forms.
109
+ - Do not open individual post permalinks.
110
+ - Do not click "Show more comments", "Load earlier replies", "See more", or any comment-expansion control.
111
+ - Do not call /voyager/api/*, fetch(), XHR, or any internal LinkedIn endpoint.
112
+ - If a login, checkpoint, captcha, authwall, or verify-you-are-human page appears, print exactly SESSION_INVALID and stop. Do not try to log in.
113
+ - In messaging mode, stay on the messaging sidebar/list. Do not open a conversation and do not read private thread contents.
114
+
115
+ Workflow:
116
+ 1. Reuse the existing tab and navigate to $TARGET_URL:
117
+ bh_run('goto_url("$TARGET_URL"); wait_for_load()')
118
+ 2. Read the current URL and a small page text sample using js(). If the URL or page text indicates login/checkpoint/captcha/authwall, print SESSION_INVALID and stop.
119
+ 3. Capture one screenshot and read it so you visually confirm the surface loaded.
120
+ 4. Perform exactly $SCROLLS bounded scroll pass(es) on the loaded surface using the bh_run scroll(direction, amount) helper, with the amounts/dwells above. Do not use window.scrollBy. Do not click anything.
121
+ 5. Capture one final screenshot.
122
+ 6. Print exactly one summary line:
123
+ LINKEDIN_PRESENCE_SUMMARY: mode=$MODE pages=1 scrolls=$SCROLLS session=ok
124
+
125
+ Keep the run short and quiet. This is a read-only session maintenance pass, not discovery, scraping, or engagement.
126
+ PROMPT_EOF
127
+
128
+ if [ "$DRY_RUN" = "1" ]; then
129
+ log "DRY_RUN: would run mode=$MODE url=$TARGET_URL scrolls=$SCROLLS"
130
+ exit 0
131
+ fi
132
+
133
+ acquire_lock "linkedin-browser" 1800
134
+ ensure_linkedin_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
135
+
136
+ TIMEOUT_BIN="$(command -v gtimeout || command -v timeout || true)"
137
+ PRESENCE_RC=0
138
+ set +e
139
+ if [ -n "$TIMEOUT_BIN" ]; then
140
+ "$TIMEOUT_BIN" 900 "$REPO_DIR/scripts/run_claude.sh" "linkedin-presence" \
141
+ --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" \
142
+ --output-format stream-json --verbose \
143
+ -p "$(cat "$PROMPT_FILE")" 2>&1 | tee -a "$LOG_FILE"
144
+ else
145
+ "$REPO_DIR/scripts/run_claude.sh" "linkedin-presence" \
146
+ --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" \
147
+ --output-format stream-json --verbose \
148
+ -p "$(cat "$PROMPT_FILE")" 2>&1 | tee -a "$LOG_FILE"
149
+ fi
150
+ PRESENCE_RC=${PIPESTATUS[0]}
151
+ set -e
152
+
153
+ release_lock "linkedin-browser"
154
+
155
+ ELAPSED=$(( $(date +%s) - RUN_START ))
156
+ COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" \
157
+ --since "$RUN_START" --scripts "linkedin-presence" 2>/dev/null || echo "0.0000")
158
+
159
+ FAILED=0
160
+ FAILURE_REASONS=""
161
+ if grep -q "SESSION_INVALID" "$LOG_FILE" 2>/dev/null; then
162
+ FAILED=1
163
+ FAILURE_REASONS="session_invalid:1"
164
+ elif [ "$PRESENCE_RC" -ne 0 ]; then
165
+ FAILED=1
166
+ if [ "$PRESENCE_RC" = "124" ]; then
167
+ FAILURE_REASONS="timeout:1"
168
+ else
169
+ FAILURE_REASONS="claude_or_browser_error:1"
170
+ fi
171
+ fi
172
+
173
+ python3 "$REPO_DIR/scripts/log_run.py" --script "presence_linkedin" \
174
+ --posted 0 --skipped 0 --failed "$FAILED" \
175
+ --cost "$COST" --elapsed "$ELAPSED" \
176
+ --scanned 1 --checked 1 \
177
+ --scan "pages=1,scrolls=$SCROLLS" \
178
+ ${FAILURE_REASONS:+--failure-reasons "$FAILURE_REASONS"} \
179
+ 2>/dev/null || true
180
+
181
+ find "$LOG_DIR" -name "linkedin-presence-*.log" -mtime +14 -delete 2>/dev/null || true
182
+ log "=== LinkedIn presence complete: $(date) rc=$PRESENCE_RC failed=$FAILED ==="