@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,730 @@
1
+ #!/usr/bin/env python3
2
+ """LinkedIn browser automation: read-only sidebar pre-check.
3
+
4
+ Usage:
5
+ python3 linkedin_browser.py unread-dms
6
+
7
+ Read-only DOM scrape: NO Voyager API, NO scroll-and-expand loops, NO
8
+ permalink fan-out, NO clicks/typing, NO programmatic login. Each
9
+ invocation does ONE navigation + ONE page.evaluate() then closes the context.
10
+
11
+ Per CLAUDE.md "LinkedIn: flagged patterns" carve-out (2026-04-29): a
12
+ read-only DOM read is permitted because its fingerprint is indistinguishable
13
+ from the existing mcp__linkedin-agent__ sessions (same profile, same cookies,
14
+ same headed Chrome binary). The 2026-04-17 restriction was caused by Voyager
15
+ calls + permalink scroll loops, neither of which appear here.
16
+
17
+ Connects to the running linkedin-agent's persistent profile at
18
+ ~/.claude/browser-profiles/linkedin. Launches HEADED Chromium (per the
19
+ CLAUDE.md note that LinkedIn fingerprints headless aggressively). Holds
20
+ the linkedin-browser lock for the entire run; expects the caller (shell)
21
+ to have already done lock acquisition + ensure_browser_healthy so the MCP
22
+ Chrome is gone and the profile is free.
23
+
24
+ Sister script for SERP discovery: scripts/discover_linkedin_candidates.py
25
+ (replaces the Claude-driven SERP nav inside skill/run-linkedin.sh Phase A).
26
+ That script imports PROFILE_DIR / VIEWPORT / SYSTEM_CHROME / LOCK_*
27
+ constants + _acquire_browser_lock + _is_login_or_checkpoint from this
28
+ module so both tools cooperate on the same Chrome profile and lock file.
29
+
30
+ Output (stdout, JSON):
31
+ {
32
+ "ok": true,
33
+ "url": "https://www.linkedin.com/messaging/",
34
+ "total_threads": 13,
35
+ "unread_count": 0,
36
+ "threads": [...],
37
+ }
38
+
39
+ Failure shapes:
40
+ {"ok": false, "error": "session_invalid", "url": "..."}
41
+ {"ok": false, "error": "profile_locked", "detail": "..."}
42
+ {"ok": false, "error": "navigation_failed", "detail": "..."}
43
+
44
+ Exits 0 on success, 1 on failure.
45
+ """
46
+
47
+ import atexit
48
+ import json
49
+ import os
50
+ import re
51
+ import subprocess
52
+ import sys
53
+ import time
54
+ from typing import Optional
55
+
56
+
57
+ def _bh_activity_log(action: str, cdp_url: str) -> None:
58
+ """Append to the universal browser-activity.log (Python-CDP path coverage).
59
+
60
+ The harness MCP server.py logs its own bh_run calls, but these CDP scripts
61
+ attach via connect_over_cdp and bypass it, so they log here directly.
62
+ """
63
+ try:
64
+ import time as _t
65
+ import os as _o
66
+ from pathlib import Path as _P
67
+ _p = _P(_o.environ.get(
68
+ "BH_ACTIVITY_LOG",
69
+ str(_P.home() / ".claude" / "browser-profiles" / "browser-activity.log"),
70
+ ))
71
+ _port = (cdp_url or "").rsplit(":", 1)[-1].split("/")[0] or "-"
72
+ _p.parent.mkdir(parents=True, exist_ok=True)
73
+ with _p.open("a") as _f:
74
+ _f.write(
75
+ f"[{_t.strftime('%Y-%m-%d %H:%M:%S')}] pycdp "
76
+ f"script={_o.path.basename(__file__)} action={action} "
77
+ f"pid={_o.getpid()} ppid={_o.getppid()} cdp={cdp_url or '-'} "
78
+ f"port={_port}\n"
79
+ )
80
+ except Exception:
81
+ pass
82
+
83
+
84
+ def _is_holder_alive(holder: str) -> bool:
85
+ """Mirror ~/.claude/hooks/linkedin-agent-lock.sh is_holder_alive().
86
+
87
+ A live Claude session puts its UUID on the cmdline as
88
+ `claude --session-id <UUID>`. pgrep matches it; absence means the
89
+ holder is dead and the lock is stale, even if its JSONL transcript
90
+ is still tail-flushing. This is the canonical liveness signal.
91
+ """
92
+ if not holder:
93
+ return False
94
+ try:
95
+ return (
96
+ subprocess.run(
97
+ ["pgrep", "-f", f"claude.*--session-id {holder}"],
98
+ stdout=subprocess.DEVNULL,
99
+ stderr=subprocess.DEVNULL,
100
+ timeout=2,
101
+ ).returncode
102
+ == 0
103
+ )
104
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
105
+ # On error, assume alive to err on the side of NOT stealing the lock.
106
+ return True
107
+
108
+ # Profile dir is overridable so the harness migration (2026-05-26) can point
109
+ # the cold-launch fallback at ~/.claude/browser-profiles/browser-harness-linkedin
110
+ # while leaving legacy linkedin-agent callers unchanged. The Twitter harness
111
+ # uses the same pattern (TWITTER_CDP_URL + harness profile dir).
112
+ PROFILE_DIR = os.path.expanduser(
113
+ os.environ.get(
114
+ "LINKEDIN_PROFILE_DIR",
115
+ "~/.claude/browser-profiles/linkedin",
116
+ )
117
+ )
118
+ LOCK_FILE = os.path.expanduser("~/.claude/linkedin-agent-lock.json")
119
+ LOCK_EXPIRY = 300 # Must match ~/.claude/hooks/linkedin-agent-lock.sh
120
+ LOCK_WAIT_MAX = 30 # seconds; pre-check should not block long
121
+ LOCK_POLL_INTERVAL = 2
122
+ VIEWPORT = {"width": 911, "height": 1016}
123
+ # linkedin-agent uses the system Google Chrome binary, not Playwright's
124
+ # bundled "Chrome for Testing". Profile was created/migrated by system
125
+ # Chrome and "Chrome for Testing" fails to open it (SIGTRAP / kill EPERM
126
+ # observed 2026-04-29). Match the agent's binary so the profile stays
127
+ # compatible.
128
+ SYSTEM_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
129
+
130
+ _LOCK_SESSION_ID = f"python:{os.getpid()}"
131
+ _LOCK_INHERITED = False
132
+ _UUID_RE = re.compile(
133
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
134
+ )
135
+
136
+
137
+ def _release_browser_lock():
138
+ if _LOCK_INHERITED:
139
+ return
140
+ try:
141
+ if os.path.exists(LOCK_FILE):
142
+ with open(LOCK_FILE) as f:
143
+ lock = json.load(f)
144
+ if lock.get("session_id") == _LOCK_SESSION_ID:
145
+ os.remove(LOCK_FILE)
146
+ except (json.JSONDecodeError, OSError):
147
+ pass
148
+
149
+
150
+ atexit.register(_release_browser_lock)
151
+
152
+
153
+ def _is_python_holder_alive(holder: str) -> bool:
154
+ """Liveness probe for a `python:PID` lock holder (defect a fix, 2026-06-16).
155
+
156
+ Mirrors twitter_browser._is_python_holder_alive (see docs/twitter_browser_lock.md).
157
+ Holders written by the python scripts are `python:<pid>`; the linkedin-agent
158
+ PreToolUse hook writes UUID holders (handled separately by _is_holder_alive).
159
+ A python holder whose process died without running its atexit release used to
160
+ starve every peer until LOCK_EXPIRY (300s); os.kill(pid, 0) lets us reclaim it
161
+ at once. Returns True for anything we cannot prove dead, so the worst case
162
+ degrades to the LOCK_EXPIRY failsafe rather than stealing a live peer's lock.
163
+ """
164
+ if not holder.startswith("python:"):
165
+ return True # not a python holder; this probe makes no claim
166
+ try:
167
+ pid = int(holder.split(":", 1)[1])
168
+ except (ValueError, IndexError):
169
+ return True # unparseable holder -> don't steal on this basis
170
+ try:
171
+ os.kill(pid, 0)
172
+ return True # process exists -> alive
173
+ except ProcessLookupError:
174
+ return False # no such process -> dead, reclaimable
175
+ except PermissionError:
176
+ return True # exists but another owner -> alive
177
+ except OSError:
178
+ return True # ambiguous -> err toward NOT stealing
179
+
180
+
181
+ def _try_take_lock() -> bool:
182
+ """Atomically claim LOCK_FILE for this process (defect c fix, 2026-06-16).
183
+ O_CREAT|O_EXCL makes "is it free? then take it" a single syscall, so two
184
+ python acquirers can't both win the old os.path.exists + open(w) race. A
185
+ False return means a peer beat us; the caller re-loops. Coexists with the
186
+ linkedin-agent hook (which registers UUID holders via its own write path):
187
+ python only takes when it has decided the lock is free or reclaimable.
188
+ """
189
+ try:
190
+ fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
191
+ except FileExistsError:
192
+ return False
193
+ except OSError:
194
+ return False
195
+ try:
196
+ os.write(fd, json.dumps(
197
+ {"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}
198
+ ).encode())
199
+ finally:
200
+ os.close(fd)
201
+ return True
202
+
203
+
204
+ def _acquire_browser_lock():
205
+ """Acquire the LinkedIn browser session mutex (~/.claude/linkedin-agent-lock.json).
206
+
207
+ Mirrors twitter_browser._acquire_browser_lock (full writeup:
208
+ docs/twitter_browser_lock.md). Co-managed with the linkedin-agent PreToolUse
209
+ hook, which registers live Claude sessions as UUID holders; we INHERIT those
210
+ rather than fight them. Reclaim priority (a holder PROVEN dead is taken at
211
+ once, so a crashed peer cannot starve the fleet for LOCK_WAIT_MAX/LOCK_EXPIRY):
212
+ 1. holder == us -> re-entrant; already ours.
213
+ 2. UUID holder, pid gone -> stale Claude session, reclaim.
214
+ 3. python:PID, pid gone -> dead peer (defect a fix), reclaim.
215
+ 4. age >= LOCK_EXPIRY -> failsafe for holders we cannot probe.
216
+ 5. live UUID holder -> inherit (parent Claude session / hook).
217
+ 6. live python:PID holder -> real peer; wait, then give up (profile_locked).
218
+
219
+ Acquisition is atomic (_try_take_lock / O_EXCL). The lockfile JSON shape
220
+ {"session_id","timestamp"} is preserved so the hook keeps interoperating.
221
+ """
222
+ global _LOCK_SESSION_ID, _LOCK_INHERITED
223
+ deadline = time.time() + LOCK_WAIT_MAX
224
+ try:
225
+ os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
226
+ except OSError:
227
+ pass
228
+ while True:
229
+ if not os.path.exists(LOCK_FILE):
230
+ if _try_take_lock():
231
+ break
232
+ if time.time() >= deadline:
233
+ print(json.dumps({
234
+ "ok": False, "error": "profile_locked",
235
+ "detail": f"create-contended waited={LOCK_WAIT_MAX}s",
236
+ }))
237
+ sys.exit(1)
238
+ time.sleep(LOCK_POLL_INTERVAL)
239
+ continue
240
+ try:
241
+ with open(LOCK_FILE) as f:
242
+ lock = json.load(f)
243
+ except (json.JSONDecodeError, OSError):
244
+ # Corrupt / half-written / vanished between exists() and open().
245
+ if _try_take_lock():
246
+ break
247
+ if time.time() >= deadline:
248
+ print(json.dumps({
249
+ "ok": False, "error": "profile_locked",
250
+ "detail": f"unreadable waited={LOCK_WAIT_MAX}s",
251
+ }))
252
+ sys.exit(1)
253
+ time.sleep(LOCK_POLL_INTERVAL)
254
+ continue
255
+ age = time.time() - lock.get("timestamp", 0)
256
+ holder = lock.get("session_id", "")
257
+
258
+ # 1. Re-entrant: the lock is already ours (or a stale lock left by a
259
+ # previous process whose PID we reused). Refresh timestamp + proceed.
260
+ if holder == _LOCK_SESSION_ID and not _LOCK_INHERITED:
261
+ try:
262
+ with open(LOCK_FILE, "w") as f:
263
+ json.dump(
264
+ {"session_id": _LOCK_SESSION_ID,
265
+ "timestamp": int(time.time())}, f)
266
+ except OSError:
267
+ pass
268
+ break
269
+
270
+ # 2-4. Reclaim a holder we can prove dead/expired (remove + atomic take).
271
+ reclaim_reason = ""
272
+ if _UUID_RE.match(holder or "") and not _is_holder_alive(holder):
273
+ reclaim_reason = "dead_uuid"
274
+ elif holder.startswith("python:") and not _is_python_holder_alive(holder):
275
+ reclaim_reason = "dead_python"
276
+ elif age >= LOCK_EXPIRY:
277
+ reclaim_reason = "expired"
278
+ if reclaim_reason:
279
+ try:
280
+ os.remove(LOCK_FILE)
281
+ except OSError:
282
+ pass
283
+ if _try_take_lock():
284
+ print(f"[browser_lock] reclaimed holder={holder or '<none>'} "
285
+ f"reason={reclaim_reason} age={int(age)}s -> pid={os.getpid()} "
286
+ f"platform=linkedin", file=sys.stderr)
287
+ break
288
+ time.sleep(LOCK_POLL_INTERVAL)
289
+ continue
290
+
291
+ # 5. Live UUID holder = parent Claude session / hook -> inherit.
292
+ if _UUID_RE.match(holder or ""):
293
+ _LOCK_SESSION_ID = holder
294
+ _LOCK_INHERITED = True
295
+ break
296
+
297
+ # 6. Live python:PID peer -> real contention. Wait, then give up. Reaching
298
+ # the deadline now means the holder is genuinely alive (dead ones were
299
+ # reclaimed above), NOT the defect-a starvation. peer_alive=1 is the tell.
300
+ if time.time() >= deadline:
301
+ print(json.dumps({
302
+ "ok": False,
303
+ "error": "profile_locked",
304
+ "detail": (
305
+ f"holder={holder} age={int(age)}s "
306
+ f"waited={LOCK_WAIT_MAX}s peer_alive=1"
307
+ ),
308
+ }))
309
+ sys.exit(1)
310
+ time.sleep(LOCK_POLL_INTERVAL)
311
+ continue
312
+
313
+
314
+ def _is_login_or_checkpoint(url: str) -> bool:
315
+ if not url:
316
+ return True
317
+ return any(
318
+ marker in url
319
+ for marker in (
320
+ "/login",
321
+ "/checkpoint",
322
+ "/uas/login",
323
+ "linkedin.com/authwall",
324
+ )
325
+ )
326
+
327
+
328
+ def _read_devtools_active_port() -> Optional[int]:
329
+ """Return the CDP port the linkedin-agent MCP Chrome is listening on.
330
+
331
+ The persistent profile dir holds a `DevToolsActivePort` file written
332
+ by Chrome on startup whenever `--remote-debugging-port=0` is passed
333
+ (the linkedin-agent MCP launches Chrome that way). First line is the
334
+ port, second line is the browser uuid path. Missing file -> None
335
+ (MCP is cold; caller raises RuntimeError — cold-launch removed
336
+ 2026-05-27, never attach to the wrong profile).
337
+ """
338
+ port_file = os.path.join(PROFILE_DIR, "DevToolsActivePort")
339
+ try:
340
+ with open(port_file) as f:
341
+ first = f.readline().strip()
342
+ port = int(first)
343
+ if port <= 0 or port >= 65536:
344
+ return None
345
+ return port
346
+ except (FileNotFoundError, ValueError, OSError):
347
+ return None
348
+
349
+
350
+ def _pid_listening_on(port: int) -> Optional[int]:
351
+ """Return the PID listening on a TCP port, via lsof. Best-effort.
352
+
353
+ Used purely for diagnostic logging in `_connect_to_running_or_launch`
354
+ so failure logs can answer "did we attach to an existing Chrome or
355
+ cold-launch one ourselves?" without guesswork. Never raises.
356
+ """
357
+ try:
358
+ out = subprocess.run(
359
+ ["lsof", "-ti", f":{port}", "-sTCP:LISTEN"],
360
+ stdout=subprocess.PIPE,
361
+ stderr=subprocess.DEVNULL,
362
+ timeout=3,
363
+ ).stdout.decode("utf-8", "replace").strip()
364
+ if out:
365
+ return int(out.splitlines()[0])
366
+ except (subprocess.TimeoutExpired, FileNotFoundError, ValueError, OSError):
367
+ pass
368
+ return None
369
+
370
+
371
+ def _connect_to_running_or_launch(p, *, prefer_cdp: bool = True):
372
+ """Get a BrowserContext for LinkedIn via CDP attach to a running harness.
373
+
374
+ Cold-launch fallback REMOVED 2026-05-27. Per explicit user instruction:
375
+ the pipeline must NEVER spawn its own Chrome on the `linkedin` profile.
376
+ That fallback would attach to a *different* profile from the one the
377
+ harness Chrome owns (`browser-harness-linkedin`), and the two have
378
+ drifted in practice: the harness profile holds the active `li_at`
379
+ session cookie, the `linkedin` profile does not. Cold-launching it
380
+ sent every scrape straight to /authwall and silently masked the real
381
+ failure (the harness was unreachable).
382
+
383
+ Strategy now (harness-only, 2026-05-31):
384
+ 1. If `LINKEDIN_CDP_URL` env var is set (skill/lib/linkedin-backend.sh
385
+ sets it to http://127.0.0.1:9556), attach to the linkedin-harness
386
+ Chrome via connect_over_cdp. Returns owns_context=False; caller
387
+ closes only the page they opened, never the context.
388
+ 2. If that attach fails, RAISE. The legacy DevToolsActivePort fallback
389
+ to the linkedin-agent profile (~/.claude/browser-profiles/linkedin)
390
+ was REMOVED 2026-05-31: it silently attached to a SECOND Chrome
391
+ whenever the harness was momentarily unreachable, which is the
392
+ "two LinkedIn browsers in parallel" bug. The harness Chrome on :9556
393
+ is now the ONLY allowed LinkedIn browser. Never attach to a sibling
394
+ profile, never cold-launch.
395
+
396
+ `prefer_cdp` kept as a kwarg for caller-API stability but no longer
397
+ has a meaningful False branch (cold-launch and legacy attach are gone).
398
+
399
+ Returns:
400
+ (context, owns_context) # owns_context is always False on success
401
+
402
+ Raises:
403
+ RuntimeError if no warm harness/MCP Chrome is reachable.
404
+ """
405
+ from playwright.sync_api import sync_playwright # noqa: F401
406
+
407
+ last_err: Optional[Exception] = None
408
+
409
+ # Lane 1: explicit harness CDP URL (preferred — set by linkedin-backend.sh
410
+ # when the browser-harness Chrome is up on port 9556).
411
+ harness_cdp_url = os.environ.get("LINKEDIN_CDP_URL", "").strip()
412
+ if prefer_cdp and harness_cdp_url:
413
+ try:
414
+ browser = p.chromium.connect_over_cdp(
415
+ harness_cdp_url,
416
+ timeout=5000,
417
+ )
418
+ contexts = browser.contexts
419
+ if contexts:
420
+ print(
421
+ f"[linkedin_browser] mode=harness_cdp_attach "
422
+ f"url={harness_cdp_url} profile=browser-harness-linkedin",
423
+ file=sys.stderr,
424
+ flush=True,
425
+ )
426
+ _bh_activity_log("attach_harness", harness_cdp_url)
427
+ return contexts[0], False
428
+ last_err = RuntimeError("harness CDP attach: zero contexts")
429
+ except Exception as e:
430
+ last_err = e
431
+ print(
432
+ f"[linkedin_browser] harness_cdp_attach failed: {e}",
433
+ file=sys.stderr,
434
+ flush=True,
435
+ )
436
+
437
+ # Lane 2 (legacy DevToolsActivePort attach to the linkedin-agent profile
438
+ # ~/.claude/browser-profiles/linkedin) was REMOVED 2026-05-31. It let the
439
+ # pipeline silently attach to a SECOND Chrome (the retired linkedin-agent
440
+ # MCP browser) whenever the harness attach above failed — the root cause
441
+ # of the "two LinkedIn browsers in parallel" bug. The harness Chrome on
442
+ # :9556 is now the ONLY allowed LinkedIn browser.
443
+
444
+ # No warm harness Chrome reachable. Fail loudly — never attach to the
445
+ # legacy linkedin-agent profile, never cold-launch.
446
+ raise RuntimeError(
447
+ "linkedin_browser: harness Chrome (port 9556) not reachable via "
448
+ "LINKEDIN_CDP_URL. Legacy DevToolsActivePort + cold-launch fallbacks "
449
+ "were removed (they attached to the wrong profile and spawned a "
450
+ "second browser). Restart the linkedin-harness Chrome and retry. "
451
+ f"Last error: {last_err}"
452
+ )
453
+
454
+
455
+ def unread_dms() -> dict:
456
+ """Scan LinkedIn /messaging/ sidebar in headed mode, read-only.
457
+
458
+ Cold-launch removed 2026-05-27 — this now requires the linkedin-harness
459
+ Chrome to be reachable via CDP; otherwise it returns
460
+ error='no_warm_browser' so the caller can surface the real cause
461
+ instead of silently attaching to a logged-out sibling profile.
462
+ """
463
+ from playwright.sync_api import sync_playwright
464
+
465
+ _acquire_browser_lock()
466
+
467
+ with sync_playwright() as p:
468
+ try:
469
+ context, _owns_context = _connect_to_running_or_launch(p)
470
+ except RuntimeError as e:
471
+ return {
472
+ "ok": False,
473
+ "error": "no_warm_browser",
474
+ "detail": str(e),
475
+ }
476
+
477
+ page = None
478
+ _reused_page = False
479
+ try:
480
+ # Reuse an existing harness tab instead of spawning a throwaway one
481
+ # (mirrors reddit_browser). new_page() also steals OS focus every
482
+ # call. Prefer a tab already on linkedin.com (not login/checkpoint),
483
+ # else the first open page; only new_page() when the context has no
484
+ # usable tab. A reused tab is left open in the finally below so the
485
+ # next consumer can reuse it too.
486
+ for pg in context.pages:
487
+ u = pg.url or ""
488
+ if "linkedin.com" in u and "login" not in u and "checkpoint" not in u:
489
+ page, _reused_page = pg, True
490
+ break
491
+ if page is None and context.pages:
492
+ page, _reused_page = context.pages[0], True
493
+ if page is None:
494
+ page = context.new_page()
495
+ try:
496
+ page.goto(
497
+ "https://www.linkedin.com/messaging/",
498
+ wait_until="domcontentloaded",
499
+ timeout=30000,
500
+ )
501
+ except Exception as e:
502
+ return {
503
+ "ok": False,
504
+ "error": "navigation_failed",
505
+ "detail": str(e),
506
+ }
507
+
508
+ # Settle: wait for the conversation list to render. LinkedIn's
509
+ # messaging UI lazy-loads after DOMContentLoaded.
510
+ try:
511
+ page.wait_for_selector(
512
+ "ul.msg-conversations-container__conversations-list, "
513
+ "ul[class*='conversations-list'], "
514
+ "main [role='list']",
515
+ timeout=10000,
516
+ )
517
+ except Exception:
518
+ pass # we'll still try to read whatever's there
519
+ page.wait_for_timeout(1500)
520
+
521
+ cur_url = page.url
522
+ if _is_login_or_checkpoint(cur_url):
523
+ return {
524
+ "ok": False,
525
+ "error": "session_invalid",
526
+ "url": cur_url,
527
+ }
528
+
529
+ # Read sidebar. Strategy:
530
+ # - For each conversation list item, derive partner name
531
+ # (bolded participant), preview text, time, and unread state.
532
+ # - Unread signal: visual blue dot (.notification-badge--show)
533
+ # OR data-test-unread, NOT generic [aria-label*=unread].
534
+ # LinkedIn renders hover "Mark as unread" buttons that
535
+ # contain the substring 'unread' on every thread.
536
+ # - thread_url: try the <a href> if rendered; otherwise null.
537
+ threads = page.evaluate(
538
+ """
539
+ () => {
540
+ const out = [];
541
+ // Find conversation list items. LinkedIn renders these as
542
+ // <li> inside the conversations list; fall back to any
543
+ // [role=listitem] anchored under the messaging main.
544
+ const candidates = document.querySelectorAll(
545
+ "ul.msg-conversations-container__conversations-list > li, "
546
+ + "ul[class*='conversations-list'] > li, "
547
+ + "main [role='listitem']"
548
+ );
549
+ for (const item of candidates) {
550
+ // Skip ad slots / non-conversation rows.
551
+ const link = item.querySelector(
552
+ "a.msg-conversation-listitem__link, a[href*='/messaging/thread/']"
553
+ );
554
+ const innerText = (item.innerText || "").trim();
555
+ if (!innerText) continue;
556
+
557
+ // Unread badge: blue dot. Avoid the broad
558
+ // [aria-label*=unread] selector which matches the
559
+ // hover "Mark as unread" affordance.
560
+ const blueDot = item.querySelector(
561
+ ".notification-badge--show, "
562
+ + "[data-test-unread='true'], "
563
+ + ".msg-conversation-card__unread-count, "
564
+ + ".notification-badge.notification-badge--show"
565
+ );
566
+ const unread = !!blueDot;
567
+
568
+ // Partner name: prefer h3 / participant-names node.
569
+ const nameEl = item.querySelector(
570
+ "h3, .msg-conversation-listitem__participant-names, "
571
+ + ".msg-conversation-card__participant-names"
572
+ );
573
+ const partner = nameEl
574
+ ? (nameEl.textContent || "").trim()
575
+ : "";
576
+
577
+ // Time element: usually a small time/timestamp span.
578
+ const timeEl = item.querySelector(
579
+ "time, .msg-conversation-listitem__time-stamp, "
580
+ + ".msg-conversation-card__time-stamp"
581
+ );
582
+ const time = timeEl
583
+ ? (timeEl.textContent || "").trim()
584
+ : "";
585
+
586
+ // Preview (snippet of last message). Take first text
587
+ // node after the participant name that isn't the time.
588
+ const previewEl = item.querySelector(
589
+ ".msg-conversation-card__message-snippet, "
590
+ + ".msg-conversation-listitem__message-snippet, "
591
+ + "p.msg-conversation-card__message-snippet"
592
+ );
593
+ let preview = previewEl
594
+ ? (previewEl.textContent || "").trim()
595
+ : "";
596
+ if (!preview) {
597
+ // Fallback: trim partner+time off the innerText.
598
+ preview = innerText
599
+ .replace(partner, "")
600
+ .replace(time, "")
601
+ .trim();
602
+ }
603
+
604
+ let threadUrl = null;
605
+ if (link) {
606
+ const href = link.getAttribute("href") || "";
607
+ if (href && /\\/messaging\\/thread\\//.test(href)) {
608
+ threadUrl = href.startsWith("http")
609
+ ? href
610
+ : ("https://www.linkedin.com" + href);
611
+ }
612
+ }
613
+
614
+ out.push({
615
+ partner,
616
+ preview: preview,
617
+ time,
618
+ thread_url: threadUrl,
619
+ unread,
620
+ });
621
+ }
622
+ return JSON.stringify(out);
623
+ }
624
+ """
625
+ )
626
+ try:
627
+ threads_list = json.loads(threads or "[]")
628
+ except json.JSONDecodeError:
629
+ threads_list = []
630
+
631
+ unread_count = sum(1 for t in threads_list if t.get("unread"))
632
+
633
+ return {
634
+ "ok": True,
635
+ "url": cur_url,
636
+ "total_threads": len(threads_list),
637
+ "unread_count": unread_count,
638
+ "threads": threads_list,
639
+ }
640
+
641
+ finally:
642
+ # CDP-attach branch: NEVER close the context — that would
643
+ # terminate the harness Chrome we just attached to. Only close a
644
+ # page WE created; if we reused an existing tab, leave it open so
645
+ # the next consumer can reuse it (tab-reuse convention).
646
+ if page is not None and not _reused_page:
647
+ try:
648
+ page.close()
649
+ except Exception:
650
+ pass
651
+
652
+
653
+ def unread_dms_with_retry(max_attempts: int = 2) -> dict:
654
+ """Wrap unread_dms with one retry on TargetClosedError-style transient
655
+ failures. The headed Chrome launch races against atexit lock release on
656
+ the previous run; a single retry after a short delay clears most cases.
657
+ """
658
+ last_result: dict = {"ok": False, "error": "no_attempts"}
659
+ for attempt in range(1, max_attempts + 1):
660
+ try:
661
+ result = unread_dms()
662
+ except Exception as e:
663
+ result = {
664
+ "ok": False,
665
+ "error": "exception",
666
+ "detail": f"{type(e).__name__}: {e}",
667
+ "attempt": attempt,
668
+ }
669
+ last_result = result
670
+ # Only retry on transient browser-target failures, not on
671
+ # session_invalid / profile_locked which won't self-heal.
672
+ err = (result.get("error") or "").lower()
673
+ detail = (result.get("detail") or "").lower()
674
+ transient = (
675
+ "targetclosed" in detail
676
+ or "target page" in detail
677
+ or "browser has been closed" in detail
678
+ or err == "navigation_failed"
679
+ )
680
+ if result.get("ok") or not transient or attempt >= max_attempts:
681
+ if attempt > 1:
682
+ result["retry_attempt"] = attempt
683
+ return result
684
+ print(
685
+ f"[linkedin_browser] transient failure attempt {attempt}: "
686
+ f"{result.get('detail') or result.get('error')}; retrying...",
687
+ file=sys.stderr,
688
+ )
689
+ time.sleep(2)
690
+ return last_result
691
+
692
+
693
+ def main():
694
+ # Guard: only authorized pipelines may invoke this helper. Other Claude
695
+ # subprocess planners (post_reddit, post_twitter, etc.) auto-load
696
+ # CLAUDE.md as system context, see this helper documented there, and
697
+ # have wandered off-task to "smoke test" it — racing the linkedin
698
+ # profile's SingletonLock and triggering server-side session
699
+ # invalidation. The legitimate caller sets the matching env var
700
+ # immediately before invoking; nothing else does.
701
+ if os.environ.get("SOCIAL_AUTOPOSTER_LINKEDIN_PRECHECK") != "1":
702
+ print(
703
+ json.dumps({
704
+ "ok": False,
705
+ "error": "unauthorized_caller",
706
+ "detail": (
707
+ "linkedin_browser.py is invoked only by the "
708
+ "engage-dm-replies pre-check. Set "
709
+ "SOCIAL_AUTOPOSTER_LINKEDIN_PRECHECK=1 from the caller "
710
+ "if this invocation is legitimate. (For SERP discovery, "
711
+ "use scripts/discover_linkedin_candidates.py instead.)"
712
+ ),
713
+ }),
714
+ file=sys.stderr,
715
+ )
716
+ sys.exit(2)
717
+ if len(sys.argv) < 2:
718
+ print(__doc__)
719
+ sys.exit(2)
720
+ cmd = sys.argv[1]
721
+ if cmd == "unread-dms":
722
+ result = unread_dms_with_retry()
723
+ print(json.dumps(result, indent=2))
724
+ sys.exit(0 if result.get("ok") else 1)
725
+ print(f"Unknown command: {cmd}", file=sys.stderr)
726
+ sys.exit(2)
727
+
728
+
729
+ if __name__ == "__main__":
730
+ main()