@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
package/skill/audit.sh ADDED
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env bash
2
+ # audit.sh — Post audit pipeline.
3
+ #
4
+ # Per-platform mode (preferred, driven by launchd via per-platform wrappers):
5
+ # --platform reddit Reddit API audit via stats.py --reddit-only
6
+ # --platform moltbook Moltbook API audit via stats.py --moltbook-only
7
+ # --platform twitter Twitter API audit via stats.py --twitter-audit
8
+ # --platform linkedin Retired 2026-04-17 (flagged CDP pattern). Engagement
9
+ # stats now collected via stats.sh Step 4 (linkedin-agent
10
+ # MCP, headed Chrome). Branch kept as no-op so the
11
+ # audit-linkedin launchd job doesn't error.
12
+ #
13
+ # Every run also executes the orphan/summary step at the end (DB-only, cheap).
14
+ # With no --platform, runs all four sequentially (legacy manual path).
15
+
16
+
17
+ set -uo pipefail
18
+
19
+ # Parse args.
20
+ PLATFORM=""
21
+ while [ $# -gt 0 ]; do
22
+ case "$1" in
23
+ --platform) PLATFORM="${2:-}"; shift 2 ;;
24
+ --platform=*) PLATFORM="${1#--platform=}"; shift ;;
25
+ *) shift ;;
26
+ esac
27
+ done
28
+
29
+ case "$PLATFORM" in
30
+ ""|reddit|twitter|linkedin|moltbook) ;;
31
+ *)
32
+ echo "audit.sh: invalid --platform '$PLATFORM' (expected reddit, twitter, linkedin, or moltbook)" >&2
33
+ exit 2
34
+ ;;
35
+ esac
36
+
37
+ # Per-platform lock name so all four can run concurrently, but a second
38
+ # invocation of the same platform waits. Legacy no-platform run keeps the
39
+ # original "audit" lock name.
40
+ LOCK_NAME="audit${PLATFORM:+-$PLATFORM}"
41
+
42
+ # Browser-profile lock first (shared across pipelines using the same browser),
43
+ # then the pipeline-specific lock. moltbook has no shared browser profile.
44
+ #
45
+ # Reddit uses the unified Python lease (2026-05-10) — TTL-aware, auto-decays
46
+ # during Claude idle gaps so peer pipelines can use the profile. The MCP
47
+ # proxy heartbeats expires_at on every reddit-agent call. LinkedIn/Twitter
48
+ # still use the bash lock (no MCP-proxy heartbeat wiring yet).
49
+ source "$(dirname "$0")/lock.sh"
50
+ REPO_DIR_FOR_LOCK="$HOME/social-autoposter"
51
+ _release_reddit_lease() {
52
+ timeout 3 python3 "$REPO_DIR_FOR_LOCK/scripts/reddit_browser_lock.py" release 2>/dev/null || true
53
+ }
54
+ case "${PLATFORM:-all}" in
55
+ linkedin) acquire_lock "linkedin-browser" 3600 ;;
56
+ reddit)
57
+ python3 "$REPO_DIR_FOR_LOCK/scripts/reddit_browser_lock.py" acquire --timeout 3600 --ttl 90 2>&1 || \
58
+ echo "WARNING: reddit_browser_lock.py acquire failed; proceeding without lease."
59
+ trap '_release_reddit_lease; _sa_release_locks' EXIT INT TERM HUP
60
+ ;;
61
+ twitter|x) acquire_lock "twitter-browser" 3600 ;;
62
+ moltbook) ;;
63
+ all)
64
+ acquire_lock "linkedin-browser" 3600
65
+ python3 "$REPO_DIR_FOR_LOCK/scripts/reddit_browser_lock.py" acquire --timeout 3600 --ttl 90 2>&1 || \
66
+ echo "WARNING: reddit_browser_lock.py acquire failed; proceeding without lease."
67
+ trap '_release_reddit_lease; _sa_release_locks' EXIT INT TERM HUP
68
+ acquire_lock "twitter-browser" 3600
69
+ ;;
70
+ esac
71
+ acquire_lock "$LOCK_NAME" 3600
72
+
73
+ # Load secrets
74
+ # shellcheck source=/dev/null
75
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
76
+
77
+ REPO_DIR="$HOME/social-autoposter"
78
+ SKILL_FILE="$REPO_DIR/SKILL.md"
79
+ LOG_DIR="$REPO_DIR/skill/logs"
80
+ # HTTP-only lane (2026-06-01): all reads go through the s4l.ai API via
81
+ # scripts/audit_helper.py. No DATABASE_URL, no psql, no fallback.
82
+ AUDIT_HELPER="$REPO_DIR/scripts/audit_helper.py"
83
+
84
+ mkdir -p "$LOG_DIR"
85
+ LOG_TAG="${PLATFORM:-all}"
86
+ LOG_FILE="$LOG_DIR/audit-${LOG_TAG}-$(date +%Y-%m-%d_%H%M%S).log"
87
+
88
+ log() { echo "[$(date +%H:%M:%S)] $*" >> "$LOG_FILE"; echo "[$(date +%H:%M:%S)] $*"; }
89
+
90
+ RUN_START=$(date +%s)
91
+ log "=== Audit Pipeline Run (${LOG_TAG}): $(date) ==="
92
+
93
+ # Decide which steps run for this invocation.
94
+ if [ -z "$PLATFORM" ]; then
95
+ RUN_REDDIT=1; RUN_MOLTBOOK=1; RUN_TWITTER=1; RUN_LINKEDIN=1
96
+ else
97
+ RUN_REDDIT=0; RUN_MOLTBOOK=0; RUN_TWITTER=0; RUN_LINKEDIN=0
98
+ case "$PLATFORM" in
99
+ reddit) RUN_REDDIT=1 ;;
100
+ moltbook) RUN_MOLTBOOK=1 ;;
101
+ twitter) RUN_TWITTER=1 ;;
102
+ linkedin) RUN_LINKEDIN=1 ;;
103
+ esac
104
+ fi
105
+
106
+ STEP1_EXIT=0
107
+ STEP2_EXIT=0
108
+ STEP3_EXIT=0
109
+
110
+ # ═══════════════════════════════════════════════════════
111
+ # Reddit API audit
112
+ # ═══════════════════════════════════════════════════════
113
+ if [ "$RUN_REDDIT" -eq 1 ]; then
114
+ log "Reddit: API audit (stats.py --reddit-only)"
115
+ if [ -z "$PLATFORM" ]; then
116
+ # Legacy all-platform path uses the combined default pass which also
117
+ # covers Moltbook + Twitter, so we don't duplicate them below.
118
+ python3 "$REPO_DIR/scripts/stats.py" >> "$LOG_FILE" 2>&1
119
+ else
120
+ python3 "$REPO_DIR/scripts/stats.py" --reddit-only >> "$LOG_FILE" 2>&1
121
+ fi
122
+ STEP1_EXIT=$?
123
+ if [ "$STEP1_EXIT" -ne 0 ]; then
124
+ log "Reddit: FAILED (exit $STEP1_EXIT)"
125
+ else
126
+ log "Reddit: Done"
127
+ fi
128
+ fi
129
+
130
+ # ═══════════════════════════════════════════════════════
131
+ # Moltbook API audit
132
+ # ═══════════════════════════════════════════════════════
133
+ # Skip in legacy mode — already covered by the combined pass above.
134
+ if [ "$RUN_MOLTBOOK" -eq 1 ] && [ -n "$PLATFORM" ]; then
135
+ log "Moltbook: API audit (stats.py --moltbook-only)"
136
+ python3 "$REPO_DIR/scripts/stats.py" --moltbook-only >> "$LOG_FILE" 2>&1
137
+ MOLTBOOK_EXIT=$?
138
+ if [ "$MOLTBOOK_EXIT" -ne 0 ]; then
139
+ log "Moltbook: FAILED (exit $MOLTBOOK_EXIT)"
140
+ else
141
+ log "Moltbook: Done"
142
+ fi
143
+ fi
144
+
145
+ # ═══════════════════════════════════════════════════════
146
+ # Twitter API audit (fxtwitter — no browser)
147
+ # ═══════════════════════════════════════════════════════
148
+ if [ "$RUN_TWITTER" -eq 1 ]; then
149
+ TWITTER_COUNT=$(python3 "$AUDIT_HELPER" twitter-active-count 2>/dev/null || echo "0")
150
+
151
+ if [ "$TWITTER_COUNT" -gt 0 ]; then
152
+ log "Twitter: API audit — $TWITTER_COUNT active tweets"
153
+ python3 "$REPO_DIR/scripts/stats.py" --twitter-audit >> "$LOG_FILE" 2>&1
154
+ STEP2_EXIT=$?
155
+ if [ "$STEP2_EXIT" -ne 0 ]; then
156
+ log "Twitter: FAILED (exit $STEP2_EXIT)"
157
+ else
158
+ log "Twitter: Done"
159
+ fi
160
+ else
161
+ log "Twitter: SKIPPED — no active Twitter posts to audit"
162
+ fi
163
+ fi
164
+
165
+ # ═══════════════════════════════════════════════════════
166
+ # LinkedIn audit — retired 2026-04-17 (flagged CDP fingerprint).
167
+ # Post-engagement stats are now collected in stats.sh Step 4 via the
168
+ # linkedin-agent MCP (headed Chrome). Deletion detection is not currently
169
+ # covered; if needed, extend stats.sh Step 4 to parse 404 / "This post
170
+ # isn't available" screens.
171
+ # ═══════════════════════════════════════════════════════
172
+ if [ "$RUN_LINKEDIN" -eq 1 ]; then
173
+ log "LinkedIn: SKIPPED — CDP audit retired (see stats.sh Step 4 for engagement stats via MCP)"
174
+ fi
175
+
176
+ # ═══════════════════════════════════════════════════════
177
+ # Orphan / stale post detection + summary (DB-only, every run)
178
+ # ═══════════════════════════════════════════════════════
179
+ log "Orphan/stale detection"
180
+
181
+ ORPHAN_REPORT=$(python3 "$AUDIT_HELPER" orphan-report 2>/dev/null || echo "")
182
+
183
+ BROKEN_URL_COUNT=$(python3 "$AUDIT_HELPER" broken-url-count 2>/dev/null || echo "0")
184
+
185
+ if [ -n "$ORPHAN_REPORT" ]; then
186
+ log "WARNING: Posts with non-standard status:"
187
+ echo "$ORPHAN_REPORT" | while IFS='|' read -r plat stat cnt; do
188
+ log " $plat $stat: $cnt"
189
+ done
190
+ fi
191
+ if [ "$BROKEN_URL_COUNT" -gt 0 ]; then
192
+ log "WARNING: $BROKEN_URL_COUNT active posts with missing/invalid our_url"
193
+ fi
194
+ if [ -z "$ORPHAN_REPORT" ] && [ "$BROKEN_URL_COUNT" = "0" ]; then
195
+ log "Orphan/stale: Clean (no orphans, no broken URLs)"
196
+ fi
197
+
198
+ log "Summary"
199
+
200
+ ACTIVE=$(python3 "$AUDIT_HELPER" status-count --status active 2>/dev/null || echo "?")
201
+ DELETED=$(python3 "$AUDIT_HELPER" status-count --status deleted 2>/dev/null || echo "?")
202
+ REMOVED=$(python3 "$AUDIT_HELPER" status-count --status removed 2>/dev/null || echo "?")
203
+
204
+ log "Post status: active=$ACTIVE deleted=$DELETED removed=$REMOVED"
205
+
206
+ # Log run to persistent monitor.
207
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
208
+ AUDIT_FAILED=$(( (STEP1_EXIT != 0 ? 1 : 0) + (STEP2_EXIT != 0 ? 1 : 0) + (STEP3_EXIT != 0 ? 1 : 0) ))
209
+ SCRIPT_TAG="audit${PLATFORM:+-$PLATFORM}"
210
+
211
+ # Sum per-platform STATS_JSON lines emitted by stats.py into log_run.py flags so
212
+ # the dashboard Job History row shows real counters (scanned/checked/changed/
213
+ # replies-refreshed/removed) instead of the legacy posted=<active_count> mush.
214
+ # Each platform's stats.py print is followed by one `STATS_JSON: {...}` line;
215
+ # we read them all from $LOG_FILE and aggregate by kind. Missing keys default to
216
+ # 0 so the existing log_run.py flag surface stays unchanged.
217
+ read -r SCANNED CHECKED CHANGED DELETED ERRORS REPLIES_REFRESHED REPLIES_FRESH THREADS_SCANNED THREADS_WRITTEN <<<"$(
218
+ python3 - "$LOG_FILE" <<'PY'
219
+ import json, sys, re
220
+ log_path = sys.argv[1]
221
+ agg = dict(scanned=0, checked=0, changed=0, deleted=0, errors=0,
222
+ replies_refreshed=0, replies_fresh=0,
223
+ threads_scanned=0, threads_written=0)
224
+ try:
225
+ with open(log_path) as f:
226
+ for line in f:
227
+ m = re.search(r"STATS_JSON:\s*(\{.*\})\s*$", line)
228
+ if not m:
229
+ continue
230
+ try:
231
+ d = json.loads(m.group(1))
232
+ except Exception:
233
+ continue
234
+ kind = d.get("kind")
235
+ if kind == "posts":
236
+ agg["scanned"] += int(d.get("total", 0) or 0)
237
+ agg["checked"] += int(d.get("checked", 0) or 0)
238
+ agg["changed"] += int(d.get("changed", 0) or 0)
239
+ agg["deleted"] += int(d.get("deleted", 0) or 0) + int(d.get("removed", 0) or 0)
240
+ agg["errors"] += int(d.get("errors", 0) or 0)
241
+ elif kind == "replies":
242
+ agg["replies_refreshed"] += int(d.get("updated", 0) or 0)
243
+ agg["replies_fresh"] += int(d.get("fresh", 0) or 0)
244
+ elif kind == "thread_snapshots":
245
+ agg["threads_scanned"] += int(d.get("scanned", 0) or 0)
246
+ agg["threads_written"] += int(d.get("written", 0) or 0)
247
+ except FileNotFoundError:
248
+ pass
249
+ print(agg["scanned"], agg["checked"], agg["changed"], agg["deleted"],
250
+ agg["errors"], agg["replies_refreshed"], agg["replies_fresh"],
251
+ agg["threads_scanned"], agg["threads_written"])
252
+ PY
253
+ )"
254
+ SCANNED="${SCANNED:-0}"
255
+ CHECKED="${CHECKED:-0}"
256
+ CHANGED="${CHANGED:-0}"
257
+ DELETED="${DELETED:-0}"
258
+ ERRORS="${ERRORS:-0}"
259
+ REPLIES_REFRESHED="${REPLIES_REFRESHED:-0}"
260
+ REPLIES_FRESH="${REPLIES_FRESH:-0}"
261
+ THREADS_SCANNED="${THREADS_SCANNED:-0}"
262
+ THREADS_WRITTEN="${THREADS_WRITTEN:-0}"
263
+
264
+ # Roll API errors from stats.py into the dashboard `failed` pill alongside
265
+ # step-exit counts (same convention stats.sh uses).
266
+ AUDIT_FAILED=$(( AUDIT_FAILED + ERRORS ))
267
+
268
+ log "Per-run counters: scanned=$SCANNED checked=$CHECKED changed=$CHANGED removed=$DELETED errors=$ERRORS replies_refreshed=$REPLIES_REFRESHED replies_fresh=$REPLIES_FRESH thread_snapshots_written=$THREADS_WRITTEN"
269
+
270
+ python3 "$REPO_DIR/scripts/log_run.py" \
271
+ --script "$SCRIPT_TAG" \
272
+ --posted 0 \
273
+ --skipped 0 \
274
+ --failed "$AUDIT_FAILED" \
275
+ --replies-refreshed "$REPLIES_REFRESHED" \
276
+ --checked "$CHECKED" \
277
+ --updated "$CHANGED" \
278
+ --removed "$DELETED" \
279
+ --scanned "$SCANNED" \
280
+ --changed "$CHANGED" \
281
+ --cost 0 \
282
+ --elapsed "$RUN_ELAPSED"
283
+
284
+ log "=== Audit Pipeline complete (${LOG_TAG}): $(date) ==="
285
+
286
+ # Clean up old logs (keep last 14 days) — covers both audit-all-* and audit-<platform>-*.
287
+ find "$LOG_DIR" -name "audit-*.log" -mtime +14 -delete 2>/dev/null || true
@@ -0,0 +1,19 @@
1
+ #!/bin/bash
2
+ # Periodic backfill of twitter_search_attempts.search_topic from
3
+ # twitter_candidates (Pass A) + (batch_id, project_name) fanout (Pass B).
4
+ # Runs every 5 minutes via com.m13v.social-twitter-attempt-topic-backfill.
5
+ # Idempotent: each pass only touches rows where search_topic IS NULL, so
6
+ # repeated runs converge to zero work once the topics are populated.
7
+ #
8
+ # Why this exists: skill/run-twitter-cycle.sh and score_twitter_candidates.py
9
+ # are both chflags-locked. The picker (pick_search_topic.py) stamps
10
+ # twitter_candidates.search_topic when it scores tweets, but the attempt row
11
+ # stays NULL because the SCAN_SCHEMA in the locked shell doesn't carry the
12
+ # topic through queries_used. This script closes that gap.
13
+ set -euo pipefail
14
+
15
+ REPO_DIR="${REPO_DIR:-/Users/matthewdi/social-autoposter}"
16
+ PYTHON_BIN="${PYTHON_BIN:-/opt/homebrew/bin/python3.11}"
17
+
18
+ cd "$REPO_DIR"
19
+ exec "$PYTHON_BIN" scripts/backfill_twitter_attempts_topic.py --days 14
@@ -0,0 +1,24 @@
1
+ #!/bin/bash
2
+ # Periodic recovery of "ghost" Twitter posts: replies that landed on x.com but
3
+ # whose POST /api/v1/posts log call failed (rate-limit cap, transient 500, or
4
+ # timeout), so twitter_post_plan.py marked the candidate 'skipped' and reported
5
+ # log_post_no_id. The tweet is live; the DB forgot it.
6
+ #
7
+ # scripts/backfill_twitter_log_post_no_id.py reconstructs the missing posts rows
8
+ # from skill/logs/twitter-cycle-*.log. It is idempotent: the API dedups on
9
+ # (platform, thread_url), so already-recovered posts no-op.
10
+ #
11
+ # Runs every 30 min via com.m13v.social-twitter-ghost-backfill. We only scan the
12
+ # last 3 days of cycle logs (rolling window) to keep each run fast; the original
13
+ # 64 KB generation_trace outage (2026-05-12..13) was already backfilled once and
14
+ # the cap is now 1 MB, so the steady-state cause is rate-limit / transient only.
15
+ set -euo pipefail
16
+
17
+ REPO_DIR="${REPO_DIR:-/Users/matthewdi/social-autoposter}"
18
+ PYTHON_BIN="${PYTHON_BIN:-/opt/homebrew/bin/python3.11}"
19
+
20
+ # macOS date: 3 days ago, YYYY-MM-DD. (date -v is macOS-only; this job is Aqua.)
21
+ SINCE="$(date -v-3d +%Y-%m-%d)"
22
+
23
+ cd "$REPO_DIR"
24
+ exec "$PYTHON_BIN" scripts/backfill_twitter_log_post_no_id.py --since "$SINCE"
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # Wraps scripts/check_external_pool_depth.py for launchd. Fires at most one
3
+ # email per (project, platform, severity) per 24h via DB-side cooldown.
4
+ set -eu
5
+ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
6
+ cd "$REPO_DIR"
7
+ exec /opt/homebrew/bin/python3.11 "$REPO_DIR/scripts/check_external_pool_depth.py"
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env bash
2
+ # check-web-chats.sh, poll Postgres for unread web-chat messages and spawn one
3
+ # Claude session per visitor. Called by launchd every 15 seconds.
4
+ #
5
+ # Mirror of ~/fazm/inbox/skill/check-founder-chat.sh: same lock pattern, same
6
+ # claim/cooldown/retry/rate-limit guardrails, same email-summary escalation.
7
+ # The only difference is the data layer: Postgres web_chat_threads /
8
+ # web_chat_messages instead of Firestore founder_chats.
9
+
10
+ set -euo pipefail
11
+
12
+ # Ensure Homebrew bins (gtimeout, jq) AND the user's npm-global bin (claude)
13
+ # are findable regardless of how the script is invoked. Launchd has these via
14
+ # the plist's PATH; manual / sandboxed shells may not.
15
+ export PATH="/Users/matthewdi/.nvm/versions/node/v20.19.4/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
16
+
17
+ source "$(dirname "$0")/lock.sh"
18
+ acquire_lock "check-web-chats" 60
19
+
20
+ # DB access is HTTP-only via scripts/http_api.py -> s4l.ai /api/v1/web-chat/*.
21
+ # No DATABASE_URL needed here any more.
22
+
23
+ # send-email.js needs RESEND_API_KEY + the analytics node_modules.
24
+ ANALYTICS_ENV="$HOME/analytics/.env.production.local"
25
+ if [ -f "$ANALYTICS_ENV" ]; then
26
+ export RESEND_API_KEY=$(grep '^RESEND_API_KEY=' "$ANALYTICS_ENV" | sed 's/^RESEND_API_KEY=//' | tr -d '"' | tr -d '\\n')
27
+ fi
28
+ export NODE_PATH="$HOME/analytics/node_modules"
29
+
30
+ REPO_DIR="$HOME/social-autoposter"
31
+ SCRIPTS_DIR="$REPO_DIR/scripts"
32
+ LOG_DIR="$REPO_DIR/skill/logs"
33
+ mkdir -p "$LOG_DIR"
34
+
35
+ PYTHON_BIN="${PYTHON_BIN:-/opt/homebrew/bin/python3.11}"
36
+ [ -x "$PYTHON_BIN" ] || PYTHON_BIN="/usr/bin/python3"
37
+
38
+ log() { echo "[$(date +%H:%M:%S)] $*" >> "$LOG_DIR/web-chat.log"; }
39
+
40
+ # Step 1: query Postgres for unread threads.
41
+ CHATS=$("$PYTHON_BIN" "$SCRIPTS_DIR/check_unread_web_chats.py" 2>>"$LOG_DIR/web-chat.log")
42
+ if [ "$CHATS" = "[]" ] || [ -z "$CHATS" ]; then
43
+ exit 0
44
+ fi
45
+
46
+ NUM=$(echo "$CHATS" | "$PYTHON_BIN" -c "import json,sys; print(len(json.load(sys.stdin)))")
47
+
48
+ for i in $(seq 0 $((NUM - 1))); do
49
+ THREAD_ID=$(echo "$CHATS" | "$PYTHON_BIN" -c "import json,sys; print(json.load(sys.stdin)[$i]['thread_id'])")
50
+ PROJECT=$(echo "$CHATS" | "$PYTHON_BIN" -c "import json,sys; print(json.load(sys.stdin)[$i]['project'])")
51
+ EMAIL=$(echo "$CHATS" | "$PYTHON_BIN" -c "import json,sys; print(json.load(sys.stdin)[$i].get('visitor_email',''))")
52
+ NAME=$(echo "$CHATS" | "$PYTHON_BIN" -c "import json,sys; d=json.load(sys.stdin)[$i]; print(d.get('visitor_name') or d.get('visitor_email') or 'visitor')")
53
+ UNREAD=$(echo "$CHATS" | "$PYTHON_BIN" -c "import json,sys; print(json.load(sys.stdin)[$i]['unread'])")
54
+ PAGE_URL=$(echo "$CHATS" | "$PYTHON_BIN" -c "import json,sys; print(json.load(sys.stdin)[$i].get('page_url',''))")
55
+
56
+ PID_FILE="/tmp/web-chat-${THREAD_ID}.pid"
57
+
58
+ # Rate-limit circuit breaker (mirror Fazm /tmp/fazm-chat-ratelimit).
59
+ if [ -f "/tmp/web-chat-ratelimit" ]; then
60
+ RL_TS=$(awk '{print $2}' /tmp/web-chat-ratelimit 2>/dev/null || echo "0")
61
+ NOW_TS=$(date +%s)
62
+ if [ $((NOW_TS - RL_TS)) -lt 3600 ]; then
63
+ continue
64
+ else
65
+ rm -f /tmp/web-chat-ratelimit
66
+ fi
67
+ fi
68
+
69
+ # Skip if a Claude session is already alive for this thread.
70
+ if [ -f "$PID_FILE" ]; then
71
+ EXISTING_PID=$(cat "$PID_FILE" 2>/dev/null || echo "")
72
+ if [ -n "$EXISTING_PID" ] && kill -0 "$EXISTING_PID" 2>/dev/null; then
73
+ log "Session already active for $PROJECT/$THREAD_ID (pid $EXISTING_PID), skipping"
74
+ continue
75
+ fi
76
+ rm -f "$PID_FILE"
77
+ fi
78
+
79
+ log "Spawning session for $PROJECT/$THREAD_ID ($EMAIL, $UNREAD unread)"
80
+
81
+ # Cooldown check (mirror claim-chat --check-only).
82
+ if ! "$PYTHON_BIN" "$SCRIPTS_DIR/claim_web_chat.py" "$THREAD_ID" --check-only 2>>"$LOG_DIR/web-chat.log"; then
83
+ log "Thread $THREAD_ID in cooldown, skipping"
84
+ continue
85
+ fi
86
+
87
+ # Claim (resets unread, sets 5-min cooldown).
88
+ "$PYTHON_BIN" "$SCRIPTS_DIR/claim_web_chat.py" "$THREAD_ID" 2>>"$LOG_DIR/web-chat.log" \
89
+ || log "WARNING: claim failed for $THREAD_ID"
90
+
91
+ # Build prompt (history is dumped from Postgres for freshness).
92
+ HISTORY_JSON=$("$PYTHON_BIN" "$SCRIPTS_DIR/dump_web_chat_history.py" --thread "$THREAD_ID")
93
+
94
+ # Pull this project's config block to give Claude context.
95
+ PROJECT_CFG=$(/opt/homebrew/bin/jq --arg n "$PROJECT" '.projects[] | select(.name==$n)' "$REPO_DIR/config.json" 2>/dev/null || echo "{}")
96
+
97
+ PROMPT_FILE=$(mktemp)
98
+ cat > "$PROMPT_FILE" <<PROMPT_EOF
99
+ Read ~/social-autoposter/skill/WEB-CHAT-SKILL.md for the workflow.
100
+ Read ~/social-autoposter/skill/WEB-CHAT-VOICE.md for tone rules.
101
+
102
+ ## Web chat to handle
103
+
104
+ PROJECT: $PROJECT
105
+ THREAD_ID: $THREAD_ID
106
+ VISITOR_EMAIL: $EMAIL
107
+ VISITOR_NAME: $NAME
108
+ PAGE_URL: $PAGE_URL
109
+ UNREAD MESSAGES: $UNREAD
110
+
111
+ ## Project config (config.json)
112
+ \`\`\`json
113
+ $PROJECT_CFG
114
+ \`\`\`
115
+
116
+ ## Full conversation history (from Postgres)
117
+ \`\`\`json
118
+ $HISTORY_JSON
119
+ \`\`\`
120
+
121
+ Process this chat now. Follow WEB-CHAT-SKILL.md exactly.
122
+ Remember to remove the PID file /tmp/web-chat-${THREAD_ID}.pid when done.
123
+ PROMPT_EOF
124
+
125
+ SESSION_LOG="$LOG_DIR/web-chat-session-${THREAD_ID}-$(date +%Y%m%d_%H%M%S).log"
126
+ FAIL_COUNT_FILE="/tmp/web-chat-fail-${THREAD_ID}"
127
+
128
+ (
129
+ set +e
130
+ cd "$REPO_DIR"
131
+ echo "[$(date)] Starting Claude session for $PROJECT/$THREAD_ID ($EMAIL)" >> "$SESSION_LOG"
132
+ gtimeout 1200 claude \
133
+ -p "$(cat "$PROMPT_FILE")" \
134
+ --dangerously-skip-permissions \
135
+ >> "$SESSION_LOG" 2>&1
136
+ EXIT_CODE=$?
137
+ echo "[$(date)] Claude exited with code $EXIT_CODE" >> "$SESSION_LOG"
138
+
139
+ if [ $EXIT_CODE -ne 0 ]; then
140
+ echo "[$(date)] WARN: session for $THREAD_ID exited with $EXIT_CODE" >> "$LOG_DIR/web-chat.log"
141
+
142
+ # Detect persistent-error states that won't recover with quick retry:
143
+ # rate limits, credit/billing, auth/quota, account-level issues.
144
+ # All trip the same 1h pause; the next cycle re-tries automatically.
145
+ # Pending threads stay in Postgres (unread_by_founder>0) so nothing is
146
+ # ever lost; the launchd poller picks them up the moment the 1h
147
+ # marker expires. No human notification — the log line is enough.
148
+ PAUSE_PATTERNS='hit your limit|rate limit|rate.limited|too many requests|usage limit|weekly limit|5.hour limit|credit balance|out of credit|insufficient (credit|funds|balance)|payment required|billing|quota exceeded|api[- ]?key|unauthori[sz]ed|forbidden|account.{0,30}(suspend|disabled)|HTTP 401|HTTP 403|HTTP 429|invalid.*x.api.key'
149
+ if grep -qiE "$PAUSE_PATTERNS" "$SESSION_LOG" 2>/dev/null; then
150
+ echo "[$(date)] PERSISTENT ERROR on $THREAD_ID (rate limit / credits / auth), pausing all spawns for 1h" >> "$LOG_DIR/web-chat.log"
151
+ echo "rate_limited $(date +%s)" > "/tmp/web-chat-ratelimit"
152
+ rm -f "$PROMPT_FILE" "$PID_FILE" "$FAIL_COUNT_FILE"
153
+ exit 0
154
+ fi
155
+
156
+ FAILS=0
157
+ [ -f "$FAIL_COUNT_FILE" ] && FAILS=$(cat "$FAIL_COUNT_FILE" 2>/dev/null || echo "0")
158
+ FAILS=$((FAILS + 1))
159
+ echo "$FAILS" > "$FAIL_COUNT_FILE"
160
+
161
+ if [ "$FAILS" -ge 3 ]; then
162
+ echo "[$(date)] GIVING UP on $THREAD_ID after $FAILS fails" >> "$LOG_DIR/web-chat.log"
163
+ rm -f "$FAIL_COUNT_FILE"
164
+ # Leave claimed so it stops retrying.
165
+ else
166
+ "$PYTHON_BIN" "$SCRIPTS_DIR/unclaim_web_chat.py" "$THREAD_ID" >> "$LOG_DIR/web-chat.log" 2>&1
167
+ echo "[$(date)] Unclaimed $THREAD_ID (retry $FAILS/3)" >> "$LOG_DIR/web-chat.log"
168
+ fi
169
+ else
170
+ # Claude finished cleanly (replied OR explicitly skipped). Stamp
171
+ # processed_at so the recovery query in check_unread_web_chats.py
172
+ # won't re-flag this thread next cycle. Without this, threads where
173
+ # Claude legitimately skipped (smoke test, off-topic, no useful
174
+ # answer) loop every 5min for 24h, since last_message_sender stays
175
+ # 'visitor' (no agent message inserted on skip).
176
+ "$PYTHON_BIN" "$SCRIPTS_DIR/mark_web_chat_processed.py" "$THREAD_ID" >> "$LOG_DIR/web-chat.log" 2>&1
177
+ rm -f "$FAIL_COUNT_FILE"
178
+ fi
179
+
180
+ # No-output guard (silent rate limits sometimes). If Claude exited 0
181
+ # but produced almost no output, treat as a silent failure: unclaim
182
+ # so the next cycle retries via the main unread>0 path. The
183
+ # processed_at stamp above is harmless here because the main SELECT
184
+ # gates on unread_by_founder>0, not on processed_at.
185
+ LINE_COUNT=$(wc -l < "$SESSION_LOG" 2>/dev/null || echo "0")
186
+ if [ "$LINE_COUNT" -le 2 ] && [ "$EXIT_CODE" -eq 0 ]; then
187
+ echo "[$(date)] WARN: $THREAD_ID produced no output, unclaiming" >> "$LOG_DIR/web-chat.log"
188
+ "$PYTHON_BIN" "$SCRIPTS_DIR/unclaim_web_chat.py" "$THREAD_ID" >> "$LOG_DIR/web-chat.log" 2>&1
189
+ fi
190
+
191
+ rm -f "$PROMPT_FILE" "$PID_FILE"
192
+ ) &
193
+
194
+ CLAUDE_PID=$!
195
+ echo "$CLAUDE_PID" > "$PID_FILE"
196
+ log "Started session for $PROJECT/$THREAD_ID (pid $CLAUDE_PID)"
197
+ done
198
+
199
+ # Trim log to last 2000 lines.
200
+ if [ -f "$LOG_DIR/web-chat.log" ]; then
201
+ tail -2000 "$LOG_DIR/web-chat.log" > "$LOG_DIR/web-chat.log.tmp" 2>/dev/null && \
202
+ mv "$LOG_DIR/web-chat.log.tmp" "$LOG_DIR/web-chat.log" || true
203
+ fi