@m13v/s4l 1.6.197-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1314 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +497 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,433 @@
1
+ #!/bin/bash
2
+ # Wrapper around `claude` that pre-assigns a session UUID, exports it for
3
+ # downstream loggers (log_post.py, reply_db.py, dm_conversation.py read
4
+ # CLAUDE_SESSION_ID from env), and after the session exits records token
5
+ # usage + computed cost into the claude_sessions table.
6
+ #
7
+ # Usage:
8
+ # scripts/run_claude.sh <script_tag> -p "PROMPT" [other claude flags...]
9
+ #
10
+ # Runner migration pattern:
11
+ # OLD: claude -p "PROMPT" 2>&1 | tee -a "$LOG_FILE"
12
+ # NEW: scripts/run_claude.sh "run-linkedin" -p "PROMPT" 2>&1 | tee -a "$LOG_FILE"
13
+ #
14
+ # The wrapper passes everything after the script_tag verbatim to `claude`,
15
+ # so all flags (--output-format, --json-schema, --model, etc.) work unchanged.
16
+ # stdout is streamed straight from claude — no buffering — so existing pipes
17
+ # and parsers see identical output.
18
+
19
+ set -uo pipefail
20
+
21
+ # SAPS_->S4L_ env mirror (brand rename 2026-07-03): old plists/tasks still
22
+ # export SAPS_*; new code reads S4L_*. Copy names, never values via eval.
23
+ while IFS='=' read -r _k _; do
24
+ case "$_k" in SAPS_*) _n="S4L_${_k#SAPS_}"; eval "[ -n \"\${$_n+x}\" ] || export $_n=\"\${$_k}\"";; esac
25
+ done <<EOF_ENV
26
+ $(env | grep '^SAPS_' | cut -d= -f1 | sed 's/$/=/')
27
+ EOF_ENV
28
+
29
+ if [ $# -lt 2 ]; then
30
+ echo "Usage: run_claude.sh <script_tag> <claude args...>" >&2
31
+ exit 2
32
+ fi
33
+
34
+ SCRIPT_TAG="$1"; shift
35
+
36
+ # If the caller pre-set CLAUDE_SESSION_ID, honor it. This lets calling
37
+ # scripts inject the same UUID into their prompt (e.g. for SQL inserts that
38
+ # need to stamp claude_session_id) before invoking the wrapper.
39
+ SESSION_ID="${CLAUDE_SESSION_ID:-$(uuidgen | tr 'A-Z' 'a-z')}"
40
+ export CLAUDE_SESSION_ID="$SESSION_ID"
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Queue provider seam (added 2026-06-23) — customer boxes have no `claude` CLI.
44
+ #
45
+ # On a .mcpb install the runtime provisions Python only; there is no `claude`
46
+ # binary to exec. When S4L_CLAUDE_PROVIDER=queue, route this call through the
47
+ # job queue instead: scripts/claude_job.py enqueues the prompt + --json-schema,
48
+ # BLOCKS until a Claude Desktop scheduled task ("saps-phase1-query" /
49
+ # "saps-phase2b-draft") processes it, and prints a claude `--output-format json`
50
+ # -shaped envelope to stdout so the pipeline's existing parsers are unchanged.
51
+ #
52
+ # Provider unset (your own machines, every launchd plist) => fall straight
53
+ # through to the real `claude -p` below, byte-for-byte unchanged. The seam lives
54
+ # here, in the single chokepoint every claude call funnels through, so NO
55
+ # pipeline script needs to know whether it's on a customer box or yours.
56
+ #
57
+ # The prompt reaches us either as a trailing positional arg (Phase 1 queries) or
58
+ # piped on stdin (Phase 2b prep); claude_job.py handles both. `exec` inherits
59
+ # stdin so the piped form passes through, and propagates the helper's exit code
60
+ # (0 = result, 79 = timed-out/blocked like a quota skip, 1 = not queue-eligible).
61
+ # ---------------------------------------------------------------------------
62
+ if [ "${S4L_CLAUDE_PROVIDER:-}" = "queue" ]; then
63
+ S4L_QUEUE_REPO="${S4L_REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
64
+ S4L_QUEUE_PY="${S4L_PYTHON:-python3}"
65
+ exec "$S4L_QUEUE_PY" "$S4L_QUEUE_REPO/scripts/claude_job.py" \
66
+ provider --tag "$SCRIPT_TAG" -- "$@"
67
+ fi
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Quota preflight + post-hoc detection (added 2026-05-02).
71
+ #
72
+ # Background: if claude is hitting an org-level cap (monthly usage cap,
73
+ # daily token cap, context-window exceeded on every prompt, credit balance
74
+ # zero, persistent 429), retrying every cadence-tick burns nothing useful —
75
+ # it just guarantees an empty envelope back to the pipeline, which then
76
+ # fails noisily downstream (cf. 2026-05-01 19:23 twitter-cycle that died
77
+ # in Phase 1 on the org monthly limit and produced 0 tweets, then 2 more
78
+ # cycles fired into the same wall).
79
+ #
80
+ # Mechanism (see scripts/preflight.sh for full design):
81
+ # - At start: check /tmp/sa-claude-blocked.json. If `blocked_until > now`,
82
+ # exit 79 immediately. The wrapper exits visibly (stderr `[skipped]`
83
+ # line) and the calling pipeline can either (a) abort gracefully on
84
+ # non-zero, or (b) check `$? == 79` and treat as "skip cycle" rather
85
+ # than "real failure".
86
+ # - After claude exits: scan SIDE_LOG for known fatal-quota patterns. On
87
+ # match, write a fresh stamp (10 min default) and force exit 79.
88
+ # - On a clean claude run, if a stamp is present, clear it — the cap has
89
+ # lifted and we shouldn't gate the next cycle.
90
+ #
91
+ # Block window = 10 min. The next launchd fire after expiry will retry
92
+ # claude for real. If the underlying cap is still in place, we re-stamp
93
+ # and skip again. This recovers automatically within 10 min of the cap
94
+ # being lifted, without piling up backlog or burning cycles in the gap.
95
+ # ---------------------------------------------------------------------------
96
+ SA_PREFLIGHT="$(cd "$(dirname "$0")" && pwd)/preflight.sh"
97
+ SA_QUOTA_PREFLIGHT_OK=0
98
+ if [ -f "$SA_PREFLIGHT" ]; then
99
+ # shellcheck source=/dev/null
100
+ source "$SA_PREFLIGHT"
101
+ SA_QUOTA_PREFLIGHT_OK=1
102
+ # If a prior run stamped a still-valid block, exit 79 with a [skipped:]
103
+ # log line. We don't reuse preflight_skip_if_claude_blocked verbatim
104
+ # because that helper exits 0 (suitable for launchd wrappers); here we
105
+ # want exit 79 so the *caller pipeline* can tell "claude blocked" from
106
+ # "claude ran cleanly but returned no candidates".
107
+ if [ -f "$SA_CLAUDE_BLOCK_STAMP" ]; then
108
+ SA_BLOCK_REMAINING=$(/usr/bin/python3 - "$SA_CLAUDE_BLOCK_STAMP" <<'PY'
109
+ import json, sys
110
+ from datetime import datetime, timezone
111
+ try:
112
+ with open(sys.argv[1]) as f:
113
+ d = json.load(f)
114
+ bu = d.get("blocked_until", "")
115
+ if not bu:
116
+ print(0); sys.exit(0)
117
+ until = datetime.fromisoformat(bu.replace("Z", "+00:00"))
118
+ now = datetime.now(timezone.utc)
119
+ print(int(max(0, (until - now).total_seconds())))
120
+ except Exception:
121
+ print(0)
122
+ PY
123
+ )
124
+ if [ "${SA_BLOCK_REMAINING:-0}" -gt 0 ]; then
125
+ SA_BLOCK_REASON=$(/usr/bin/python3 -c "import json; print(json.load(open('$SA_CLAUDE_BLOCK_STAMP')).get('reason','unknown'))" 2>/dev/null)
126
+ echo "[run_claude] skipped: claude_blocked reason=$SA_BLOCK_REASON expires_in=${SA_BLOCK_REMAINING}s script=$SCRIPT_TAG; exit 79" >&2
127
+ exit 79
128
+ fi
129
+ fi
130
+ fi
131
+
132
+ # Auto-detect the platform agent from --mcp-config and signal the PreToolUse
133
+ # hooks (~/.claude/hooks/<platform>-agent-lock.sh) to bypass the cross-session
134
+ # block check. Rationale: every caller of run_claude.sh inside this repo is a
135
+ # launchd-managed pipeline that has already acquired the shell-level
136
+ # <platform>-browser lock via skill/lock.sh BEFORE invoking us. The shell
137
+ # lock is the authoritative serializer; the hook lock used to layer a second
138
+ # block on top, which produced false positives like the 2026-05-01 14:33
139
+ # LinkedIn run that paid $8.91 for an empty envelope because the *prior*
140
+ # LinkedIn cycle's JSONL was 57s stale (under the hook's 60s threshold)
141
+ # even though the shell lock had cleanly released.
142
+ #
143
+ # When SA_PIPELINE_LOCKED=1 is set, the hook trusts the shell layer and
144
+ # skips the cross-session check entirely.
145
+ for arg in "$@"; do
146
+ case "$arg" in
147
+ *linkedin-agent-mcp.json) export SA_PIPELINE_PLATFORM="linkedin"; export SA_PIPELINE_LOCKED=1 ;;
148
+ *twitter-harness-mcp.json) export SA_PIPELINE_PLATFORM="twitter"; export SA_PIPELINE_LOCKED=1 ;;
149
+ *reddit-agent-mcp.json) export SA_PIPELINE_PLATFORM="reddit"; export SA_PIPELINE_LOCKED=1 ;;
150
+ esac
151
+ done
152
+
153
+ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
154
+ START=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
155
+
156
+ # Allow one-off model override without touching locked scripts.
157
+ MODEL_ARGS=()
158
+ if [ -n "${MODEL_OVERRIDE:-}" ]; then
159
+ MODEL_ARGS=(--model "$MODEL_OVERRIDE")
160
+ fi
161
+
162
+ # Tee claude's stdout to a side file so we can extract the native SDK cost
163
+ # (streamRes.total_cost_usd) emitted in the final result event of stream-json /
164
+ # json output. Stdout still flows unchanged to whoever piped this wrapper, so
165
+ # downstream parsers see identical bytes. PIPESTATUS[0] preserves claude's
166
+ # exit code through the tee.
167
+ SIDE_LOG="$(mktemp -t sa_run_claude_stdout.XXXXXX)"
168
+
169
+ # Active-session sidecar. Lets the dashboard surface a live JSONL-tail link
170
+ # for the in-flight `claude` invocation while the phase is running, and lets
171
+ # investigators find the right transcript when run_claude.sh gets killed by
172
+ # the watchdog before log_claude_session.py can archive it. The file name is
173
+ # the wrapper PID so /api/claude-active can GC stale entries by checking
174
+ # whether the owning process is still alive.
175
+ ACTIVE_DIR="/tmp/sa-active-claude"
176
+ mkdir -p "$ACTIVE_DIR" 2>/dev/null || true
177
+ ACTIVE_FILE="$ACTIVE_DIR/$$.json"
178
+
179
+ # Process-group ID of the most recent claude invocation. Set by the run loop
180
+ # below (we run claude inside a `set -m` brace group + `&` so the forked
181
+ # subshell PID == its PGID, and claude + any grandchildren it spawns inherit
182
+ # that PGID). Used by _sa_cleanup to nuke orphan grandchildren that survive
183
+ # claude itself (e.g. a `find /` claude launched in the background and never
184
+ # waited on, which on 2026-05-01 burned CPU on PID 3187 long after the
185
+ # orchestrator exited because nothing was responsible for cleaning up after
186
+ # claude's kids).
187
+ CLAUDE_PG=""
188
+
189
+ # After-claude cleanup: explicitly remove the hook-layer lockfile for this
190
+ # session so the NEXT pipeline cycle doesn't see a stale lock from us. The
191
+ # unlock hook (PostToolUse) refreshes the lock timestamp to keep it alive
192
+ # across multi-tool sessions; without an explicit final cleanup, the lock
193
+ # survives session end and (per JSONL-mtime check) reads as "live" for up
194
+ # to 60s after we exit, causing the false-positive that produced the
195
+ # 2026-05-01 14:33 $8.91 empty-envelope run.
196
+ _sa_cleanup() {
197
+ rm -f "$SIDE_LOG"
198
+ rm -f "$ACTIVE_FILE"
199
+
200
+ # Sweep orphan claude descendants. Process groups survive the parent's
201
+ # death (kids reparented to launchd keep their PGID), so killing
202
+ # `kill -- -PGID` reaches every grandchild, including ones reparented
203
+ # to PID 1. Done before the lockfile cleanup so any orphan still
204
+ # holding a browser lock dies first.
205
+ if [ -n "$CLAUDE_PG" ]; then
206
+ local survivors
207
+ survivors=$(pgrep -g "$CLAUDE_PG" 2>/dev/null | grep -v "^$$\$" || true)
208
+ if [ -n "$survivors" ]; then
209
+ echo "[run_claude] sweeping orphan claude descendants in pg=$CLAUDE_PG: $(echo $survivors | tr '\n' ' ')" >&2
210
+ kill -TERM -- -"$CLAUDE_PG" 2>/dev/null || true
211
+ sleep 0.3
212
+ kill -KILL -- -"$CLAUDE_PG" 2>/dev/null || true
213
+ fi
214
+ fi
215
+
216
+ if [ -n "${SA_PIPELINE_PLATFORM:-}" ]; then
217
+ local lockfile="$HOME/.claude/${SA_PIPELINE_PLATFORM}-agent-lock.json"
218
+ if [ -f "$lockfile" ]; then
219
+ # Only remove if WE hold it — defensive in case a peer raced in.
220
+ local holder
221
+ holder=$(jq -r '.session_id // empty' "$lockfile" 2>/dev/null || echo "")
222
+ if [ "$holder" = "$SESSION_ID" ]; then
223
+ rm -f "$lockfile"
224
+ fi
225
+ fi
226
+ fi
227
+ }
228
+ # Cover EXIT (normal/return from script), INT (Ctrl-C from interactive),
229
+ # TERM (watchdog SIGTERM from scripts/watchdog_hung_runs.py), and HUP
230
+ # (controlling-tty death). SIGKILL is uncatchable; the active sidecar
231
+ # self-GCs on read in that case.
232
+ trap _sa_cleanup EXIT INT TERM HUP
233
+
234
+ # AUP-refusal retry loop. The Claude API safety filter occasionally refuses
235
+ # Phase A / SERP-driven prompts non-deterministically (the same prompt that
236
+ # refused at 18:13 succeeded at 17:58 today, 2026-05-01). Refusal output
237
+ # format: "API Error: Claude Code is unable to respond to this request,
238
+ # which appears to violate our Usage Policy". Retry up to 2 more times with
239
+ # 30s / 60s backoff and a fresh session UUID each retry (the prior session
240
+ # may have been flagged backend-side). Other RC failures pass through.
241
+ : > "$SIDE_LOG"
242
+ MAX_AUP_RETRIES=2
243
+ AUP_BACKOFF=(30 60)
244
+ # Transient-failure retry. The `claude` CLI gets reinstalled
245
+ # periodically (npm/curl installer), and the install briefly removes
246
+ # the old binary before writing the new one. Any invocation in that
247
+ # window gets exit 127 (command not found). Retry up to 3 times with
248
+ # 5s/10s/20s backoff before giving up. These retries do NOT count
249
+ # against the AUP-refusal budget, since the binary never actually ran.
250
+ # Caused two failed runs on 2026-05-01: 19:33 (engage-dm-replies, mid
251
+ # v2.1.126 install) and again on a follow-up cycle.
252
+ MAX_TRANSIENT_RETRIES=3
253
+ TRANSIENT_BACKOFF=(5 10 20)
254
+ RC=0
255
+ attempt=0
256
+ while :; do
257
+ attempt=$((attempt + 1))
258
+ transient_attempt=0
259
+ while :; do
260
+ : > "$SIDE_LOG"
261
+
262
+ # Refresh active-session sidecar each attempt — SESSION_ID rotates on
263
+ # AUP retries (line ~140), so the dashboard always points at the live
264
+ # transcript, not the abandoned one.
265
+ cat > "$ACTIVE_FILE" <<EOF
266
+ {
267
+ "session_id": "$SESSION_ID",
268
+ "script_tag": "$SCRIPT_TAG",
269
+ "wrapper_pid": $$,
270
+ "started_at": "$START",
271
+ "attempt": $attempt,
272
+ "platform": "${SA_PIPELINE_PLATFORM:-}"
273
+ }
274
+ EOF
275
+
276
+ # Run claude in its own process group so we can kill orphans on exit.
277
+ # `set -m` makes background jobs each get their own PGID == job PID;
278
+ # the brace-group pipeline runs in a forked subshell whose PID is the
279
+ # PGID, and claude inherits that PGID along with any descendants it
280
+ # spawns. PIPESTATUS is captured INSIDE the brace group so the
281
+ # subshell's exit code IS claude's exit code (not tee's), giving us
282
+ # the same exit semantics callers had before.
283
+ #
284
+ # BASH-VERSION CAVEAT: this whole pattern depends on bash putting each
285
+ # backgrounded job in its own process group when `set -m` is on, AND
286
+ # on `$!` returning the brace-group subshell's PID (which equals the
287
+ # new PGID). Verified on macOS bash 3.2 (the system default, 2026-05).
288
+ # If the system bash is ever upgraded (bash 5.x, ble.sh wrappers,
289
+ # `shopt -s lastpipe`, `set -o pipefail` toggles, the `inherit_errexit`
290
+ # option, or running under zsh-as-bash compatibility mode), re-verify
291
+ # the orphan sweep with /tmp/sa_pg_test.sh before assuming PGIDs still
292
+ # line up. Specifically check:
293
+ # 1. `pgrep -g $CLAUDE_PG` finds claude + grandchildren mid-run.
294
+ # 2. A `nohup ... &` grandchild that survives claude's exit still
295
+ # shows up in pgrep -g $CLAUDE_PG until the EXIT trap fires.
296
+ # 3. `kill -- -$CLAUDE_PG` actually reaches reparented (PPID=1)
297
+ # orphans — some shells silently strip job-control and put
298
+ # everything in the parent shell's PG, which would make the
299
+ # cleanup nuke the WRONG PG and either kill our own shell or
300
+ # no-op while the orphan keeps running.
301
+ # The 2026-05-01 PID 3187 incident was the symptom we built this
302
+ # against; if it ever returns, this assumption breaking is the
303
+ # first thing to suspect.
304
+ set -m
305
+ { claude --session-id "$SESSION_ID" ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} "$@" | tee -a "$SIDE_LOG"; exit "${PIPESTATUS[0]}"; } &
306
+ CLAUDE_PG=$!
307
+ set +m
308
+ wait "$CLAUDE_PG"
309
+ RC=$?
310
+ if [ "$RC" -ne 127 ]; then
311
+ break
312
+ fi
313
+ if [ "$transient_attempt" -ge "$MAX_TRANSIENT_RETRIES" ]; then
314
+ echo "[run_claude] claude binary still missing after $MAX_TRANSIENT_RETRIES retries; giving up with exit 127" >&2
315
+ break
316
+ fi
317
+ sleep_secs="${TRANSIENT_BACKOFF[$transient_attempt]:-20}"
318
+ transient_attempt=$((transient_attempt + 1))
319
+ echo "[run_claude] claude not found (exit 127, likely mid-reinstall); retrying in ${sleep_secs}s ($transient_attempt/$MAX_TRANSIENT_RETRIES)" >&2
320
+ sleep "$sleep_secs"
321
+ done
322
+ if grep -qE "(API Error|Error).*Usage Policy|appears to violate our Usage Policy" "$SIDE_LOG"; then
323
+ if [ "$attempt" -le "$MAX_AUP_RETRIES" ]; then
324
+ sleep_secs="${AUP_BACKOFF[$((attempt - 1))]:-60}"
325
+ echo "[run_claude] AUP refusal on attempt $attempt/$((MAX_AUP_RETRIES + 1)); retrying in ${sleep_secs}s with new session" >&2
326
+ sleep "$sleep_secs"
327
+ SESSION_ID="$(uuidgen | tr 'A-Z' 'a-z')"
328
+ export CLAUDE_SESSION_ID="$SESSION_ID"
329
+ # SIDE_LOG reset is handled at the top of the inner transient loop.
330
+ continue
331
+ fi
332
+ echo "[run_claude] AUP refusal on final attempt $attempt; giving up" >&2
333
+ fi
334
+ break
335
+ done
336
+
337
+ END=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Post-hoc quota-error detection (added 2026-05-02).
341
+ #
342
+ # Scan claude's stdout for known fatal-quota signals. On match: stamp the
343
+ # shared block file (so subsequent pipelines skip cleanly) and force exit 79.
344
+ # On no match AND a successful claude run, clear any stale stamp — the cap
345
+ # has lifted.
346
+ #
347
+ # Why post-hoc and not via streaming watchdog: claude already wrote the
348
+ # bytes by the time we'd notice, and the orchestrator turn is one shot per
349
+ # wrapper invocation. Stamping at exit + skipping the NEXT fire is cheaper
350
+ # than racing to interrupt the current one. The current run paid for the
351
+ # error already; we just protect the next 10 min of cadence-ticks.
352
+ # ---------------------------------------------------------------------------
353
+ if [ "$SA_QUOTA_PREFLIGHT_OK" = "1" ]; then
354
+ # Skip the regex-based quota classifier when Claude emitted a successful
355
+ # SDK result event. Background: 2026-05-07 the Anthropic x SpaceX news
356
+ # cycle produced tweets containing the literal phrase "5 hour rate limit".
357
+ # Phase 1 of run-twitter-cycle.sh dumps every scraped tweet's full text
358
+ # into stdout as structured_output, the classifier matched the phrase
359
+ # inside tweet bodies, stamped /tmp/sa-claude-blocked.json, and the
360
+ # subsequent Phase 2b-prep skipped with `claude_blocked` even though
361
+ # Claude itself ran cleanly. The shared stamp file then blocked ~12 runs
362
+ # across twitter-cycle, engage-twitter, and run-moltbook before clearing.
363
+ #
364
+ # The result event is a single-line JSON object emitted last, and only
365
+ # on a clean orchestrator turn:
366
+ # {"type":"result","subtype":"success","is_error":false,...}
367
+ # When that line is present AND claude exited 0, the run was clean by
368
+ # construction — there is no quota error to classify. Anchoring on
369
+ # `"subtype":"success"` (not just `is_error:false`) is intentional:
370
+ # tweet bodies may contain `is_error` strings, but `"subtype":"success"`
371
+ # is unique to the final result event.
372
+ SA_RESULT_OK=0
373
+ if [ "$RC" = "0" ] && grep -qE '"type":"result"[^}]*"subtype":"success"' "$SIDE_LOG" 2>/dev/null; then
374
+ SA_RESULT_OK=1
375
+ fi
376
+
377
+ SA_QUOTA_REASON=""
378
+ if [ "$SA_RESULT_OK" = "0" ]; then
379
+ SA_QUOTA_REASON="$(preflight_classify_claude_error "$SIDE_LOG" 2>/dev/null | head -1 | tr -d '[:space:]')"
380
+ fi
381
+
382
+ if [ -n "$SA_QUOTA_REASON" ]; then
383
+ # Stamp + force exit 79. Block window 600s (10 min). If the underlying
384
+ # cap is real, the next 10 min of fires skip cleanly. After 600s a
385
+ # fresh fire retries; success clears the stamp, repeat-failure
386
+ # refreshes it.
387
+ preflight_stamp_claude_blocked "$SA_QUOTA_REASON" 600 "$SCRIPT_TAG" "$SESSION_ID"
388
+ echo "[run_claude] quota error detected reason=$SA_QUOTA_REASON; skipping next 10 min of fires (exit 79)" >&2
389
+ # Still log the session for cost accounting before exiting.
390
+ ORCH_COST="$(grep -oE '"total_cost_usd"[[:space:]]*:[[:space:]]*[0-9]+(\.[0-9]+)?' "$SIDE_LOG" 2>/dev/null \
391
+ | tail -1 \
392
+ | sed -E 's/.*:[[:space:]]*//')"
393
+ ORCH_ARGS=()
394
+ if [ -n "$ORCH_COST" ]; then
395
+ ORCH_ARGS=(--orchestrator-cost-usd "$ORCH_COST")
396
+ fi
397
+ /usr/bin/python3 "$REPO_DIR/scripts/log_claude_session.py" \
398
+ --session-id "$SESSION_ID" \
399
+ --script "$SCRIPT_TAG" \
400
+ --started-at "$START" \
401
+ --ended-at "$END" \
402
+ ${ORCH_ARGS[@]+"${ORCH_ARGS[@]}"} >&2 || true
403
+ exit 79
404
+ fi
405
+ # Clean run AND no quota signal — clear any stale stamp (cap has lifted).
406
+ if [ "$RC" = "0" ] && [ -f "$SA_CLAUDE_BLOCK_STAMP" ]; then
407
+ preflight_clear_claude_block
408
+ fi
409
+ fi
410
+
411
+ # Pull the LAST total_cost_usd in the stdout (the result event is emitted last
412
+ # in both stream-json and json modes). Tolerant to spaces and floats; defaults
413
+ # to empty when the format doesn't expose a result event (e.g. interactive runs
414
+ # that crash before the result line) so log_claude_session.py just leaves the
415
+ # DB column NULL.
416
+ ORCH_COST="$(grep -oE '"total_cost_usd"[[:space:]]*:[[:space:]]*[0-9]+(\.[0-9]+)?' "$SIDE_LOG" 2>/dev/null \
417
+ | tail -1 \
418
+ | sed -E 's/.*:[[:space:]]*//')"
419
+
420
+ # Best-effort cost logging. Never let logging failures mask the wrapped
421
+ # command's exit code.
422
+ ORCH_ARGS=()
423
+ if [ -n "$ORCH_COST" ]; then
424
+ ORCH_ARGS=(--orchestrator-cost-usd "$ORCH_COST")
425
+ fi
426
+ python3 "$REPO_DIR/scripts/log_claude_session.py" \
427
+ --session-id "$SESSION_ID" \
428
+ --script "$SCRIPT_TAG" \
429
+ --started-at "$START" \
430
+ --ended-at "$END" \
431
+ ${ORCH_ARGS[@]+"${ORCH_ARGS[@]}"} >&2 || true
432
+
433
+ exit $RC