@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,593 @@
1
+ #!/usr/bin/env python3
2
+ """reddit_browser_lock.py — explicit per-post browser lock for Reddit.
3
+
4
+ Why this exists
5
+ ---------------
6
+ link-edit-reddit.sh used to acquire the bash-level reddit-browser lock for
7
+ its entire claude session (~90 min). During that window the orchestrator
8
+ was 99% in SEO page-gen / DB / file-IO time, NOT actually using the reddit
9
+ browser. Other reddit pipelines (engage-reddit, dm-replies-reddit,
10
+ post-reddit) sat blocked the whole time.
11
+
12
+ This helper lets the claude orchestrator acquire/release the reddit-browser
13
+ lock per post, so the lock is only held during the actual `mcp__reddit-agent__*`
14
+ browser ops (typically 15-60s per comment edit) instead of the full run.
15
+
16
+ Interop with skill/lock.sh
17
+ --------------------------
18
+ We share the exact lock-dir format used by skill/lock.sh:
19
+
20
+ /tmp/social-autoposter-<name>.lock/ (the lock; mkdir-atomic)
21
+ pid (one line: owner PID)
22
+ /tmp/social-autoposter-<name>.lock.queue/ (FIFO ticket queue)
23
+ <ns_timestamp>-<pid> (one ticket per waiter)
24
+
25
+ Bash and Python helpers can BOTH compete for the same lock safely. Stale
26
+ detection mirrors lock.sh: missing pid file, dead holder PID, or lock-dir
27
+ age > 3h all trigger steal.
28
+
29
+ Owner PID
30
+ ---------
31
+ The lock's owner PID is NOT this short-lived python process — that would
32
+ be detected as dead the moment we exit. Instead, we walk up the process
33
+ tree from os.getppid() looking for the long-lived link-edit-reddit.sh
34
+ (or any claude --session-id ancestor) and use THAT pid. If none is found,
35
+ we fall back to os.getppid() (typically the bash subprocess from claude's
36
+ Bash tool, which lives at least until the tool call returns).
37
+
38
+ Lease/TTL semantics (added 2026-05-08)
39
+ --------------------------------------
40
+ On acquire we also write `expires_at` (a Unix timestamp) inside the lock dir.
41
+ The lock is considered STALE (and stealable by a peer) once `now() > expires_at`.
42
+
43
+ The MCP browser wrapper (`scripts/mcp_lock_proxy.py`) bumps `expires_at` on every
44
+ `tools/call` request that crosses it, and again on every response, plus a periodic
45
+ 30s pulse while a request is in flight. So as long as actual reddit-agent browser
46
+ work is happening, the lease keeps renewing. The moment the holder stops calling
47
+ the browser (page-gen, sleeps, DB writes, agent crashes), no more bumps fire and
48
+ the lease auto-expires within `LEASE_TTL_SECONDS` seconds (default 90). Peers
49
+ in the queue then see the lock as stale and steal it.
50
+
51
+ This eliminates orphaned-lock outages without needing the agent to remember to
52
+ call `release` in every code path. `release` still works (and is preferred when
53
+ the agent knows it's done early), but is no longer load-bearing for correctness.
54
+
55
+ CLI
56
+ ---
57
+ python3 reddit_browser_lock.py acquire [--name reddit-browser] [--timeout 600] [--ttl 90]
58
+ python3 reddit_browser_lock.py release [--name reddit-browser]
59
+ python3 reddit_browser_lock.py status [--name reddit-browser]
60
+ python3 reddit_browser_lock.py heartbeat [--name reddit-browser] [--ttl 90]
61
+
62
+ Acquire prints a single line:
63
+ OK owner_pid=<N> waited=<sec> ttl=<sec> (success)
64
+ BUSY holder_pid=<N> age=<sec> (timed out)
65
+ ERROR <reason> (unexpected)
66
+
67
+ Release prints:
68
+ OK
69
+ NOT_HELD (lock dir missing)
70
+ HELD_BY_OTHER holder_pid=<N> (don't release — different owner)
71
+ ERROR <reason>
72
+
73
+ Heartbeat prints:
74
+ OK expires_at=<unix_ts> (lease extended)
75
+ NOT_HELD (no lock dir; nothing to extend)
76
+ HELD_BY_OTHER holder_pid=<N> (we're not the owner; refused to bump)
77
+ ERROR <reason>
78
+
79
+ Status prints JSON:
80
+ {"name":"...", "held":bool, "holder_pid":N|null, "age_sec":N|null,
81
+ "expires_at":F|null, "ttl_remaining_sec":F|null, "expired":bool, "queue":[..]}
82
+
83
+ Exit codes
84
+ ----------
85
+ 0 — success / lock state read cleanly
86
+ 1 — timeout / busy / not-held / refused-foreign-release
87
+ 2 — usage / argument error
88
+ """
89
+
90
+ from __future__ import annotations
91
+
92
+ import argparse
93
+ import errno
94
+ import json
95
+ import os
96
+ import shutil
97
+ import subprocess
98
+ import sys
99
+ import time
100
+ from pathlib import Path
101
+
102
+ LOCK_ROOT = "/tmp"
103
+ DEFAULT_NAME = "reddit-browser"
104
+ DEFAULT_ACQUIRE_TIMEOUT = 600 # 10 min — generous for a per-post browser slot
105
+ POLL_INTERVAL = 2.0
106
+ STALE_LOCK_AGE = 10800 # 3h, matches lock.sh safety net (final backstop)
107
+
108
+ # Lease/TTL: how long the lock stays valid without a heartbeat. The MCP
109
+ # wrapper auto-heartbeats during browser activity, so a holder that stops
110
+ # making MCP browser calls will see its lease expire after this many seconds
111
+ # of idleness, allowing peers to steal the lock.
112
+ #
113
+ # Sized from real reddit-agent MCP call distribution (n=2390, 14 days):
114
+ # p99 = 30s, max-legit = 5.6 min (one outlier on browser_close).
115
+ # 90s = 15x p99, leaves comfortable headroom inside the wrapper's 30s
116
+ # periodic pulse for in-flight calls. For the rare 5+ min outlier the
117
+ # pulse keeps renewing, so the lease never accidentally expires under
118
+ # real activity.
119
+ DEFAULT_LEASE_TTL_SECONDS = 90
120
+
121
+
122
+ def lock_paths(name: str) -> tuple[Path, Path, Path]:
123
+ base = Path(LOCK_ROOT) / f"social-autoposter-{name}.lock"
124
+ return base, base / "pid", Path(f"{base}.queue")
125
+
126
+
127
+ def expires_file_path(lock_dir: Path) -> Path:
128
+ return lock_dir / "expires_at"
129
+
130
+
131
+ def read_expires_at(lock_dir: Path) -> float | None:
132
+ f = expires_file_path(lock_dir)
133
+ if not f.is_file():
134
+ return None
135
+ try:
136
+ return float(f.read_text().strip())
137
+ except Exception:
138
+ return None
139
+
140
+
141
+ def write_expires_at(lock_dir: Path, ts: float) -> bool:
142
+ """Write `expires_at` atomically. Returns True on success.
143
+
144
+ Uses write-then-rename to keep readers from seeing a half-written value.
145
+ Safe no-op if `lock_dir` was removed mid-write (e.g. lock got released
146
+ or stolen between the existence check and the write).
147
+ """
148
+ try:
149
+ if not lock_dir.is_dir():
150
+ return False
151
+ tmp = lock_dir / f"expires_at.tmp.{os.getpid()}"
152
+ tmp.write_text(f"{ts:.3f}\n")
153
+ tmp.replace(expires_file_path(lock_dir))
154
+ return True
155
+ except FileNotFoundError:
156
+ return False
157
+ except Exception:
158
+ return False
159
+
160
+
161
+ def pid_alive(pid: int) -> bool:
162
+ if pid <= 0:
163
+ return False
164
+ try:
165
+ os.kill(pid, 0)
166
+ return True
167
+ except ProcessLookupError:
168
+ return False
169
+ except PermissionError:
170
+ # Process exists, we just can't signal it
171
+ return True
172
+ except OSError as e:
173
+ return e.errno != errno.ESRCH
174
+
175
+
176
+ def ps_command(pid: int) -> str:
177
+ """Return the full command line for `pid`, or '' if not found."""
178
+ try:
179
+ r = subprocess.run(
180
+ ["ps", "-o", "command=", "-p", str(pid)],
181
+ capture_output=True, text=True, timeout=2,
182
+ )
183
+ return r.stdout.strip()
184
+ except Exception:
185
+ return ""
186
+
187
+
188
+ def ps_ppid(pid: int) -> int:
189
+ try:
190
+ r = subprocess.run(
191
+ ["ps", "-o", "ppid=", "-p", str(pid)],
192
+ capture_output=True, text=True, timeout=2,
193
+ )
194
+ return int(r.stdout.strip() or 0)
195
+ except Exception:
196
+ return 0
197
+
198
+
199
+ def find_owner_pid() -> int:
200
+ """Walk up the process tree to find a long-lived owner.
201
+
202
+ Looks for: link-edit-reddit.sh, run-reddit-search.sh, engage-reddit.sh,
203
+ or any `claude --session-id` ancestor. Returns the FIRST match. Falls
204
+ back to os.getppid() if none found within depth 12.
205
+ """
206
+ pid = os.getppid()
207
+ for _ in range(12):
208
+ if pid <= 1:
209
+ break
210
+ cmd = ps_command(pid)
211
+ if not cmd:
212
+ break
213
+ if (
214
+ "link-edit-reddit.sh" in cmd
215
+ or "run-reddit-search.sh" in cmd
216
+ or "engage-reddit.sh" in cmd
217
+ or "engage-dm-replies-reddit.sh" in cmd
218
+ or "scan-reddit-replies" in cmd
219
+ or ("claude" in cmd and "--session-id" in cmd)
220
+ ):
221
+ return pid
222
+ pid = ps_ppid(pid)
223
+ return os.getppid()
224
+
225
+
226
+ def gc_stale_tickets(queue_dir: Path) -> None:
227
+ if not queue_dir.is_dir():
228
+ return
229
+ for ticket in queue_dir.iterdir():
230
+ try:
231
+ tpid = int(ticket.read_text().strip() or "0")
232
+ except Exception:
233
+ continue
234
+ if not pid_alive(tpid):
235
+ try:
236
+ ticket.unlink()
237
+ except Exception:
238
+ pass
239
+
240
+
241
+ def lock_is_stale(lock_dir: Path, pid_file: Path) -> tuple[bool, str]:
242
+ """Return (is_stale, reason) for a lock dir we found existing.
243
+
244
+ Checked in order:
245
+ 1. pid file missing / unparseable / zero
246
+ 2. holder PID is dead
247
+ 3. lease TTL expired (`now() > expires_at`) — primary stale signal
248
+ 4. lock dir mtime older than STALE_LOCK_AGE (final backstop, only
249
+ matters for legacy locks that never wrote `expires_at`)
250
+ """
251
+ if not pid_file.is_file():
252
+ return True, "no_pid_file"
253
+ try:
254
+ holder = int(pid_file.read_text().strip() or "0")
255
+ except Exception:
256
+ return True, "unparseable_pid"
257
+ if holder <= 0:
258
+ return True, "zero_pid"
259
+ if not pid_alive(holder):
260
+ return True, f"dead_holder_{holder}"
261
+ # Primary staleness signal: TTL expired (no heartbeat in TTL window).
262
+ expires_at = read_expires_at(lock_dir)
263
+ if expires_at is not None:
264
+ idle = time.time() - expires_at
265
+ if idle > 0:
266
+ return True, f"ttl_expired_idle_{int(idle)}s_holder_{holder}"
267
+ try:
268
+ age = time.time() - lock_dir.stat().st_mtime
269
+ if age > STALE_LOCK_AGE:
270
+ return True, f"age_{int(age)}s_>_{STALE_LOCK_AGE}s"
271
+ except FileNotFoundError:
272
+ return True, "lock_dir_vanished"
273
+ return False, ""
274
+
275
+
276
+ def remove_lock(lock_dir: Path) -> None:
277
+ try:
278
+ shutil.rmtree(lock_dir, ignore_errors=True)
279
+ except Exception:
280
+ pass
281
+
282
+
283
+ def sweep_orphan_browser_processes(name: str) -> None:
284
+ """Kill orphan Chrome / playwright-mcp processes reparented to PID 1.
285
+
286
+ Ported from skill/lock.sh:175-198 on 2026-05-10 as part of the migration
287
+ that consolidates reddit-browser locking onto a single TTL-aware system.
288
+
289
+ A prior holder may have exited without cleanly closing Chrome (parent
290
+ playwright-mcp died with SIGKILL/OOM, Chrome reparented to PID 1, profile
291
+ stays locked). Since we just acquired the exclusive lock, any Chrome on
292
+ this profile is an orphan and safe to kill before the caller launches
293
+ a fresh MCP session.
294
+
295
+ The ppid==1 filter is load-bearing: a live peer's Chromium is parented
296
+ to its mcp wrapper (alive). Without the guard, a peer that acquired
297
+ concurrently would SIGTERM the legitimate holder's Chrome and trigger
298
+ crashes like the GPU exit_code=15 we saw on 2026-04-28 14:12 PT.
299
+
300
+ Only fires for `*-browser` locks; no-op for pipeline locks.
301
+ """
302
+ if not name.endswith("-browser"):
303
+ return
304
+ platform = name[: -len("-browser")]
305
+ agent_marker = f"{platform}-agent.json"
306
+
307
+ def _udd_is_platform_profile(cmd_str: str) -> bool:
308
+ """True only when --user-data-dir points at the EXACT browser-profiles/<platform>
309
+ dir (or a subdir of it), NOT a sibling like browser-profiles/<platform>-harness.
310
+
311
+ The old test (`f"browser-profiles/{platform}" in cmd`) was a plain substring
312
+ match, so for platform="reddit" it also matched "browser-profiles/reddit-harness"
313
+ and swept the persistent reddit-harness Chrome (launched detached -> ppid=1) on
314
+ every single lock acquire. That is exactly what kept killing the harness mid-cycle
315
+ during the 2026-05-29 migration. Compare the first path component after
316
+ "browser-profiles/" against the platform name instead.
317
+ """
318
+ marker = "user-data-dir="
319
+ idx = cmd_str.find(marker)
320
+ if idx == -1:
321
+ return False
322
+ val = cmd_str[idx + len(marker):].split(" ", 1)[0].strip().strip('"').strip("'")
323
+ key = "browser-profiles/"
324
+ j = val.find(key)
325
+ if j == -1:
326
+ return False
327
+ seg = val[j + len(key):].split("/", 1)[0]
328
+ return seg == platform
329
+
330
+ try:
331
+ r = subprocess.run(
332
+ ["ps", "-A", "-o", "pid=,ppid=,command="],
333
+ capture_output=True, text=True, timeout=5,
334
+ )
335
+ except Exception:
336
+ return
337
+
338
+ chrome_pids: list[int] = []
339
+ mcp_pids: list[int] = []
340
+ for line in r.stdout.splitlines():
341
+ # ps output: " PID PPID command..."
342
+ parts = line.strip().split(None, 2)
343
+ if len(parts) < 3:
344
+ continue
345
+ pid_s, ppid_s, cmd = parts
346
+ if ppid_s != "1":
347
+ continue
348
+ if "user-data-dir=" in cmd and _udd_is_platform_profile(cmd):
349
+ try:
350
+ chrome_pids.append(int(pid_s))
351
+ except ValueError:
352
+ pass
353
+ elif agent_marker in cmd:
354
+ try:
355
+ mcp_pids.append(int(pid_s))
356
+ except ValueError:
357
+ pass
358
+
359
+ for pid in chrome_pids:
360
+ try:
361
+ os.kill(pid, 15) # SIGTERM
362
+ except (ProcessLookupError, PermissionError):
363
+ pass
364
+ except OSError:
365
+ pass
366
+ if chrome_pids:
367
+ print(
368
+ f"# swept orphan Chrome (ppid=1) holding {platform} profile: {chrome_pids}",
369
+ flush=True,
370
+ )
371
+ time.sleep(1)
372
+
373
+ for pid in mcp_pids:
374
+ try:
375
+ os.kill(pid, 15)
376
+ except (ProcessLookupError, PermissionError):
377
+ pass
378
+ except OSError:
379
+ pass
380
+ if mcp_pids:
381
+ print(
382
+ f"# swept orphan MCP wrappers (ppid=1) for {platform}-agent: {mcp_pids}",
383
+ flush=True,
384
+ )
385
+ time.sleep(1)
386
+
387
+
388
+ def cmd_acquire(name: str, timeout: int, ttl: int) -> int:
389
+ lock_dir, pid_file, queue_dir = lock_paths(name)
390
+ queue_dir.mkdir(parents=True, exist_ok=True)
391
+
392
+ owner_pid = find_owner_pid()
393
+ ticket_name = f"{time.time_ns()}-{os.getpid()}"
394
+ ticket_path = queue_dir / ticket_name
395
+ try:
396
+ ticket_path.write_text(f"{owner_pid}\n")
397
+ except Exception as e:
398
+ print(f"ERROR ticket_write_failed:{e}", flush=True)
399
+ return 2
400
+
401
+ waited = 0.0
402
+ try:
403
+ while True:
404
+ gc_stale_tickets(queue_dir)
405
+ tickets = sorted([t.name for t in queue_dir.iterdir() if t.is_file()])
406
+ head = tickets[0] if tickets else None
407
+ if head == ticket_name:
408
+ # Try to acquire by mkdir
409
+ try:
410
+ lock_dir.mkdir()
411
+ # Write expires_at FIRST, then pid_file. Order matters:
412
+ # peers reading a partially-initialized lock during the
413
+ # tiny window between the two writes will see an absent
414
+ # pid_file → `no_pid_file` → treated as stale → safe
415
+ # steal. They never see "valid pid + no TTL" which would
416
+ # mean "respect lock indefinitely".
417
+ write_expires_at(lock_dir, time.time() + ttl)
418
+ pid_file.write_text(f"{owner_pid}\n")
419
+ # Sweep orphan Chrome / MCP wrappers reparented to PID 1
420
+ # before the caller launches a fresh MCP session. Ported
421
+ # from lock.sh:175-198 (2026-05-10) so the bash and Python
422
+ # locks no longer diverge in housekeeping behavior.
423
+ sweep_orphan_browser_processes(name)
424
+ print(
425
+ f"OK owner_pid={owner_pid} waited={waited:.1f} ttl={ttl}",
426
+ flush=True,
427
+ )
428
+ return 0
429
+ except FileExistsError:
430
+ stale, reason = lock_is_stale(lock_dir, pid_file)
431
+ if stale:
432
+ print(f"# steal_stale_lock reason={reason}", flush=True)
433
+ remove_lock(lock_dir)
434
+ continue
435
+ if waited >= timeout:
436
+ # Identify holder for diagnostics
437
+ holder_pid = None
438
+ age = None
439
+ if pid_file.is_file():
440
+ try:
441
+ holder_pid = int(pid_file.read_text().strip() or "0")
442
+ except Exception:
443
+ holder_pid = None
444
+ if lock_dir.is_dir():
445
+ try:
446
+ age = int(time.time() - lock_dir.stat().st_mtime)
447
+ except FileNotFoundError:
448
+ age = None
449
+ print(
450
+ f"BUSY holder_pid={holder_pid if holder_pid else 'unknown'} "
451
+ f"age={age if age is not None else 'unknown'}s waited={waited:.1f}s",
452
+ flush=True,
453
+ )
454
+ return 1
455
+ time.sleep(POLL_INTERVAL)
456
+ waited += POLL_INTERVAL
457
+ finally:
458
+ # Clean up our ticket regardless of outcome
459
+ try:
460
+ ticket_path.unlink()
461
+ except FileNotFoundError:
462
+ pass
463
+ except Exception:
464
+ pass
465
+
466
+
467
+ def cmd_release(name: str) -> int:
468
+ lock_dir, pid_file, _ = lock_paths(name)
469
+ if not lock_dir.is_dir():
470
+ print("NOT_HELD", flush=True)
471
+ return 1
472
+ holder_pid = None
473
+ if pid_file.is_file():
474
+ try:
475
+ holder_pid = int(pid_file.read_text().strip() or "0")
476
+ except Exception:
477
+ holder_pid = None
478
+ expected_owner = find_owner_pid()
479
+ if holder_pid is not None and holder_pid != expected_owner and pid_alive(holder_pid):
480
+ # Don't release a lock we didn't acquire
481
+ print(f"HELD_BY_OTHER holder_pid={holder_pid}", flush=True)
482
+ return 1
483
+ remove_lock(lock_dir)
484
+ print("OK", flush=True)
485
+ return 0
486
+
487
+
488
+ def cmd_heartbeat(name: str, ttl: int) -> int:
489
+ """Bump the lease expiry. Called by the MCP wrapper on browser activity.
490
+
491
+ Design intent: the heartbeat IS the activity signal. If any reddit-agent
492
+ MCP browser call is happening anywhere on the box, the lease should stay
493
+ alive — independent of which process tree branch is firing the bump. So
494
+ we bump unconditionally as long as the lock dir exists.
495
+
496
+ Why no ownership check: the orchestrator's bash subprocess (which calls
497
+ `acquire`) and the MCP wrapper's heartbeat subprocess descend from
498
+ different parents, so a strict `holder_pid == find_owner_pid()` check
499
+ can falsely reject legit heartbeats in test environments and even in
500
+ edge cases in prod (e.g. when the launchd → script → claude chain
501
+ walks differently for a python subprocess vs. a bash subprocess).
502
+ The lock's correctness is enforced at acquire/release; heartbeat is
503
+ just a "yes, work is happening, don't expire me yet" pulse.
504
+
505
+ Worst case if a peer's wrapper accidentally bumps the holder's lease:
506
+ the holder keeps the lock 90s longer than strictly necessary. Bounded
507
+ by `--ttl`. The peer's `acquire` queue still proceeds in FIFO order
508
+ once activity ceases.
509
+ """
510
+ lock_dir, _pid_file, _ = lock_paths(name)
511
+ if not lock_dir.is_dir():
512
+ print("NOT_HELD", flush=True)
513
+ return 1
514
+ new_expires = time.time() + ttl
515
+ if not write_expires_at(lock_dir, new_expires):
516
+ print("NOT_HELD", flush=True)
517
+ return 1
518
+ print(f"OK expires_at={new_expires:.0f}", flush=True)
519
+ return 0
520
+
521
+
522
+ def cmd_status(name: str) -> int:
523
+ lock_dir, pid_file, queue_dir = lock_paths(name)
524
+ info = {
525
+ "name": name,
526
+ "held": False,
527
+ "holder_pid": None,
528
+ "age_sec": None,
529
+ "expires_at": None,
530
+ "ttl_remaining_sec": None,
531
+ "expired": False,
532
+ "queue": [],
533
+ }
534
+ if lock_dir.is_dir():
535
+ info["held"] = True
536
+ if pid_file.is_file():
537
+ try:
538
+ info["holder_pid"] = int(pid_file.read_text().strip() or "0")
539
+ except Exception:
540
+ info["holder_pid"] = None
541
+ try:
542
+ info["age_sec"] = int(time.time() - lock_dir.stat().st_mtime)
543
+ except FileNotFoundError:
544
+ pass
545
+ expires_at = read_expires_at(lock_dir)
546
+ if expires_at is not None:
547
+ info["expires_at"] = expires_at
548
+ remaining = expires_at - time.time()
549
+ info["ttl_remaining_sec"] = round(remaining, 1)
550
+ info["expired"] = remaining <= 0
551
+ if queue_dir.is_dir():
552
+ info["queue"] = sorted([t.name for t in queue_dir.iterdir() if t.is_file()])
553
+ print(json.dumps(info), flush=True)
554
+ return 0
555
+
556
+
557
+ def main() -> int:
558
+ p = argparse.ArgumentParser(description="Per-post browser lock helper for the reddit-agent profile.")
559
+ sub = p.add_subparsers(dest="cmd", required=True)
560
+
561
+ p_acq = sub.add_parser("acquire", help="Acquire the lock; blocks up to --timeout sec.")
562
+ p_acq.add_argument("--name", default=DEFAULT_NAME)
563
+ p_acq.add_argument("--timeout", type=int, default=DEFAULT_ACQUIRE_TIMEOUT,
564
+ help=f"Max seconds to wait (default {DEFAULT_ACQUIRE_TIMEOUT}).")
565
+ p_acq.add_argument("--ttl", type=int, default=DEFAULT_LEASE_TTL_SECONDS,
566
+ help=f"Initial lease TTL in seconds (default {DEFAULT_LEASE_TTL_SECONDS}). "
567
+ "MCP browser wrapper will heartbeat to keep this fresh during real activity.")
568
+
569
+ p_rel = sub.add_parser("release", help="Release the lock if held by us.")
570
+ p_rel.add_argument("--name", default=DEFAULT_NAME)
571
+
572
+ p_hb = sub.add_parser("heartbeat", help="Extend the lease (called by the MCP wrapper on browser activity).")
573
+ p_hb.add_argument("--name", default=DEFAULT_NAME)
574
+ p_hb.add_argument("--ttl", type=int, default=DEFAULT_LEASE_TTL_SECONDS,
575
+ help=f"Seconds to extend from now (default {DEFAULT_LEASE_TTL_SECONDS}).")
576
+
577
+ p_stat = sub.add_parser("status", help="Print JSON state of the lock.")
578
+ p_stat.add_argument("--name", default=DEFAULT_NAME)
579
+
580
+ args = p.parse_args()
581
+ if args.cmd == "acquire":
582
+ return cmd_acquire(args.name, args.timeout, args.ttl)
583
+ if args.cmd == "release":
584
+ return cmd_release(args.name)
585
+ if args.cmd == "heartbeat":
586
+ return cmd_heartbeat(args.name, args.ttl)
587
+ if args.cmd == "status":
588
+ return cmd_status(args.name)
589
+ return 2
590
+
591
+
592
+ if __name__ == "__main__":
593
+ sys.exit(main())