@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,1333 @@
1
+ #!/usr/bin/env python3
2
+ """LinkedIn pipeline killswitch.
3
+
4
+ Single source of truth for "LinkedIn is throttling / has revoked our
5
+ session; do not run anything that talks to LinkedIn until a human
6
+ re-auths and clears the flag".
7
+
8
+ State lives at ~/.claude/social-autoposter/linkedin.killswitch as JSON:
9
+
10
+ {
11
+ "signal": "http_999" | "authwall_redirect" | "throttle_no_pagination"
12
+ | "li_at_cleared" | "session_invalid_marker" | "manual",
13
+ "detail": "...",
14
+ "ts": "2026-05-27T21:20:58Z",
15
+ "run_log_path": "/Users/matthewdi/social-autoposter/skill/logs/...log",
16
+ "pid": 12345,
17
+ "user_to_resolve": "Re-auth LinkedIn in harness Chrome, then clear."
18
+ }
19
+
20
+ Every LinkedIn entrypoint (skill/*linkedin*.sh, engage-dm-replies.sh)
21
+ calls `check` at the top and exits 0 if active. `engage()` is
22
+ idempotent: the FIRST signal wins, later signals append to a
23
+ trail file so we can see what cascaded.
24
+
25
+ CLI:
26
+ python3 scripts/linkedin_killswitch.py check # exit 0 if clear, 1 if active
27
+ python3 scripts/linkedin_killswitch.py status # print payload (json), exit 0
28
+ python3 scripts/linkedin_killswitch.py engage \\
29
+ --signal http_999 \\
30
+ --detail "GET /in/me/recent-activity/comments/ -> 999" \\
31
+ --run-log /path/to/log
32
+ python3 scripts/linkedin_killswitch.py clear # remove the flag (human ack)
33
+
34
+ The shell pattern (after `set -euo pipefail`, before any work):
35
+
36
+ if ! /opt/homebrew/bin/python3 "$REPO_DIR/scripts/linkedin_killswitch.py" check >/dev/null 2>&1; then
37
+ log "LINKEDIN_KILLSWITCH active. To resume: re-auth LinkedIn in harness Chrome, then:"
38
+ log " python3 $REPO_DIR/scripts/linkedin_killswitch.py clear"
39
+ exit 0
40
+ fi
41
+
42
+ Email alert on engage: re-uses the same Gmail token strike_alert.py
43
+ uses. ONE email per engage call (idempotency in the file prevents
44
+ re-emailing on every cron tick). Subject prefix "[LI KILL]".
45
+ """
46
+
47
+ import argparse
48
+ import base64
49
+ import json
50
+ import os
51
+ import re
52
+ import sys
53
+ import time
54
+ from datetime import datetime, timedelta, timezone
55
+ from email.mime.text import MIMEText
56
+
57
+
58
+ # State paths are env-overridable so the auto-recovery job can be tested
59
+ # against a throwaway killswitch file without touching the live one.
60
+ STATE_DIR = os.path.expanduser(
61
+ os.environ.get("LINKEDIN_KILLSWITCH_DIR", "~/.claude/social-autoposter")
62
+ )
63
+ STATE_FILE = os.path.expanduser(
64
+ os.environ.get("LINKEDIN_KILLSWITCH_FILE", os.path.join(STATE_DIR, "linkedin.killswitch"))
65
+ )
66
+ TRAIL_FILE = os.path.expanduser(
67
+ os.environ.get(
68
+ "LINKEDIN_KILLSWITCH_TRAIL", os.path.join(STATE_DIR, "linkedin.killswitch.trail.jsonl")
69
+ )
70
+ )
71
+
72
+ GMAIL_TOKEN_PATH = os.path.expanduser("~/gmail-api/token_i_at_m13v.com.json")
73
+ GMAIL_SCOPES = ["https://mail.google.com/"]
74
+ NOTIFICATION_EMAIL = os.environ.get("NOTIFICATION_EMAIL", "i@m13v.com")
75
+
76
+ # Auto-recovery (2026-06-03): after the killswitch has been active this long,
77
+ # an hourly launchd job (skill/linkedin-recovery.sh) runs a gentle read-only
78
+ # probe of LinkedIn. If the session is healthy again, it clears the flag, which
79
+ # resumes every LinkedIn pipeline on its next fire (they all gate on this file).
80
+ # The wait protects the account: per the anti-bot rule we let the session sit
81
+ # idle ~24h after a 999/authwall before re-touching it, rather than hammering
82
+ # the login wall on every cron tick. Override for testing.
83
+ RECOVERY_MIN_AGE_HOURS = float(os.environ.get("LINKEDIN_RECOVERY_MIN_AGE_HOURS", "24"))
84
+ LINKEDIN_CDP_URL = os.environ.get("LINKEDIN_CDP_URL", "http://127.0.0.1:9556")
85
+
86
+ # Stop-completely policy (2026-06-03): after the 24h wait, the recovery job runs
87
+ # a read-only probe to see if the session healed on its own. We NEVER attempt a
88
+ # programmatic login (anti-bot rule). If the probe still shows logged-out after
89
+ # this many failed attempts, the session is genuinely dead: we mark the
90
+ # killswitch terminal so the hourly job stops probing entirely and a human must
91
+ # re-auth + clear. Default 1 == "wait 24h, try once, then stop completely".
92
+ RECOVERY_MAX_ATTEMPTS = int(os.environ.get("LINKEDIN_RECOVERY_MAX_ATTEMPTS", "1"))
93
+
94
+ # Claude-driven re-login (2026-06-03): the read-only probe above only detects a
95
+ # self-healed session. The active recovery path instead has the hourly job spin
96
+ # up a Claude session that drives the real harness Chrome (the allowed pattern;
97
+ # scripted Python login is the banned one) to actually log back in. That session
98
+ # returns one of four verdicts, recorded via `recover-record`:
99
+ # - held -> login succeeded; enter a "pending hold" window and
100
+ # re-verify (read-only) after RECOVERY_HOLD_CHECK_MINUTES
101
+ # that it STUCK. Only after a clean hold-check do we clear
102
+ # the flag + resume.
103
+ # - hard_block -> checkpoint / captcha / wrong creds / 2FA wall, or a
104
+ # restriction with NO stated lift time. Terminal
105
+ # immediately: do NOT poke a blocked account again.
106
+ # - restricted_temp -> a TEMPORARY restriction that states an explicit lift
107
+ # time (e.g. "restricted until June 03 2026 4:05 PM PDT").
108
+ # We don't go terminal: we dip until that time + a buffer,
109
+ # then make ONE more attempt, up to RECOVERY_RESTRICTED_MAX.
110
+ # The model passes the lift time as `lift=<ISO8601>` in the
111
+ # detail; if it is unparseable we dip a fixed fallback.
112
+ # - transient -> page didn't load / ambiguous. Re-anchor the 24h clock and
113
+ # let the next eligible cycle try again, up to
114
+ # RECOVERY_TRANSIENT_MAX.
115
+ # If a login held but the session drops during the hold window ("logged out
116
+ # shortly after"), the hold-check goes terminal too: try once, don't keep trying.
117
+ RECOVERY_HOLD_CHECK_MINUTES = float(
118
+ os.environ.get("LINKEDIN_RECOVERY_HOLD_CHECK_MINUTES", "45")
119
+ )
120
+ RECOVERY_TRANSIENT_MAX_ATTEMPTS = int(
121
+ os.environ.get("LINKEDIN_RECOVERY_TRANSIENT_MAX_ATTEMPTS", "3")
122
+ )
123
+ # Timed-dip retries for temporary restrictions before we give up and go terminal.
124
+ RECOVERY_RESTRICTED_MAX_ATTEMPTS = int(
125
+ os.environ.get("LINKEDIN_RECOVERY_RESTRICTED_MAX_ATTEMPTS", "3")
126
+ )
127
+ # Buffer added past the stated lift time before the retry fires (clock skew +
128
+ # LinkedIn not lifting exactly on the dot).
129
+ RECOVERY_RESTRICTED_BUFFER_MINUTES = float(
130
+ os.environ.get("LINKEDIN_RECOVERY_RESTRICTED_BUFFER_MINUTES", "30")
131
+ )
132
+ # Fallback dip when the model reports a temporary restriction but we cannot parse
133
+ # a lift time from the detail. We still avoid terminal, just wait a fixed window.
134
+ RECOVERY_RESTRICTED_FALLBACK_HOURS = float(
135
+ os.environ.get("LINKEDIN_RECOVERY_RESTRICTED_FALLBACK_HOURS", "24")
136
+ )
137
+
138
+ VALID_SIGNALS = {
139
+ "http_999",
140
+ "authwall_redirect",
141
+ "checkpoint_redirect",
142
+ "login_redirect",
143
+ "throttle_no_pagination",
144
+ "li_at_cleared",
145
+ "session_invalid_marker",
146
+ "captcha_detected",
147
+ "manual",
148
+ }
149
+
150
+
151
+ def _now_iso():
152
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
153
+
154
+
155
+ def _fmt_local(iso_or_dt):
156
+ """Render a UTC ISO string (or datetime) in the machine's local timezone for
157
+ human-readable emails, e.g. '2026-06-03 16:35 PDT'. Falls back to the raw
158
+ input on any parse failure so the email still goes out."""
159
+ try:
160
+ dt = iso_or_dt
161
+ if isinstance(dt, str):
162
+ dt = _parse_ts(dt)
163
+ if dt is None:
164
+ return str(iso_or_dt)
165
+ if dt.tzinfo is None:
166
+ dt = dt.replace(tzinfo=timezone.utc)
167
+ local = dt.astimezone()
168
+ return local.strftime("%Y-%m-%d %I:%M %p %Z").replace(" 0", " ")
169
+ except Exception:
170
+ return str(iso_or_dt)
171
+
172
+
173
+ def _ensure_dir():
174
+ os.makedirs(STATE_DIR, exist_ok=True)
175
+
176
+
177
+ def is_active():
178
+ return os.path.isfile(STATE_FILE)
179
+
180
+
181
+ def read():
182
+ if not is_active():
183
+ return None
184
+ try:
185
+ with open(STATE_FILE, "r") as f:
186
+ return json.load(f)
187
+ except Exception:
188
+ return {"signal": "unknown", "detail": "state file unreadable"}
189
+
190
+
191
+ def _parse_ts(ts):
192
+ """Parse an ISO Z timestamp like 2026-06-03T07:23:10Z. None on failure."""
193
+ try:
194
+ return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
195
+ except Exception:
196
+ return None
197
+
198
+
199
+ def age_seconds():
200
+ """Seconds since the killswitch engaged, or None if inactive/unparseable."""
201
+ p = read()
202
+ if not p:
203
+ return None
204
+ dt = _parse_ts(p.get("ts", ""))
205
+ if dt is None:
206
+ return None
207
+ return (datetime.now(timezone.utc) - dt).total_seconds()
208
+
209
+
210
+ def _append_trail(payload):
211
+ _ensure_dir()
212
+ try:
213
+ with open(TRAIL_FILE, "a") as f:
214
+ f.write(json.dumps(payload) + "\n")
215
+ except Exception:
216
+ pass
217
+
218
+
219
+ def _scrub_dashes(s):
220
+ if not s:
221
+ return s
222
+ return s.replace("\u2014", ",").replace("\u2013", ",")
223
+
224
+
225
+ def _send_alert_email(payload, first_time):
226
+ """Send an email alert. first_time=True only on the engaging call.
227
+
228
+ Best-effort: failure to send must not block engagement (the file
229
+ is the source of truth, not the email)."""
230
+ try:
231
+ from google.auth.transport.requests import Request
232
+ from google.oauth2.credentials import Credentials
233
+ from googleapiclient.discovery import build
234
+
235
+ if not os.path.isfile(GMAIL_TOKEN_PATH):
236
+ return False, "gmail token missing"
237
+
238
+ creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
239
+ if creds.expired and creds.refresh_token:
240
+ creds.refresh(Request())
241
+ with open(GMAIL_TOKEN_PATH, "w") as f:
242
+ f.write(creds.to_json())
243
+
244
+ service = build("gmail", "v1", credentials=creds, cache_discovery=False)
245
+
246
+ tag = "ENGAGED" if first_time else "REPEAT"
247
+ subject = "[LI KILL] {tag} signal={sig}".format(
248
+ tag=tag, sig=payload.get("signal", "?"),
249
+ )
250
+
251
+ body_lines = [
252
+ "LinkedIn killswitch " + tag.lower() + ".",
253
+ "",
254
+ "All LinkedIn pipelines on this machine will refuse to run until",
255
+ "a human re-authenticates LinkedIn in the harness Chrome and",
256
+ "clears the flag.",
257
+ "",
258
+ "Signal: " + str(payload.get("signal", "?")),
259
+ "Detail: " + str(payload.get("detail", "")),
260
+ "Timestamp: " + str(payload.get("ts", "")),
261
+ "PID: " + str(payload.get("pid", "")),
262
+ "Run log: " + str(payload.get("run_log_path", "")),
263
+ "",
264
+ "To resume:",
265
+ " 1. Open harness Chrome (linkedin profile) and sign back in.",
266
+ " 2. Confirm a normal /feed/ page renders without authwall.",
267
+ " 3. Clear the killswitch:",
268
+ " python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
269
+ "",
270
+ "State file: " + STATE_FILE,
271
+ "Trail file: " + TRAIL_FILE,
272
+ ]
273
+ body = _scrub_dashes("\n".join(body_lines))
274
+ subject = _scrub_dashes(subject)
275
+
276
+ msg = MIMEText(body, "plain", "utf-8")
277
+ msg["to"] = NOTIFICATION_EMAIL
278
+ msg["subject"] = subject
279
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
280
+ service.users().messages().send(userId="me", body={"raw": raw}).execute()
281
+ return True, "sent"
282
+ except Exception as exc:
283
+ return False, "send failed: " + str(exc)
284
+
285
+
286
+ def engage(signal, detail="", run_log_path="", extra=None, send_email=True):
287
+ """Engage the killswitch. Idempotent: first signal wins.
288
+
289
+ Returns the on-disk payload (either the existing one or the
290
+ newly-written one). Always appends to the trail so we can see
291
+ cascades."""
292
+ if signal not in VALID_SIGNALS:
293
+ signal = "manual"
294
+
295
+ payload_new = {
296
+ "signal": signal,
297
+ "detail": str(detail)[:2000],
298
+ "ts": _now_iso(),
299
+ "run_log_path": run_log_path,
300
+ "pid": os.getpid(),
301
+ "user_to_resolve": (
302
+ "Re-auth LinkedIn in harness Chrome, confirm /feed/ renders, "
303
+ "then run: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
304
+ ),
305
+ }
306
+ if isinstance(extra, dict):
307
+ for k, v in extra.items():
308
+ if k not in payload_new:
309
+ payload_new[k] = v
310
+
311
+ _ensure_dir()
312
+ _append_trail({"event": "engage_call", **payload_new})
313
+
314
+ first_time = not is_active()
315
+ if first_time:
316
+ tmp = STATE_FILE + ".tmp"
317
+ with open(tmp, "w") as f:
318
+ json.dump(payload_new, f, indent=2)
319
+ f.write("\n")
320
+ os.replace(tmp, STATE_FILE)
321
+ on_disk = payload_new
322
+ else:
323
+ on_disk = read() or payload_new
324
+
325
+ if send_email:
326
+ ok, msg = _send_alert_email(payload_new, first_time)
327
+ _append_trail({"event": "email_attempt", "ok": ok, "msg": msg, "first_time": first_time})
328
+
329
+ return on_disk
330
+
331
+
332
+ _LOGIN_MARKERS = ("/login", "/checkpoint", "/uas/login", "linkedin.com/authwall")
333
+
334
+
335
+ def _probe_linkedin_health(cdp_url, feed_only=False):
336
+ """Gentle, read-only health probe of the LinkedIn session.
337
+
338
+ Attaches (CDP) to the already-running linkedin-harness Chrome and does the
339
+ minimal nav set the anti-bot carve-out allows: ONE nav to /feed/ (confirms
340
+ we are logged in) and, unless feed_only, ONE nav to the exact
341
+ /in/me/recent-activity/comments/ endpoint that trips the killswitch (confirms
342
+ it no longer bounces to the authwall). No Voyager calls, no scroll loops, no
343
+ permalink fan-out, no clicks/typing, no programmatic login. Reuses an
344
+ existing tab and never closes the shared context.
345
+
346
+ feed_only=True is the per-run detection gate: a single /feed/ nav is enough
347
+ to tell "are we still logged in?" without touching the activity endpoint on
348
+ every healthy pipeline fire.
349
+
350
+ Returns (healthy: bool, detail: str, conclusive: bool). Never raises.
351
+ conclusive=True means we definitively observed login state (healthy feed, or
352
+ a redirect to the authwall/login/checkpoint). conclusive=False means we
353
+ could not determine it (CDP attach failed, nav timeout, Chrome down): an
354
+ infra hiccup, NOT evidence the session is dead, so callers must not engage
355
+ the killswitch or count it as a failed re-login attempt on this.
356
+ """
357
+ try:
358
+ from playwright.sync_api import sync_playwright
359
+ except Exception as e:
360
+ return False, "playwright import failed: {}".format(e), False
361
+
362
+ try:
363
+ with sync_playwright() as p:
364
+ try:
365
+ browser = p.chromium.connect_over_cdp(cdp_url, timeout=8000)
366
+ except Exception as e:
367
+ return False, "cdp attach failed ({}): {}".format(cdp_url, e), False
368
+ contexts = browser.contexts
369
+ if not contexts:
370
+ return False, "cdp attach: zero contexts", False
371
+ ctx = contexts[0]
372
+
373
+ page = None
374
+ reused = False
375
+ for pg in ctx.pages:
376
+ u = pg.url or ""
377
+ if "linkedin.com" in u and "login" not in u and "checkpoint" not in u:
378
+ page, reused = pg, True
379
+ break
380
+ if page is None and ctx.pages:
381
+ page, reused = ctx.pages[0], True
382
+ if page is None:
383
+ page = ctx.new_page()
384
+
385
+ try:
386
+ # Nav 1: /feed/ — are we still logged in?
387
+ page.goto(
388
+ "https://www.linkedin.com/feed/",
389
+ wait_until="domcontentloaded",
390
+ timeout=30000,
391
+ )
392
+ page.wait_for_timeout(2000)
393
+ u1 = page.url or ""
394
+ if any(m in u1 for m in _LOGIN_MARKERS):
395
+ return False, "feed redirected to auth: {}".format(u1), True
396
+
397
+ if feed_only:
398
+ title = ""
399
+ try:
400
+ title = page.title() or ""
401
+ except Exception:
402
+ pass
403
+ return True, "feed renders (title={!r}, url={})".format(title, u1), True
404
+
405
+ # Nav 2: the exact endpoint that engaged the killswitch.
406
+ page.goto(
407
+ "https://www.linkedin.com/in/me/recent-activity/comments/",
408
+ wait_until="domcontentloaded",
409
+ timeout=30000,
410
+ )
411
+ page.wait_for_timeout(2000)
412
+ u2 = page.url or ""
413
+ if any(m in u2 for m in _LOGIN_MARKERS):
414
+ return False, "activity endpoint redirected to auth: {}".format(u2), True
415
+
416
+ title = ""
417
+ try:
418
+ title = page.title() or ""
419
+ except Exception:
420
+ pass
421
+ return True, "feed+activity render (title={!r}, url={})".format(title, u2), True
422
+ finally:
423
+ if page is not None and not reused:
424
+ try:
425
+ page.close()
426
+ except Exception:
427
+ pass
428
+ except Exception as e:
429
+ return False, "probe exception: {}: {}".format(type(e).__name__, e), False
430
+
431
+
432
+ def _send_recovery_email(detail, age_sec):
433
+ """Notify that the killswitch auto-cleared after a healthy probe."""
434
+ try:
435
+ from google.auth.transport.requests import Request
436
+ from google.oauth2.credentials import Credentials
437
+ from googleapiclient.discovery import build
438
+
439
+ if not os.path.isfile(GMAIL_TOKEN_PATH):
440
+ return False, "gmail token missing"
441
+
442
+ creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
443
+ if creds.expired and creds.refresh_token:
444
+ creds.refresh(Request())
445
+ with open(GMAIL_TOKEN_PATH, "w") as f:
446
+ f.write(creds.to_json())
447
+
448
+ service = build("gmail", "v1", credentials=creds, cache_discovery=False)
449
+ age_h = round(age_sec / 3600.0, 1) if age_sec else "?"
450
+ subject = "[LI KILL] RECOVERED auto-probe healthy"
451
+ body_lines = [
452
+ "LinkedIn killswitch auto-cleared.",
453
+ "",
454
+ "The hourly recovery probe found the session healthy after the",
455
+ "killswitch had been active for " + str(age_h) + "h, so it cleared",
456
+ "the flag. Every LinkedIn pipeline resumes on its next launchd fire.",
457
+ "",
458
+ "Probe detail: " + str(detail),
459
+ "",
460
+ "If LinkedIn was NOT actually healthy, re-engage manually:",
461
+ " python3 ~/social-autoposter/scripts/linkedin_killswitch.py \\",
462
+ " engage --signal manual --detail 'auto-recovery false positive'",
463
+ "",
464
+ "State file: " + STATE_FILE,
465
+ "Trail file: " + TRAIL_FILE,
466
+ ]
467
+ body = _scrub_dashes("\n".join(body_lines))
468
+ msg = MIMEText(body, "plain", "utf-8")
469
+ msg["to"] = NOTIFICATION_EMAIL
470
+ msg["subject"] = _scrub_dashes(subject)
471
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
472
+ service.users().messages().send(userId="me", body={"raw": raw}).execute()
473
+ return True, "sent"
474
+ except Exception as exc:
475
+ return False, "send failed: " + str(exc)
476
+
477
+
478
+ def is_terminal():
479
+ """True if auto-recovery has given up (failed re-login after the 24h wait)
480
+ and a human must re-auth + clear. Once terminal, the hourly recovery job
481
+ stops probing entirely."""
482
+ p = read()
483
+ return bool(p and p.get("recovery_terminal"))
484
+
485
+
486
+ def _record_failed_recovery(detail):
487
+ """A read-only recovery probe conclusively showed still-logged-out after the
488
+ 24h wait. Increment the attempt counter on the live state file (preserving
489
+ the original ts so age keeps accruing) and, once attempts reach
490
+ RECOVERY_MAX_ATTEMPTS, flip recovery_terminal so we stop completely.
491
+
492
+ Returns (attempts: int, terminal: bool)."""
493
+ p = read() or {}
494
+ attempts = int(p.get("recovery_attempts", 0)) + 1
495
+ p["recovery_attempts"] = attempts
496
+ p["last_recovery_ts"] = _now_iso()
497
+ p["last_recovery_detail"] = str(detail)[:2000]
498
+ terminal = attempts >= RECOVERY_MAX_ATTEMPTS
499
+ if terminal:
500
+ p["recovery_terminal"] = True
501
+ p["recovery_terminal_ts"] = _now_iso()
502
+ _ensure_dir()
503
+ tmp = STATE_FILE + ".tmp"
504
+ with open(tmp, "w") as f:
505
+ json.dump(p, f, indent=2)
506
+ f.write("\n")
507
+ os.replace(tmp, STATE_FILE)
508
+ _append_trail({
509
+ "event": "recovery_failed",
510
+ "ts": _now_iso(),
511
+ "attempts": attempts,
512
+ "terminal": terminal,
513
+ "detail": str(detail)[:500],
514
+ })
515
+ return attempts, terminal
516
+
517
+
518
+ def _send_terminal_email(detail, attempts, age_sec):
519
+ """Notify that auto-recovery gave up; manual re-auth required."""
520
+ try:
521
+ from google.auth.transport.requests import Request
522
+ from google.oauth2.credentials import Credentials
523
+ from googleapiclient.discovery import build
524
+
525
+ if not os.path.isfile(GMAIL_TOKEN_PATH):
526
+ return False, "gmail token missing"
527
+
528
+ creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
529
+ if creds.expired and creds.refresh_token:
530
+ creds.refresh(Request())
531
+ with open(GMAIL_TOKEN_PATH, "w") as f:
532
+ f.write(creds.to_json())
533
+
534
+ service = build("gmail", "v1", credentials=creds, cache_discovery=False)
535
+ age_h = round(age_sec / 3600.0, 1) if age_sec else "?"
536
+ subject = "[LI KILL] AUTO-RECOVERY FAILED, manual re-auth required"
537
+ body_lines = [
538
+ "LinkedIn auto-recovery has STOPPED COMPLETELY.",
539
+ "",
540
+ "After the " + str(RECOVERY_MIN_AGE_HOURS) + "h wait, the read-only probe",
541
+ "ran " + str(attempts) + " attempt(s) and the session was still logged out",
542
+ "(redirected to the authwall/login). Per the anti-bot rule we never",
543
+ "log in programmatically, so the hourly recovery job will now stop",
544
+ "probing and every LinkedIn pipeline stays paused until you act.",
545
+ "",
546
+ "Killswitch age at give-up: " + str(age_h) + "h",
547
+ "Last probe detail: " + str(detail),
548
+ "",
549
+ "To resume:",
550
+ " 1. Open the linkedin-harness Chrome (port 9556) and sign back in.",
551
+ " 2. Confirm /feed/ renders without an authwall.",
552
+ " 3. Clear the killswitch:",
553
+ " python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
554
+ "",
555
+ "State file: " + STATE_FILE,
556
+ "Trail file: " + TRAIL_FILE,
557
+ ]
558
+ body = _scrub_dashes("\n".join(body_lines))
559
+ msg = MIMEText(body, "plain", "utf-8")
560
+ msg["to"] = NOTIFICATION_EMAIL
561
+ msg["subject"] = _scrub_dashes(subject)
562
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
563
+ service.users().messages().send(userId="me", body={"raw": raw}).execute()
564
+ return True, "sent"
565
+ except Exception as exc:
566
+ return False, "send failed: " + str(exc)
567
+
568
+
569
+ def _write_state(p):
570
+ """Atomically persist the killswitch state dict."""
571
+ _ensure_dir()
572
+ tmp = STATE_FILE + ".tmp"
573
+ with open(tmp, "w") as f:
574
+ json.dump(p, f, indent=2)
575
+ f.write("\n")
576
+ os.replace(tmp, STATE_FILE)
577
+
578
+
579
+ def _record_login_held():
580
+ """Claude reported a successful re-login. Don't clear the flag yet: enter a
581
+ pending-hold window and re-verify (read-only) after RECOVERY_HOLD_CHECK_MINUTES
582
+ that the session actually stuck. Returns the hold_check_due ISO ts."""
583
+ p = read() or {}
584
+ due = datetime.now(timezone.utc) + timedelta(minutes=RECOVERY_HOLD_CHECK_MINUTES)
585
+ due_iso = due.strftime("%Y-%m-%dT%H:%M:%SZ")
586
+ p["recovery_pending_hold"] = True
587
+ p["hold_check_due"] = due_iso
588
+ p["login_held_ts"] = _now_iso()
589
+ p["recovery_transient_attempts"] = 0
590
+ p["recovery_restricted_attempts"] = 0
591
+ p.pop("recovery_restricted_until", None)
592
+ p.pop("recovery_terminal", None)
593
+ _write_state(p)
594
+ _append_trail({
595
+ "event": "login_held",
596
+ "ts": _now_iso(),
597
+ "hold_check_due": due_iso,
598
+ })
599
+ return due_iso
600
+
601
+
602
+ def _record_hard_block(detail):
603
+ """Claude hit a wall it cannot pass (checkpoint / captcha / restriction /
604
+ wrong creds / 2FA). Go terminal: never auto-poke a restricted account again."""
605
+ p = read() or {}
606
+ p["recovery_terminal"] = True
607
+ p["recovery_terminal_ts"] = _now_iso()
608
+ p["recovery_terminal_reason"] = "hard_block"
609
+ p["last_recovery_detail"] = str(detail)[:2000]
610
+ p.pop("recovery_pending_hold", None)
611
+ p.pop("hold_check_due", None)
612
+ p.pop("recovery_restricted_until", None)
613
+ _write_state(p)
614
+ _append_trail({
615
+ "event": "recovery_hard_block",
616
+ "ts": _now_iso(),
617
+ "detail": str(detail)[:500],
618
+ })
619
+
620
+
621
+ def _record_transient(detail):
622
+ """Claude couldn't conclusively log in or fail (page didn't load, ambiguous).
623
+ Re-anchor the 24h clock so the next eligible cycle tries again, up to
624
+ RECOVERY_TRANSIENT_MAX_ATTEMPTS, after which we go terminal.
625
+ Returns (transient_attempts: int, terminal: bool)."""
626
+ p = read() or {}
627
+ attempts = int(p.get("recovery_transient_attempts", 0)) + 1
628
+ p["recovery_transient_attempts"] = attempts
629
+ p["last_recovery_ts"] = _now_iso()
630
+ p["last_recovery_detail"] = str(detail)[:2000]
631
+ terminal = attempts >= RECOVERY_TRANSIENT_MAX_ATTEMPTS
632
+ if terminal:
633
+ p["recovery_terminal"] = True
634
+ p["recovery_terminal_ts"] = _now_iso()
635
+ p["recovery_terminal_reason"] = "transient_exhausted"
636
+ else:
637
+ # Re-anchor age so we wait another full RECOVERY_MIN_AGE_HOURS before the
638
+ # next attempt rather than retrying on the next hourly tick.
639
+ p["ts"] = _now_iso()
640
+ p.pop("recovery_pending_hold", None)
641
+ p.pop("hold_check_due", None)
642
+ p.pop("recovery_restricted_until", None)
643
+ _write_state(p)
644
+ _append_trail({
645
+ "event": "recovery_transient",
646
+ "ts": _now_iso(),
647
+ "transient_attempts": attempts,
648
+ "terminal": terminal,
649
+ "detail": str(detail)[:500],
650
+ })
651
+ return attempts, terminal
652
+
653
+
654
+ def _parse_lift_time(detail):
655
+ """Extract a restriction lift time from the verdict detail. The model is asked
656
+ to embed it as `lift=<ISO8601>` (with a timezone offset or trailing Z), e.g.
657
+ `lift=2026-06-03T16:05:00-07:00`. Returns a tz-aware UTC datetime, or None if
658
+ absent/unparseable (caller falls back to a fixed dip)."""
659
+ if not detail:
660
+ return None
661
+ m = re.search(r"lift=([0-9T:\-\+\.Zz]+)", str(detail))
662
+ if not m:
663
+ return None
664
+ raw = m.group(1).strip()
665
+ # Try the strict Z form first, then full ISO (handles +/-HH:MM offsets).
666
+ dt = _parse_ts(raw)
667
+ if dt is None:
668
+ try:
669
+ dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
670
+ except Exception:
671
+ return None
672
+ if dt.tzinfo is None:
673
+ dt = dt.replace(tzinfo=timezone.utc)
674
+ return dt.astimezone(timezone.utc)
675
+
676
+
677
+ def _record_restricted_temp(detail):
678
+ """Claude hit a TEMPORARY restriction that states an explicit lift time. Don't
679
+ go terminal: dip until that time + a buffer, then allow ONE more attempt, up to
680
+ RECOVERY_RESTRICTED_MAX_ATTEMPTS (after which we do go terminal). Returns
681
+ (restricted_attempts: int, terminal: bool, retry_at_iso: str)."""
682
+ p = read() or {}
683
+ attempts = int(p.get("recovery_restricted_attempts", 0)) + 1
684
+ p["recovery_restricted_attempts"] = attempts
685
+ p["last_recovery_ts"] = _now_iso()
686
+ p["last_recovery_detail"] = str(detail)[:2000]
687
+
688
+ terminal = attempts >= RECOVERY_RESTRICTED_MAX_ATTEMPTS
689
+ retry_at_iso = ""
690
+ if terminal:
691
+ p["recovery_terminal"] = True
692
+ p["recovery_terminal_ts"] = _now_iso()
693
+ p["recovery_terminal_reason"] = "restricted_exhausted"
694
+ p.pop("recovery_restricted_until", None)
695
+ else:
696
+ lift = _parse_lift_time(detail)
697
+ if lift is not None:
698
+ retry_at = lift + timedelta(minutes=RECOVERY_RESTRICTED_BUFFER_MINUTES)
699
+ # Never schedule the retry in the past (stated lift already elapsed):
700
+ # give it at least the buffer from now so we don't immediately re-poke.
701
+ floor = datetime.now(timezone.utc) + timedelta(
702
+ minutes=RECOVERY_RESTRICTED_BUFFER_MINUTES
703
+ )
704
+ if retry_at < floor:
705
+ retry_at = floor
706
+ else:
707
+ retry_at = datetime.now(timezone.utc) + timedelta(
708
+ hours=RECOVERY_RESTRICTED_FALLBACK_HOURS
709
+ )
710
+ retry_at_iso = retry_at.strftime("%Y-%m-%dT%H:%M:%SZ")
711
+ p["recovery_restricted_until"] = retry_at_iso
712
+ p.pop("recovery_pending_hold", None)
713
+ p.pop("hold_check_due", None)
714
+ _write_state(p)
715
+ _append_trail({
716
+ "event": "recovery_restricted_temp",
717
+ "ts": _now_iso(),
718
+ "restricted_attempts": attempts,
719
+ "terminal": terminal,
720
+ "retry_at": retry_at_iso,
721
+ "detail": str(detail)[:500],
722
+ })
723
+ return attempts, terminal, retry_at_iso
724
+
725
+
726
+ def _restricted_until_seconds():
727
+ """Seconds until (negative) / since (positive) the restricted-dip retry is due.
728
+ None if no restricted-dip window is set or its ts is unparseable."""
729
+ p = read()
730
+ if not p:
731
+ return None
732
+ until_iso = p.get("recovery_restricted_until")
733
+ if not until_iso:
734
+ return None
735
+ until = _parse_ts(until_iso)
736
+ if until is None:
737
+ return None
738
+ return (datetime.now(timezone.utc) - until).total_seconds()
739
+
740
+
741
+ def _send_simple_email(subject, body_lines):
742
+ """Best-effort plain-text alert to NOTIFICATION_EMAIL. Never raises."""
743
+ try:
744
+ from google.auth.transport.requests import Request
745
+ from google.oauth2.credentials import Credentials
746
+ from googleapiclient.discovery import build
747
+
748
+ if not os.path.isfile(GMAIL_TOKEN_PATH):
749
+ return False, "gmail token missing"
750
+ creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
751
+ if creds.expired and creds.refresh_token:
752
+ creds.refresh(Request())
753
+ with open(GMAIL_TOKEN_PATH, "w") as f:
754
+ f.write(creds.to_json())
755
+ service = build("gmail", "v1", credentials=creds, cache_discovery=False)
756
+ body = _scrub_dashes("\n".join(body_lines))
757
+ msg = MIMEText(body, "plain", "utf-8")
758
+ msg["to"] = NOTIFICATION_EMAIL
759
+ msg["subject"] = _scrub_dashes(subject)
760
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
761
+ service.users().messages().send(userId="me", body={"raw": raw}).execute()
762
+ return True, "sent"
763
+ except Exception as exc:
764
+ return False, "send failed: " + str(exc)
765
+
766
+
767
+ def clear():
768
+ """Human ack: remove the flag. Trail row records who cleared it."""
769
+ if not is_active():
770
+ return False
771
+ try:
772
+ os.remove(STATE_FILE)
773
+ except FileNotFoundError:
774
+ pass
775
+ _append_trail({
776
+ "event": "clear",
777
+ "ts": _now_iso(),
778
+ "pid": os.getpid(),
779
+ "cleared_by": os.environ.get("USER", "?"),
780
+ })
781
+ return True
782
+
783
+
784
+ def _cmd_check(args):
785
+ if is_active():
786
+ sys.exit(1)
787
+ sys.exit(0)
788
+
789
+
790
+ def _cmd_status(args):
791
+ p = read()
792
+ if p is None:
793
+ print(json.dumps({"active": False}))
794
+ sys.exit(0)
795
+ out = {"active": True, **p}
796
+ print(json.dumps(out, indent=2))
797
+ sys.exit(0)
798
+
799
+
800
+ def _cmd_engage(args):
801
+ extra = {}
802
+ if args.extra:
803
+ try:
804
+ extra = json.loads(args.extra)
805
+ except Exception:
806
+ extra = {"raw_extra": args.extra}
807
+ p = engage(
808
+ signal=args.signal,
809
+ detail=args.detail or "",
810
+ run_log_path=args.run_log or "",
811
+ extra=extra,
812
+ send_email=not args.no_email,
813
+ )
814
+ print(json.dumps(p, indent=2))
815
+ sys.exit(0)
816
+
817
+
818
+ def _cmd_clear(args):
819
+ ok = clear()
820
+ print(json.dumps({"cleared": ok}))
821
+ sys.exit(0)
822
+
823
+
824
+ def _cmd_detect_gate(args):
825
+ """Per-run logout detector, called by ensure_linkedin_browser_for_backend so
826
+ ANY LinkedIn pipeline trips the killswitch on its natural next fire.
827
+
828
+ - If the killswitch is already active: no-op, exit 0 (the file gate / hourly
829
+ recovery already own the situation; don't double-probe).
830
+ - Otherwise run a single read-only /feed/ probe. If it CONCLUSIVELY shows
831
+ logged-out (redirect to authwall/login/checkpoint), engage the killswitch
832
+ (signal login_redirect) and exit 2 so the caller can abort this fire. The
833
+ flag pauses every other pipeline on its next fire and starts the 24h
834
+ recovery clock. Healthy or inconclusive (infra) -> exit 0, proceed."""
835
+ if is_active():
836
+ # Already flagged: nothing to detect. Stay silent + cheap.
837
+ sys.exit(0)
838
+ cdp_url = args.cdp_url or LINKEDIN_CDP_URL
839
+ healthy, detail, conclusive = _probe_linkedin_health(cdp_url, feed_only=True)
840
+ _append_trail({
841
+ "event": "detect_gate",
842
+ "ts": _now_iso(),
843
+ "healthy": healthy,
844
+ "conclusive": conclusive,
845
+ "detail": detail,
846
+ })
847
+ if healthy:
848
+ print("detect-gate: session healthy ({})".format(detail), file=sys.stderr)
849
+ sys.exit(0)
850
+ if not conclusive:
851
+ # Couldn't determine (CDP down, nav timeout). Don't engage on infra
852
+ # noise; let the pipeline's own SESSION_INVALID handling deal with it.
853
+ print("detect-gate: inconclusive ({}), proceeding".format(detail), file=sys.stderr)
854
+ sys.exit(0)
855
+ # Conclusively logged out. Trip the killswitch for the whole fleet.
856
+ run_log_path = os.environ.get("S4L_RUN_LOG_PATH", "")
857
+ engage(
858
+ signal="login_redirect",
859
+ detail="detect-gate: {}".format(detail),
860
+ run_log_path=run_log_path,
861
+ extra={"detected_by": os.environ.get("S4L_PIPELINE_NAME", "?"), "probe": "feed_only"},
862
+ send_email=not args.no_email,
863
+ )
864
+ print(
865
+ "detect-gate: LOGGED OUT, killswitch ENGAGED ({}); aborting this fire".format(detail),
866
+ file=sys.stderr,
867
+ )
868
+ sys.exit(2)
869
+
870
+
871
+ def _hold_check_due_seconds():
872
+ """Seconds until (negative) / since (positive) the pending-hold re-verify is
873
+ due. None if not in a pending-hold window or the ts is unparseable."""
874
+ p = read()
875
+ if not p or not p.get("recovery_pending_hold"):
876
+ return None
877
+ due = _parse_ts(p.get("hold_check_due", ""))
878
+ if due is None:
879
+ return None
880
+ return (datetime.now(timezone.utc) - due).total_seconds()
881
+
882
+
883
+ def _cmd_recover_check(args):
884
+ """Gate for the hourly recovery job. Exits 0 when there is work to do and
885
+ prints the MODE on stdout so the shell knows which path to drive:
886
+
887
+ "login" -> killswitch active >= RECOVERY_MIN_AGE_HOURS and not mid-hold:
888
+ spin up the Claude re-login session, then `recover-record`.
889
+ "hold" -> a prior login succeeded and the hold window has elapsed: run the
890
+ read-only `recover-hold` re-verify (no Claude, no login).
891
+
892
+ Exits 1 (no stdout) when there is nothing to do this hour (inactive,
893
+ terminal, too young, or still inside an unelapsed hold window)."""
894
+ if not is_active():
895
+ print("recover-check: killswitch not active, nothing to recover", file=sys.stderr)
896
+ sys.exit(1)
897
+ if is_terminal():
898
+ print(
899
+ "recover-check: TERMINAL (auto-recovery gave up); "
900
+ "manual re-auth + clear required, not probing",
901
+ file=sys.stderr,
902
+ )
903
+ sys.exit(1)
904
+
905
+ # Pending-hold takes priority: a login already succeeded; we are only waiting
906
+ # to confirm it stuck. No new login attempt while in this window.
907
+ hold_age = _hold_check_due_seconds()
908
+ if hold_age is not None:
909
+ if hold_age >= 0:
910
+ print(
911
+ "recover-check: hold-check due ({:.0f}m past due), re-verifying".format(
912
+ hold_age / 60.0
913
+ ),
914
+ file=sys.stderr,
915
+ )
916
+ print("hold")
917
+ sys.exit(0)
918
+ print(
919
+ "recover-check: login holding, hold-check in {:.0f}m".format(
920
+ -hold_age / 60.0
921
+ ),
922
+ file=sys.stderr,
923
+ )
924
+ sys.exit(1)
925
+
926
+ # Temporary-restriction dip: a prior attempt hit a restriction with a stated
927
+ # lift time, so we are waiting for that time (+ buffer) rather than the flat
928
+ # 24h gate. This window takes precedence over the age gate: once the lift time
929
+ # passes we allow ONE more login attempt regardless of flag age.
930
+ restr_age = _restricted_until_seconds()
931
+ if restr_age is not None:
932
+ if restr_age >= 0:
933
+ print(
934
+ "recover-check: restriction lift passed ({:.0f}m ago), retrying login".format(
935
+ restr_age / 60.0
936
+ ),
937
+ file=sys.stderr,
938
+ )
939
+ print("login")
940
+ sys.exit(0)
941
+ print(
942
+ "recover-check: temporarily restricted, retry in {:.0f}m".format(
943
+ -restr_age / 60.0
944
+ ),
945
+ file=sys.stderr,
946
+ )
947
+ sys.exit(1)
948
+
949
+ age = age_seconds()
950
+ min_age = RECOVERY_MIN_AGE_HOURS * 3600
951
+ if age is None:
952
+ print(
953
+ "recover-check: active but ts unparseable, manual clear required",
954
+ file=sys.stderr,
955
+ )
956
+ sys.exit(1)
957
+ if age < min_age:
958
+ print(
959
+ "recover-check: active but only {:.1f}h old (< {}h), waiting".format(
960
+ age / 3600.0, RECOVERY_MIN_AGE_HOURS
961
+ ),
962
+ file=sys.stderr,
963
+ )
964
+ sys.exit(1)
965
+ print(
966
+ "recover-check: eligible for re-login (active {:.1f}h >= {}h)".format(
967
+ age / 3600.0, RECOVERY_MIN_AGE_HOURS
968
+ ),
969
+ file=sys.stderr,
970
+ )
971
+ print("login")
972
+ sys.exit(0)
973
+
974
+
975
+ def _cmd_recover(args):
976
+ """Run the gentle probe (Chrome must already be up); clear + email on health.
977
+
978
+ Re-checks the age gate itself (unless --force) so it is safe to call
979
+ directly, not just behind recover-check."""
980
+ if not is_active():
981
+ print(json.dumps({"recovered": False, "reason": "not_active"}))
982
+ sys.exit(0)
983
+ if is_terminal():
984
+ print(json.dumps({"recovered": False, "reason": "terminal_manual_required"}))
985
+ sys.exit(0)
986
+ age = age_seconds()
987
+ min_age = RECOVERY_MIN_AGE_HOURS * 3600
988
+ if not args.force and (age is None or age < min_age):
989
+ print(json.dumps({
990
+ "recovered": False,
991
+ "reason": "too_young",
992
+ "age_hours": (round(age / 3600.0, 2) if age else None),
993
+ }))
994
+ sys.exit(0)
995
+
996
+ cdp_url = args.cdp_url or LINKEDIN_CDP_URL
997
+ healthy, detail, conclusive = _probe_linkedin_health(cdp_url)
998
+ _append_trail({
999
+ "event": "recover_probe",
1000
+ "ts": _now_iso(),
1001
+ "healthy": healthy,
1002
+ "conclusive": conclusive,
1003
+ "detail": detail,
1004
+ "age_hours": (round(age / 3600.0, 2) if age else None),
1005
+ })
1006
+ if not healthy:
1007
+ # Inconclusive (CDP down, nav timeout): infra hiccup, not a dead
1008
+ # session. Do NOT count it as a failed re-login; just retry next hour.
1009
+ if not conclusive:
1010
+ print(json.dumps({
1011
+ "recovered": False,
1012
+ "reason": "probe_inconclusive",
1013
+ "detail": detail,
1014
+ }))
1015
+ sys.exit(0)
1016
+ # Conclusively still logged out after the 24h wait. Record the failed
1017
+ # attempt; once we hit RECOVERY_MAX_ATTEMPTS we stop completely.
1018
+ attempts, terminal = _record_failed_recovery(detail)
1019
+ if terminal and not args.no_email:
1020
+ ok, msg = _send_terminal_email(detail, attempts, age)
1021
+ _append_trail({"event": "terminal_email", "ok": ok, "msg": msg})
1022
+ print(json.dumps({
1023
+ "recovered": False,
1024
+ "reason": ("recovery_terminal" if terminal else "relogin_failed_retrying"),
1025
+ "attempts": attempts,
1026
+ "terminal": terminal,
1027
+ "detail": detail,
1028
+ }))
1029
+ sys.exit(0)
1030
+
1031
+ clear()
1032
+ _append_trail({"event": "recover_clear", "ts": _now_iso(), "detail": detail})
1033
+ if not args.no_email:
1034
+ ok, msg = _send_recovery_email(detail, age)
1035
+ _append_trail({"event": "recover_email", "ok": ok, "msg": msg})
1036
+ print(json.dumps({"recovered": True, "detail": detail}))
1037
+ sys.exit(0)
1038
+
1039
+
1040
+ def _cmd_recover_record(args):
1041
+ """Record the verdict of a Claude-driven re-login attempt and apply the
1042
+ matching state transition. Called by skill/linkedin-recovery.sh after it runs
1043
+ the login session. Verdicts:
1044
+
1045
+ held -> enter the pending-hold window; do NOT resume yet.
1046
+ hard_block -> terminal (manual re-auth required), no further attempts.
1047
+ restricted_temp -> temporary restriction with a stated lift time: dip until
1048
+ that time + buffer, then retry once, up to the cap.
1049
+ transient -> re-anchor the 24h clock and try again later, up to the cap.
1050
+ """
1051
+ if not is_active():
1052
+ print(json.dumps({"recorded": False, "reason": "not_active"}))
1053
+ sys.exit(0)
1054
+ if is_terminal():
1055
+ print(json.dumps({"recorded": False, "reason": "already_terminal"}))
1056
+ sys.exit(0)
1057
+
1058
+ verdict = args.verdict
1059
+ detail = args.detail or ""
1060
+ age = age_seconds()
1061
+
1062
+ if verdict == "held":
1063
+ due_iso = _record_login_held()
1064
+ if not args.no_email:
1065
+ ok, msg = _send_simple_email(
1066
+ "[LI KILL] re-login OK, verifying it holds",
1067
+ [
1068
+ "A Claude-driven re-login into LinkedIn SUCCEEDED.",
1069
+ "",
1070
+ "Pipelines stay paused for now: we re-verify (read-only) at",
1071
+ str(due_iso) + " that the session actually held before",
1072
+ "clearing the flag. If it dropped by then, we stop completely.",
1073
+ "",
1074
+ "Login detail: " + str(detail),
1075
+ "State file: " + STATE_FILE,
1076
+ ],
1077
+ )
1078
+ _append_trail({"event": "pending_hold_email", "ok": ok, "msg": msg})
1079
+ print(json.dumps({
1080
+ "recorded": True,
1081
+ "verdict": "held",
1082
+ "pending_hold": True,
1083
+ "hold_check_due": due_iso,
1084
+ }))
1085
+ sys.exit(0)
1086
+
1087
+ if verdict == "hard_block":
1088
+ _record_hard_block(detail)
1089
+ if not args.no_email:
1090
+ age_h = round(age / 3600.0, 1) if age else "?"
1091
+ ok, msg = _send_simple_email(
1092
+ "[LI KILL] re-login BLOCKED, manual re-auth required",
1093
+ [
1094
+ "A Claude-driven re-login into LinkedIn hit a hard block and",
1095
+ "auto-recovery has STOPPED COMPLETELY (no further attempts).",
1096
+ "",
1097
+ "This is a checkpoint / captcha / account restriction / wrong",
1098
+ "credentials / 2FA wall: poking it again only deepens the block.",
1099
+ "",
1100
+ "Killswitch age: " + str(age_h) + "h",
1101
+ "Block detail: " + str(detail),
1102
+ "",
1103
+ "To resume:",
1104
+ " 1. Open the linkedin-harness Chrome (port 9556), sign in,",
1105
+ " and clear any checkpoint by hand.",
1106
+ " 2. Confirm /feed/ renders.",
1107
+ " 3. python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
1108
+ "",
1109
+ "State file: " + STATE_FILE,
1110
+ ],
1111
+ )
1112
+ _append_trail({"event": "terminal_email", "ok": ok, "msg": msg})
1113
+ print(json.dumps({
1114
+ "recorded": True,
1115
+ "verdict": "hard_block",
1116
+ "terminal": True,
1117
+ }))
1118
+ sys.exit(0)
1119
+
1120
+ if verdict == "restricted_temp":
1121
+ attempts, terminal, retry_at = _record_restricted_temp(detail)
1122
+ if not args.no_email:
1123
+ if terminal:
1124
+ ok, msg = _send_simple_email(
1125
+ "[LI KILL] temporary restriction kept recurring, stopping",
1126
+ [
1127
+ "A Claude-driven re-login hit a TEMPORARY restriction again",
1128
+ "after " + str(attempts) + " timed retries; auto-recovery has",
1129
+ "STOPPED COMPLETELY. The restriction is not clearing on its own.",
1130
+ "",
1131
+ "Last detail: " + str(detail),
1132
+ "Manual re-auth + clear required:",
1133
+ " python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
1134
+ "State file: " + STATE_FILE,
1135
+ ],
1136
+ )
1137
+ else:
1138
+ ok, msg = _send_simple_email(
1139
+ "[LI KILL] temporarily restricted, will retry after lift time",
1140
+ [
1141
+ "A Claude-driven re-login found the account TEMPORARILY",
1142
+ "restricted (automated-activity flag with a stated lift time).",
1143
+ "",
1144
+ "We are NOT giving up: pipelines stay paused and we will make",
1145
+ "one more login attempt after the restriction lifts, at",
1146
+ " " + _fmt_local(retry_at) + " (" + str(retry_at) + " UTC)",
1147
+ "This is attempt " + str(attempts) + " of "
1148
+ + str(RECOVERY_RESTRICTED_MAX_ATTEMPTS) + ".",
1149
+ "",
1150
+ "Restriction detail: " + str(detail),
1151
+ "State file: " + STATE_FILE,
1152
+ ],
1153
+ )
1154
+ _append_trail({"event": "restricted_temp_email", "ok": ok, "msg": msg})
1155
+ print(json.dumps({
1156
+ "recorded": True,
1157
+ "verdict": "restricted_temp",
1158
+ "restricted_attempts": attempts,
1159
+ "terminal": terminal,
1160
+ "retry_at": retry_at,
1161
+ }))
1162
+ sys.exit(0)
1163
+
1164
+ # transient
1165
+ attempts, terminal = _record_transient(detail)
1166
+ if terminal and not args.no_email:
1167
+ ok, msg = _send_simple_email(
1168
+ "[LI KILL] re-login gave up after repeated inconclusive attempts",
1169
+ [
1170
+ "Claude-driven re-login could not conclusively log in after",
1171
+ str(attempts) + " transient attempt(s); auto-recovery STOPPED.",
1172
+ "",
1173
+ "Last detail: " + str(detail),
1174
+ "Manual re-auth + clear required.",
1175
+ "State file: " + STATE_FILE,
1176
+ ],
1177
+ )
1178
+ _append_trail({"event": "terminal_email", "ok": ok, "msg": msg})
1179
+ print(json.dumps({
1180
+ "recorded": True,
1181
+ "verdict": "transient",
1182
+ "transient_attempts": attempts,
1183
+ "terminal": terminal,
1184
+ }))
1185
+ sys.exit(0)
1186
+
1187
+
1188
+ def _cmd_recover_hold(args):
1189
+ """Read-only re-verify that a successful re-login actually held. Run after the
1190
+ pending-hold window elapses (recover-check prints mode "hold"). No login, no
1191
+ Claude: just the gentle probe.
1192
+
1193
+ healthy -> clear the flag + resume the fleet (the win).
1194
+ conclusively out -> terminal: the session dropped shortly after login,
1195
+ which is exactly the "doesn't hold -> stop" rule.
1196
+ inconclusive (infra) -> leave pending; the next hourly tick re-checks.
1197
+ """
1198
+ if not is_active():
1199
+ print(json.dumps({"held": False, "reason": "not_active"}))
1200
+ sys.exit(0)
1201
+ if is_terminal():
1202
+ print(json.dumps({"held": False, "reason": "already_terminal"}))
1203
+ sys.exit(0)
1204
+ p = read() or {}
1205
+ if not p.get("recovery_pending_hold"):
1206
+ print(json.dumps({"held": False, "reason": "not_pending_hold"}))
1207
+ sys.exit(0)
1208
+
1209
+ cdp_url = args.cdp_url or LINKEDIN_CDP_URL
1210
+ healthy, detail, conclusive = _probe_linkedin_health(cdp_url)
1211
+ age = age_seconds()
1212
+ _append_trail({
1213
+ "event": "hold_check_probe",
1214
+ "ts": _now_iso(),
1215
+ "healthy": healthy,
1216
+ "conclusive": conclusive,
1217
+ "detail": detail,
1218
+ })
1219
+
1220
+ if healthy:
1221
+ clear()
1222
+ _append_trail({"event": "recover_clear", "ts": _now_iso(), "detail": "hold_check: " + str(detail)})
1223
+ if not args.no_email:
1224
+ ok, msg = _send_recovery_email("re-login held: " + str(detail), age)
1225
+ _append_trail({"event": "recover_email", "ok": ok, "msg": msg})
1226
+ print(json.dumps({"held": True, "recovered": True, "detail": detail}))
1227
+ sys.exit(0)
1228
+
1229
+ if not conclusive:
1230
+ # Infra hiccup during the hold-check; don't punish the session, retry next hour.
1231
+ print(json.dumps({"held": False, "reason": "hold_check_inconclusive", "detail": detail}))
1232
+ sys.exit(0)
1233
+
1234
+ # Conclusively logged out again -> the login did not hold. Stop completely.
1235
+ _record_hard_block("login did not hold: " + str(detail))
1236
+ p2 = read() or {}
1237
+ p2["recovery_terminal_reason"] = "login_dropped_after_hold"
1238
+ _write_state(p2)
1239
+ if not args.no_email:
1240
+ ok, msg = _send_simple_email(
1241
+ "[LI KILL] re-login did NOT hold, stopping completely",
1242
+ [
1243
+ "A re-login succeeded but the session dropped within the hold",
1244
+ "window. Per the 'if it doesn't hold, don't try again' rule,",
1245
+ "auto-recovery has STOPPED COMPLETELY.",
1246
+ "",
1247
+ "Hold-check detail: " + str(detail),
1248
+ "Manual re-auth + clear required:",
1249
+ " python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
1250
+ "State file: " + STATE_FILE,
1251
+ ],
1252
+ )
1253
+ _append_trail({"event": "terminal_email", "ok": ok, "msg": msg})
1254
+ print(json.dumps({"held": False, "terminal": True, "reason": "login_dropped_after_hold", "detail": detail}))
1255
+ sys.exit(0)
1256
+
1257
+
1258
+ def main():
1259
+ parser = argparse.ArgumentParser(description="LinkedIn pipeline killswitch")
1260
+ sub = parser.add_subparsers(dest="cmd", required=True)
1261
+
1262
+ sub.add_parser("check", help="exit 0 if clear, 1 if active (no output)")
1263
+ sub.add_parser("status", help="print JSON payload of current state")
1264
+
1265
+ e = sub.add_parser("engage", help="engage the killswitch")
1266
+ e.add_argument("--signal", required=True, choices=sorted(VALID_SIGNALS))
1267
+ e.add_argument("--detail", default="")
1268
+ e.add_argument("--run-log", default="")
1269
+ e.add_argument("--extra", default="", help="JSON object of extra fields")
1270
+ e.add_argument("--no-email", action="store_true", help="skip email alert")
1271
+
1272
+ sub.add_parser("clear", help="clear the killswitch (human ack)")
1273
+
1274
+ dg = sub.add_parser(
1275
+ "detect-gate",
1276
+ help="per-run logout probe; engage + exit 2 if conclusively logged out",
1277
+ )
1278
+ dg.add_argument("--cdp-url", default="", help="harness CDP URL (default $LINKEDIN_CDP_URL)")
1279
+ dg.add_argument("--no-email", action="store_true", help="skip engage alert email")
1280
+
1281
+ sub.add_parser(
1282
+ "recover-check",
1283
+ help="exit 0 if active AND >= RECOVERY_MIN_AGE_HOURS old (else 1)",
1284
+ )
1285
+
1286
+ r = sub.add_parser(
1287
+ "recover",
1288
+ help="gentle read-only self-heal probe; clear + email on health (legacy)",
1289
+ )
1290
+ r.add_argument("--cdp-url", default="", help="harness CDP URL (default $LINKEDIN_CDP_URL)")
1291
+ r.add_argument("--no-email", action="store_true", help="skip recovery email")
1292
+ r.add_argument("--force", action="store_true", help="skip the age gate")
1293
+
1294
+ rr = sub.add_parser(
1295
+ "recover-record",
1296
+ help="record the verdict of a Claude re-login attempt + transition state",
1297
+ )
1298
+ rr.add_argument(
1299
+ "--verdict",
1300
+ required=True,
1301
+ choices=["held", "hard_block", "restricted_temp", "transient"],
1302
+ help=(
1303
+ "held=login ok (verify hold); hard_block=terminal; "
1304
+ "restricted_temp=temporary restriction, dip until lift time then retry; "
1305
+ "transient=retry later"
1306
+ ),
1307
+ )
1308
+ rr.add_argument("--detail", default="", help="human-readable detail for the trail/email")
1309
+ rr.add_argument("--no-email", action="store_true", help="skip the alert email")
1310
+
1311
+ rh = sub.add_parser(
1312
+ "recover-hold",
1313
+ help="read-only re-verify a prior re-login held; clear or go terminal",
1314
+ )
1315
+ rh.add_argument("--cdp-url", default="", help="harness CDP URL (default $LINKEDIN_CDP_URL)")
1316
+ rh.add_argument("--no-email", action="store_true", help="skip the alert email")
1317
+
1318
+ args = parser.parse_args()
1319
+ {
1320
+ "check": _cmd_check,
1321
+ "status": _cmd_status,
1322
+ "engage": _cmd_engage,
1323
+ "clear": _cmd_clear,
1324
+ "detect-gate": _cmd_detect_gate,
1325
+ "recover-check": _cmd_recover_check,
1326
+ "recover": _cmd_recover,
1327
+ "recover-record": _cmd_recover_record,
1328
+ "recover-hold": _cmd_recover_hold,
1329
+ }[args.cmd](args)
1330
+
1331
+
1332
+ if __name__ == "__main__":
1333
+ main()