@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,988 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ claude_job.py — queue-backed substitute for `claude -p` on boxes without the
4
+ Claude CLI (customer .mcpb installs).
5
+
6
+ The deterministic pipeline never calls `claude` directly; every invocation goes
7
+ through scripts/run_claude.sh. When S4L_CLAUDE_PROVIDER=queue is set (only on
8
+ customer boxes — your own machines leave it unset and keep calling claude -p
9
+ directly), run_claude.sh delegates here instead of exec'ing the `claude` binary.
10
+ The pipeline is otherwise untouched: it enqueues the same prompt + json-schema it
11
+ would have passed to claude, blocks until a result appears, and gets back bytes
12
+ shaped exactly like claude's `--output-format json` envelope, so the existing
13
+ parsers don't change.
14
+
15
+ Three roles:
16
+ provider — (producer side, called by run_claude.sh) extract the prompt (stdin
17
+ or trailing arg) + --json-schema, enqueue a typed job, BLOCK until a
18
+ result lands, then print a claude-json-shaped envelope to stdout.
19
+ next — (consumer side, called by a Claude Desktop scheduled task) atomically
20
+ claim the oldest pending job of a given type and print it as JSON.
21
+ result — (consumer side) store the JSON the task produced (validated) and
22
+ unblock the waiting provider.
23
+
24
+ Queue = plain files under <state_dir>/claude-queue/. No DB, no network.
25
+ state_dir = $S4L_STATE_DIR or ~/.social-autoposter-mcp
26
+
27
+ Job-type mapping is by run_claude.sh script_tag. Only the PURE text->JSON calls
28
+ are queue-eligible; anything else exits non-zero so the caller's own fallback
29
+ runs (e.g. link_tail's mechanical concat). twitter-link-tail is intentionally
30
+ NOT mapped: the customer flow skips it for now.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import os
38
+ import re
39
+ import signal
40
+ import subprocess
41
+ import sys
42
+ import time
43
+ import uuid
44
+
45
+ # SAPS_->S4L_ env mirror (brand rename 2026-07-03): old launchd plists and
46
+ # scheduled-task prompts still export SAPS_*; this process reads S4L_*.
47
+ import s4l_env # noqa: E402 (lives next to this file in scripts/)
48
+
49
+ s4l_env.mirror()
50
+
51
+ # Best-effort menu-bar activity narration. Importable because this script's own
52
+ # directory (scripts/) is on sys.path[0] when run as `python3 .../claude_job.py`.
53
+ # A failure to import (or to write) must NEVER affect the queue's real work.
54
+ try:
55
+ import saps_activity as _activity # type: ignore
56
+ except Exception: # pragma: no cover - cosmetic only
57
+ _activity = None
58
+
59
+ # script_tag -> queue type. ONLY pure text->JSON claude calls belong here.
60
+ TAG_TO_TYPE = {
61
+ "run-twitter-cycle-queries": "twitter-query",
62
+ "run-twitter-cycle-prep": "twitter-prep",
63
+ "feedback-digest": "feedback-digest",
64
+ }
65
+
66
+ # queue type -> (activity state, label) the menu bar shows while the job is in
67
+ # flight. Phase-1 queries drive the X search ("finding threads"); Phase-2b prep is
68
+ # the reply drafting. Both the launchd provider (which blocks for minutes) and the
69
+ # scheduled-task worker (which does the LLM turn) narrate from this one map.
70
+ TYPE_TO_ACTIVITY = {
71
+ "twitter-query": ("scanning", "queries"),
72
+ "twitter-prep": ("drafting", "draft"),
73
+ "feedback-digest": ("learning", "feedback"),
74
+ }
75
+
76
+ # queue type -> execution notes PREPENDED to the prompt sidecar at claim time.
77
+ # This keeps the scheduled-task worker fully type-blind: its SKILL.md is one
78
+ # generic claim -> follow -> submit loop, and anything a specific job type
79
+ # needs the executor to know (pacing, persist cadence) travels WITH the job.
80
+ # The twitter-prep note exists because the host kills an unattended session
81
+ # ~90s after its LAST tool call; drafting a whole batch in one silent turn
82
+ # starves that clock (the v6 worker-prompt lesson, moved under the hood).
83
+ TYPE_TO_WORKER_NOTES = {
84
+ "twitter-prep": (
85
+ "WORKER EXECUTION NOTES (queue metadata; follow while executing the "
86
+ "prompt below): this unattended session is terminated ~90 seconds after "
87
+ "your LAST tool call. The prompt asks you to draft replies for SEVERAL "
88
+ "candidates. Do NOT draft them all silently in one turn. Work ONE "
89
+ "candidate at a time: draft its reply, then IMMEDIATELY run that "
90
+ "candidate's log_draft.py persist command exactly as the prompt's "
91
+ "persist step specifies (a quick Bash call), THEN move to the next. "
92
+ "Those per-candidate Bash calls keep the session alive. Begin the first "
93
+ "candidate promptly. Only after EVERY candidate is drafted and logged "
94
+ "do you assemble and submit the single result JSON."
95
+ ),
96
+ }
97
+
98
+
99
+ def _act_write(qtype: str) -> None:
100
+ if _activity is None:
101
+ return
102
+ sl = TYPE_TO_ACTIVITY.get(qtype)
103
+ if not sl:
104
+ return
105
+ try:
106
+ _activity.write(sl[0], f"{sl[1]}: starting")
107
+ except Exception:
108
+ pass
109
+
110
+
111
+ def _act_clear() -> None:
112
+ if _activity is None:
113
+ return
114
+ try:
115
+ _activity.clear()
116
+ except Exception:
117
+ pass
118
+
119
+
120
+ def _fmt_dur(secs: float) -> str:
121
+ """Compact human duration for the menu-bar label: '45s', '12m'."""
122
+ s = int(max(0, secs))
123
+ return f"{s}s" if s < 60 else f"{s // 60}m"
124
+
125
+
126
+ def _act_write_progress(
127
+ qtype: str, created: float, claimed_at: float | None, now: float
128
+ ) -> None:
129
+ """Granular in-flight menu-bar label, so a wedged cycle reads as the TRUTH
130
+ instead of a static 'drafting replies' that lingers for the whole producer
131
+ timeout (the failure mode where the worker never claims the job, or claims it
132
+ and dies mid-run, looked identical to healthy drafting before this).
133
+
134
+ - job still in pending/ (no worker has claimed it) -> '<base> (queued <dur>)'
135
+ counting from enqueue. A growing 'queued 18m' is the unmistakable tell that
136
+ a scheduled-task worker is orphaned and nothing is draining.
137
+ - job claimed (pending file gone -> moved to running/) -> '<base> (<dur>)'
138
+ counting from the claim, i.e. real drafting elapsed.
139
+
140
+ Purely cosmetic and best-effort: a write failure must never affect the queue."""
141
+ if _activity is None:
142
+ return
143
+ sl = TYPE_TO_ACTIVITY.get(qtype)
144
+ if not sl:
145
+ return
146
+ state, base = sl
147
+ if claimed_at is None:
148
+ label = f"{base}: queued {_fmt_dur(now - created)}"
149
+ else:
150
+ label = f"{base}: {_fmt_dur(now - claimed_at)}"
151
+ try:
152
+ _activity.write(state, label)
153
+ except Exception:
154
+ pass
155
+
156
+ # claude flags that consume the following argv token as their value, so the
157
+ # value is never mistaken for the positional prompt.
158
+ VALUE_FLAGS = {
159
+ "--mcp-config",
160
+ "--json-schema",
161
+ "--output-format",
162
+ "--input-format",
163
+ "--model",
164
+ "--fallback-model",
165
+ "--system-prompt",
166
+ "--append-system-prompt",
167
+ "--permission-mode",
168
+ "--allowedTools",
169
+ "--disallowedTools",
170
+ "--add-dir",
171
+ "--session-id",
172
+ "--settings",
173
+ }
174
+
175
+ POLL_INTERVAL_S = 2.0
176
+ # Per-call budget the producer waits for ONE claude job (a query or a draft-prep
177
+ # reasoning turn). Was 600s, which sat right at the edge of the draft call's real
178
+ # ~9-10 min need: on the QA box ~41% of twitter-prep jobs breached 600s and got
179
+ # dropped (each drop = a lost draft AND an orphaned over-running worker that
180
+ # becomes a leaked agent-mode session). The DIRECT launchd `claude -p` lane has no
181
+ # such per-call cap — its draft call just runs inside the 180-min cycle watchdog —
182
+ # so 600s here made the queue lane diverge and silently fail where the direct lane
183
+ # would not. 1800s (30 min) = 3x the real draft need, matching the sibling Twitter
184
+ # engagement claude cap (engage-twitter Phase B gtimeout 1800), which removes the
185
+ # drops while staying a bounded per-call value (not the whole-cycle budget).
186
+ # COUPLING: reap_stale_claude_sessions.py reaps leaked workers at THIS value plus a
187
+ # fixed margin (S4L_REAPER_AGE_MARGIN_SEC, default 300s) and MUST stay > it (a lower
188
+ # reaper would SIGKILL a draft the producer is still waiting on). Both read
189
+ # S4L_CLAUDE_QUEUE_TIMEOUT and both default to 1800; keep them in lockstep if you
190
+ # change the base.
191
+ DEFAULT_TIMEOUT_S = int(os.environ.get("S4L_CLAUDE_QUEUE_TIMEOUT", "1800"))
192
+ # Jobs older than this (pending or running) are swept — a job nobody drained in
193
+ # this long is a leftover from a timed-out producer or a dead worker, and keeping
194
+ # it would feed a stale prompt to a worker much later. Default 2x the timeout.
195
+ STALE_TTL_S = int(os.environ.get("S4L_CLAUDE_QUEUE_STALE_TTL", str(DEFAULT_TIMEOUT_S * 2)))
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # EXPERIMENT (2026-06-29, per user): hide the "Top Posts by Project" few-shot
199
+ # block from the Phase-2b drafting prompt.
200
+ #
201
+ # WHY: that block is ~42% of the prompt — top_performers.py emits up to 5 curated
202
+ # example posts for EVERY project (~20 projects = ~74 examples, ~16k tokens), and
203
+ # it is NOT scoped to the projects in this cycle's candidates. On a `.mcpb` box the
204
+ # drafting runs inside a Claude Desktop scheduled-task session that the app
205
+ # SIGTERMs after ~120s; the 38k-token prompt pushed the worker past that window
206
+ # before it could submit, so jobs never drained. Dropping this block shrinks the
207
+ # prompt to ~22k tokens (one Read, faster turns) while KEEPING the "Best Example
208
+ # Per Style" block (which is style-scoped and tiny).
209
+ #
210
+ # This is a DELIVERY-LAYER trim only: the generator (top_performers.py, locked) is
211
+ # untouched — we strip the section from the prompt text right before it is queued.
212
+ # A loud marker is left in its place and a provider.log line is emitted, so it is
213
+ # obvious the section was intentionally hidden, not lost.
214
+ #
215
+ # DEFAULT: ON (hidden). Set S4L_HIDE_TOP_BY_PROJECT=0 (or false/no) to restore the
216
+ # full per-project example block.
217
+ HIDE_TOP_BY_PROJECT = (
218
+ os.environ.get("S4L_HIDE_TOP_BY_PROJECT", "1").strip().lower()
219
+ not in ("0", "false", "no", "off", "")
220
+ )
221
+
222
+
223
+ def _strip_top_by_project(prompt: str) -> str:
224
+ """Remove the '### Top Posts by Project' block from a drafting prompt.
225
+
226
+ Returns the prompt with that one section replaced by a clearly-labelled
227
+ HIDDEN marker (so anyone reading the prompt sees it was intentionally hidden
228
+ behind S4L_HIDE_TOP_BY_PROJECT, not silently dropped). No-op if the block is
229
+ absent (e.g. query prompts, or a report that produced no per-project posts).
230
+ The section runs from its '### Top Posts by Project' header to the next '##'/
231
+ '###' header (normally '### Bottom N Posts').
232
+ """
233
+ start = "### Top Posts by Project"
234
+ i = prompt.find(start)
235
+ if i < 0:
236
+ return prompt
237
+ m = re.search(r"\n#{2,3} ", prompt[i + len(start):])
238
+ j = (i + len(start) + m.start() + 1) if m else len(prompt)
239
+ marker = (
240
+ "### Top Posts by Project — HIDDEN\n"
241
+ "[This per-project few-shot block (~16k tokens) was hidden at the delivery "
242
+ "layer by claude_job.py via S4L_HIDE_TOP_BY_PROJECT (default ON, added "
243
+ "2026-06-29) so the drafting worker fits Claude Desktop's ~120s "
244
+ "scheduled-session window. Set S4L_HIDE_TOP_BY_PROJECT=0 to restore it. "
245
+ "The 'Best Example Per Style' block above is kept.]\n\n"
246
+ )
247
+ return prompt[:i] + marker + prompt[j:]
248
+
249
+
250
+
251
+ # --------------------------------------------------------------------------- #
252
+ # Queue layout #
253
+ # --------------------------------------------------------------------------- #
254
+ def _apply_state_dir_override(ns) -> None:
255
+ """`--state-dir` wins over $S4L_STATE_DIR. The scheduled-task worker passes
256
+ it explicitly so it always reads the SAME queue the launchd kicker writes to,
257
+ regardless of what env the task session inherits."""
258
+ sd = getattr(ns, "state_dir", None)
259
+ if sd:
260
+ os.environ["S4L_STATE_DIR"] = sd
261
+
262
+
263
+ def state_dir() -> str:
264
+ return os.environ.get("S4L_STATE_DIR") or os.path.join(
265
+ os.path.expanduser("~"), ".social-autoposter-mcp"
266
+ )
267
+
268
+
269
+ def queue_root() -> str:
270
+ return os.path.join(state_dir(), "claude-queue")
271
+
272
+
273
+ def pending_dir(qtype: str) -> str:
274
+ return os.path.join(queue_root(), "pending", qtype)
275
+
276
+
277
+ def running_dir() -> str:
278
+ return os.path.join(queue_root(), "running")
279
+
280
+
281
+ def result_dir() -> str:
282
+ return os.path.join(queue_root(), "result")
283
+
284
+
285
+ def heartbeat_path() -> str:
286
+ """Single file the worker stamps each time it claims or completes a job. Its
287
+ mtime/contents prove the scheduled-task worker is actually draining the queue
288
+ (vs. the SKILL.md merely existing on disk, which survives a Claude account
289
+ switch and gave a false-green). Read by the MCP's autopilot liveness check and
290
+ the stall detector. Empty-queue ("no jobs") fires deliberately do NOT stamp it
291
+ — we want "is a job getting DRAINED", not "did a worker tick"."""
292
+ return os.path.join(queue_root(), "worker-heartbeat.json")
293
+
294
+
295
+ def _stamp_heartbeat(event: str, qtype: str | None = None) -> None:
296
+ """Best-effort: never let a heartbeat write failure break the queue."""
297
+ try:
298
+ os.makedirs(queue_root(), exist_ok=True)
299
+ _atomic_write(
300
+ heartbeat_path(),
301
+ {"at": time.time(), "event": event, "type": qtype or ""},
302
+ )
303
+ except Exception:
304
+ pass
305
+
306
+
307
+ def drain_status_path() -> str:
308
+ """LATCHED autopilot-liveness marker the producer maintains: how many times in
309
+ a row it has enqueued a job and timed out with NO worker draining it. Unlike a
310
+ pending-job age check, this persists across the gaps between cycles (the
311
+ producer removes the job on timeout, so there's no pending file to look at
312
+ between cycles) — so the menu bar / dashboard / Sentry watcher can show a
313
+ CONTINUOUS stall instead of one that flickers off every time a job is removed.
314
+ Cleared (consecutive_timeouts=0) the moment a draft actually drains."""
315
+ return os.path.join(queue_root(), "drain-status.json")
316
+
317
+
318
+ def _read_drain_status() -> dict:
319
+ try:
320
+ with open(drain_status_path()) as f:
321
+ return json.load(f)
322
+ except Exception:
323
+ return {}
324
+
325
+
326
+ def _mark_drain_success() -> None:
327
+ """A job drained successfully -> clear the latched stall."""
328
+ try:
329
+ os.makedirs(queue_root(), exist_ok=True)
330
+ _atomic_write(
331
+ drain_status_path(),
332
+ {"consecutive_timeouts": 0, "last_success_at": time.time()},
333
+ )
334
+ except Exception:
335
+ pass
336
+
337
+
338
+ def _bump_drain_timeout() -> None:
339
+ """The producer gave up waiting -> latch/escalate the stall."""
340
+ try:
341
+ os.makedirs(queue_root(), exist_ok=True)
342
+ cur = _read_drain_status()
343
+ prev = int(cur.get("consecutive_timeouts", 0) or 0)
344
+ cur["consecutive_timeouts"] = prev + 1
345
+ cur["last_timeout_at"] = time.time()
346
+ _atomic_write(drain_status_path(), cur)
347
+ except Exception:
348
+ pass
349
+
350
+
351
+ # --------------------------------------------------------------------------- #
352
+ # Opt-in worker self-reap (2026-06-27) #
353
+ # --------------------------------------------------------------------------- #
354
+ # A scheduled-task worker turn finishes its one queue iteration but Claude
355
+ # Desktop keeps the agent-mode `claude` process warm (`--input-format
356
+ # stream-json`), so finished workers pile up and leak RAM. The launchd reaper
357
+ # (reap_stale_claude_sessions.py) is the GUARANTEE that bounds this. This opt-in
358
+ # path is a faster, source-side trim: once THIS worker is provably done (no work
359
+ # to do, or its result is already on disk), terminate OUR OWN session so it never
360
+ # becomes part of the standing pool. Strictly off unless S4L_WORKER_SELF_REAP is
361
+ # set, so it ships dormant and cannot destabilize the default behaviour.
362
+ #
363
+ # Safety properties:
364
+ # * No-op unless the env flag is set.
365
+ # * Only ever targets a process in OUR OWN ancestry that matches the reaper's
366
+ # worker signature (claude-code agent-mode session). The producer side
367
+ # (run-twitter-cycle.sh -> python) has no such ancestor, so a misplaced call
368
+ # there finds nothing and does nothing.
369
+ # * Detached + delayed: a double-forked grandchild waits a few seconds (so the
370
+ # current turn returns and prints its final line normally) before signalling.
371
+ # * Re-verifies the target's cmdline right before SIGTERM, so a recycled PID is
372
+ # never signalled.
373
+ # * Best-effort throughout: never raises into the caller, never changes the
374
+ # worker's exit code, never touches the result already written to disk.
375
+ _SELF_REAP_SIG = (
376
+ "claude-code/",
377
+ "/Contents/MacOS/claude ",
378
+ "--input-format stream-json",
379
+ "local-agent-mode-sessions",
380
+ )
381
+ _SELF_REAP_UUID_RE = re.compile(r"local-agent-mode-sessions/[0-9a-fA-F-]{36}")
382
+
383
+
384
+ def _self_reap_enabled() -> bool:
385
+ return os.environ.get("S4L_WORKER_SELF_REAP", "").strip().lower() in (
386
+ "1",
387
+ "true",
388
+ "yes",
389
+ "on",
390
+ )
391
+
392
+
393
+ def _ps_pid_map() -> dict:
394
+ """pid -> (ppid, command) for every process. Empty dict on any failure."""
395
+ out: dict = {}
396
+ try:
397
+ res = subprocess.run(
398
+ ["/bin/ps", "-axo", "pid=,ppid=,command="],
399
+ capture_output=True,
400
+ text=True,
401
+ timeout=10,
402
+ )
403
+ except Exception:
404
+ return out
405
+ for line in res.stdout.splitlines():
406
+ parts = line.strip().split(None, 2)
407
+ if len(parts) < 3:
408
+ continue
409
+ try:
410
+ pid, ppid = int(parts[0]), int(parts[1])
411
+ except ValueError:
412
+ continue
413
+ out[pid] = (ppid, parts[2])
414
+ return out
415
+
416
+
417
+ def _find_own_session(psmap: dict):
418
+ """Walk OUR ancestry to the nearest claude agent-mode worker session that
419
+ matches the reaper signature. Returns (pid, uuid_token) or None."""
420
+ pid = os.getpid()
421
+ seen: set = set()
422
+ for _ in range(40): # bounded climb up the process tree
423
+ if pid in seen:
424
+ break
425
+ seen.add(pid)
426
+ ent = psmap.get(pid)
427
+ if not ent:
428
+ break
429
+ ppid, cmd = ent
430
+ if all(t in cmd for t in _SELF_REAP_SIG) and "Helpers/disclaimer" not in cmd:
431
+ m = _SELF_REAP_UUID_RE.search(cmd)
432
+ if m:
433
+ return pid, m.group(0)
434
+ pid = ppid
435
+ if pid <= 1:
436
+ break
437
+ return None
438
+
439
+
440
+ def _maybe_self_reap(delay: float = 6.0) -> None:
441
+ """Opt-in: terminate our own finished worker session. See block comment above."""
442
+ if not _self_reap_enabled():
443
+ return
444
+ try:
445
+ found = _find_own_session(_ps_pid_map())
446
+ if not found:
447
+ return
448
+ target_pid, token = found
449
+ if os.fork() != 0:
450
+ return # the worker continues + exits normally
451
+ except Exception:
452
+ return
453
+ # child -> detach into its own session, then exit, orphaning the grandchild
454
+ try:
455
+ os.setsid()
456
+ if os.fork() != 0:
457
+ os._exit(0)
458
+ except Exception:
459
+ os._exit(0)
460
+ # grandchild (fully detached): wait, re-verify, signal
461
+ try:
462
+ time.sleep(delay)
463
+ cur = _ps_pid_map().get(target_pid)
464
+ if cur and token in cur[1]: # same session, not a recycled PID
465
+ try:
466
+ os.kill(target_pid, signal.SIGTERM)
467
+ except OSError:
468
+ pass
469
+ except Exception:
470
+ pass
471
+ os._exit(0)
472
+
473
+
474
+ def _ensure_dirs(qtype: str | None = None) -> None:
475
+ for d in (running_dir(), result_dir()):
476
+ os.makedirs(d, exist_ok=True)
477
+ if qtype:
478
+ os.makedirs(pending_dir(qtype), exist_ok=True)
479
+
480
+
481
+ def _atomic_write(path: str, obj) -> None:
482
+ tmp = f"{path}.tmp.{os.getpid()}"
483
+ with open(tmp, "w") as f:
484
+ json.dump(obj, f)
485
+ os.replace(tmp, path)
486
+
487
+
488
+ def _atomic_write_text(path: str, text: str) -> None:
489
+ tmp = f"{path}.tmp.{os.getpid()}"
490
+ with open(tmp, "w") as f:
491
+ f.write(text)
492
+ os.replace(tmp, path)
493
+
494
+
495
+ def _sweep_stale() -> int:
496
+ """Remove pending/running job files older than STALE_TTL_S. Returns count."""
497
+ removed = 0
498
+ now = time.time()
499
+ roots = [running_dir()]
500
+ pend = os.path.join(queue_root(), "pending")
501
+ if os.path.isdir(pend):
502
+ roots += [os.path.join(pend, d) for d in os.listdir(pend)]
503
+ for d in roots:
504
+ if not os.path.isdir(d):
505
+ continue
506
+ for name in os.listdir(d):
507
+ if not name.endswith(".json"):
508
+ continue
509
+ fp = os.path.join(d, name)
510
+ try:
511
+ with open(fp) as f:
512
+ created = json.load(f).get("created_at", 0)
513
+ if now - float(created) > STALE_TTL_S:
514
+ os.remove(fp)
515
+ removed += 1
516
+ except Exception:
517
+ continue
518
+ return removed
519
+
520
+
521
+ # --------------------------------------------------------------------------- #
522
+ # provider (producer side, run by run_claude.sh) #
523
+ # --------------------------------------------------------------------------- #
524
+ def _parse_claude_args(args: list[str]) -> tuple[str | None, str | None]:
525
+ """Return (trailing_prompt, schema_path) from the verbatim claude argv."""
526
+ schema_path = None
527
+ positionals: list[str] = []
528
+ i = 0
529
+ while i < len(args):
530
+ a = args[i]
531
+ if a == "--json-schema":
532
+ schema_path = args[i + 1] if i + 1 < len(args) else None
533
+ i += 2
534
+ continue
535
+ if a in VALUE_FLAGS:
536
+ i += 2
537
+ continue
538
+ if a.startswith("-"):
539
+ i += 1 # boolean flag (-p, --strict-mcp-config, --verbose, ...)
540
+ continue
541
+ positionals.append(a)
542
+ i += 1
543
+ prompt = positionals[-1] if positionals else None
544
+ return prompt, schema_path
545
+
546
+
547
+ def _plog(msg: str) -> None:
548
+ """Provider diagnostics go to a log file, NEVER stderr.
549
+
550
+ The pipeline captures this wrapper's output with `2>&1` and parses the FIRST
551
+ JSON value (raw_decode). Anything we print to stderr BEFORE the envelope (e.g.
552
+ an "enqueued, waiting" line) lands ahead of the JSON and breaks the parse with
553
+ "Expecting value: line 1 column 2". So stdout carries ONLY the final envelope
554
+ and stderr stays silent; humans read provider.log instead. (fix 2026-06-24)
555
+ """
556
+ try:
557
+ os.makedirs(queue_root(), exist_ok=True)
558
+ with open(os.path.join(queue_root(), "provider.log"), "a") as f:
559
+ f.write(f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} pid={os.getpid()} {msg}\n")
560
+ except Exception:
561
+ pass
562
+
563
+
564
+ def cmd_provider(ns) -> int:
565
+ _apply_state_dir_override(ns)
566
+ qtype = TAG_TO_TYPE.get(ns.tag)
567
+ if not qtype:
568
+ # Not a queue-eligible call. Exit non-zero so run_claude.sh's caller
569
+ # treats it as a claude failure and runs its own fallback path.
570
+ _plog(f"tag '{ns.tag}' is not queue-eligible; no provider")
571
+ return 1
572
+
573
+ stdin_text = ""
574
+ if not sys.stdin.isatty():
575
+ try:
576
+ stdin_text = sys.stdin.read()
577
+ except Exception:
578
+ stdin_text = ""
579
+
580
+ trailing_prompt, schema_path = _parse_claude_args(ns.claude_args)
581
+ prompt = stdin_text if stdin_text.strip() else (trailing_prompt or "")
582
+ if not prompt.strip():
583
+ _plog("empty prompt; nothing to enqueue")
584
+ return 1
585
+
586
+ schema_text = None
587
+ if schema_path and os.path.exists(schema_path):
588
+ try:
589
+ with open(schema_path) as f:
590
+ schema_text = f.read()
591
+ except Exception:
592
+ schema_text = None
593
+
594
+ # Delivery-layer trim: hide the "Top Posts by Project" block from drafting
595
+ # prompts so the worker fits the ~120s scheduled-session window. See
596
+ # HIDE_TOP_BY_PROJECT / _strip_top_by_project above. Drafting jobs only.
597
+ if qtype == "twitter-prep" and HIDE_TOP_BY_PROJECT:
598
+ _before_len = len(prompt)
599
+ prompt = _strip_top_by_project(prompt)
600
+ if len(prompt) != _before_len:
601
+ _plog(
602
+ "hid 'Top Posts by Project' block: -%d chars "
603
+ "(S4L_HIDE_TOP_BY_PROJECT on; set =0 to restore)"
604
+ % (_before_len - len(prompt))
605
+ )
606
+
607
+ job_id = uuid.uuid4().hex
608
+ created = time.time()
609
+ _ensure_dirs(qtype)
610
+ _sweep_stale() # clear leftovers from prior timed-out producers before enqueuing
611
+ job = {
612
+ "job_id": job_id,
613
+ "type": qtype,
614
+ "tag": ns.tag,
615
+ "prompt": prompt,
616
+ "schema": schema_text,
617
+ "created_at": created,
618
+ }
619
+ # Filename is <created_ns>_<job_id>.json so a plain sorted() listing is FIFO.
620
+ fname = f"{int(created * 1e9):020d}_{job_id}.json"
621
+ pending_path = os.path.join(pending_dir(qtype), fname)
622
+ running_path = os.path.join(running_dir(), fname)
623
+ _atomic_write(pending_path, job)
624
+ _plog(f"enqueued {qtype} job {job_id}; waiting for a scheduled task (timeout {ns.timeout}s)")
625
+ # Narrate the (multi-minute) block to the menu bar. The launchd draft lane has
626
+ # no other activity writer, so without this the box looks idle while it works.
627
+ # Cleared by run-draft-and-publish.sh's exit trap at cycle end (and by the
628
+ # worker's cmd_result), so we deliberately do NOT clear on the success path
629
+ # here — that would flicker the indicator off between the cycle's claude calls.
630
+ _act_write(qtype)
631
+
632
+ res_path = os.path.join(result_dir(), f"{job_id}.json")
633
+ deadline = created + ns.timeout
634
+ last_hb = created # last menu-bar heartbeat (see below)
635
+ claimed_at = None # set the moment a worker moves the job pending/ -> running/
636
+ while time.time() < deadline:
637
+ now = time.time()
638
+ # A worker claims a job by atomically renaming pending/ -> running/, so the
639
+ # pending file vanishing is our signal that drafting actually STARTED (vs.
640
+ # the job still sitting unclaimed). Latch the claim time once so the label
641
+ # can distinguish "waiting for a worker" from "worker is drafting" and show
642
+ # the right elapsed for each.
643
+ if claimed_at is None and not os.path.exists(pending_path):
644
+ claimed_at = now
645
+ # Heartbeat the menu-bar label so its `since` stays fresh for the whole
646
+ # multi-minute block. The consumer (s4l_state.read_activity) ages a label
647
+ # out after a TTL, so without this refresh a long drafting turn would look
648
+ # stale and the spinner would wrongly blink to idle. Refreshing here means
649
+ # the label is fresh EXACTLY while real work is happening, and stops the
650
+ # instant we return or die — so the consumer's TTL can then expire it
651
+ # instead of it freezing forever. Throttled to ~10s; best-effort only. The
652
+ # label now carries claim-state + elapsed so a stuck cycle reads honestly
653
+ # ("queued 18m") instead of a reassuring static "drafting replies".
654
+ if now - last_hb >= 10:
655
+ _act_write_progress(qtype, created, claimed_at, now)
656
+ last_hb = now
657
+ if os.path.exists(res_path):
658
+ try:
659
+ with open(res_path) as f:
660
+ res = json.load(f)
661
+ except Exception:
662
+ time.sleep(POLL_INTERVAL_S)
663
+ continue
664
+ os.remove(res_path)
665
+ if res.get("status") == "error":
666
+ _plog(f"job {job_id} returned error: {res.get('error', 'unknown')}")
667
+ return 1
668
+ obj = res.get("result")
669
+ # Emit a claude `--output-format json` shaped envelope so the
670
+ # pipeline's existing raw_decode + structured_output/result parser
671
+ # is byte-compatible.
672
+ envelope = {
673
+ "type": "result",
674
+ "subtype": "success",
675
+ "is_error": False,
676
+ "structured_output": obj,
677
+ "result": json.dumps(obj) if not isinstance(obj, str) else obj,
678
+ }
679
+ sys.stdout.write(json.dumps(envelope))
680
+ sys.stdout.flush()
681
+ # A worker drained this job -> the autopilot is alive; clear any latched
682
+ # stall so the menu bar / dashboard / Sentry watcher recover.
683
+ _mark_drain_success()
684
+ return 0
685
+ time.sleep(POLL_INTERVAL_S)
686
+
687
+ # Don't leak the job: remove it from pending/running so it can't be drafted
688
+ # later with a stale prompt (and so /tmp doesn't accumulate stuck jobs).
689
+ for p in (pending_path, running_path):
690
+ try:
691
+ os.remove(p)
692
+ except OSError:
693
+ pass
694
+ # We gave up waiting for a worker — drop the "drafting" menu-bar label we kept
695
+ # re-asserting while blocked. Otherwise it lingers and the menu bar shows
696
+ # "drafting replies" forever (masking the autopilot-stalled ⚠) even though no
697
+ # routine ever claimed the job.
698
+ _act_clear()
699
+ # Latch the stall so it persists across the gap until the next cycle enqueues
700
+ # (no pending file exists between cycles, so an instantaneous queue check would
701
+ # flicker the ⚠ off). Cleared only when a draft actually drains.
702
+ _bump_drain_timeout()
703
+ _plog(f"timed out after {ns.timeout}s waiting for job {job_id} ({qtype}); removed the job")
704
+ return 79 # mirror run_claude.sh's "blocked, skip cleanly" exit code
705
+
706
+
707
+ # --------------------------------------------------------------------------- #
708
+ # next (consumer side, run by a scheduled task) #
709
+ # --------------------------------------------------------------------------- #
710
+ def _agent_session_pid():
711
+ """Best-effort: the Claude agent-mode SESSION pid running THIS worker — the
712
+ exact process the stale-session reaper (reap_stale_claude_sessions.py) would
713
+ target. We climb our own process tree to the ancestor whose cmd carries the
714
+ reaper's worker signature ('claude-code/' + 'local-agent-mode-sessions') and
715
+ return its pid, so the claim can be stamped with it and the reaper can SPARE
716
+ that session for the whole drafting turn (instead of SIGTERMing it at the short
717
+ grace window — the 2026-06-29 draft-kill regression). None if not identifiable;
718
+ the reaper then falls back to its newest-spare heuristic.
719
+ """
720
+ try:
721
+ out = subprocess.run(
722
+ ["/bin/ps", "-axo", "pid=,ppid=,command="],
723
+ capture_output=True, text=True, timeout=10,
724
+ ).stdout
725
+ info = {}
726
+ for line in out.splitlines():
727
+ m = re.match(r"\s*(\d+)\s+(\d+)\s+(.*)$", line)
728
+ if m:
729
+ info[int(m.group(1))] = (int(m.group(2)), m.group(3))
730
+ pid = os.getpid()
731
+ for _ in range(16): # bounded climb up the tree
732
+ ent = info.get(pid)
733
+ if not ent or ent[0] <= 1:
734
+ break
735
+ ppid = ent[0]
736
+ pcmd = info.get(ppid, (0, ""))[1]
737
+ if ("claude-code/" in pcmd) and ("local-agent-mode-sessions" in pcmd):
738
+ return ppid
739
+ pid = ppid
740
+ except Exception:
741
+ return None
742
+ return None
743
+
744
+
745
+ def cmd_next(ns) -> int:
746
+ _apply_state_dir_override(ns)
747
+ qtype = ns.type
748
+ # "any" (the universal type-blind worker) scans EVERY pending type dir;
749
+ # a comma list scans those types; a single type keeps legacy behavior.
750
+ # Job filenames start with a zero-padded nanosecond timestamp, so one
751
+ # global lexicographic sort is oldest-first across types.
752
+ if qtype == "any":
753
+ _ensure_dirs()
754
+ root = os.path.join(queue_root(), "pending")
755
+ try:
756
+ scan_types = sorted(
757
+ d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))
758
+ )
759
+ except FileNotFoundError:
760
+ scan_types = []
761
+ else:
762
+ scan_types = [t.strip() for t in qtype.split(",") if t.strip()]
763
+ for t in scan_types:
764
+ _ensure_dirs(t)
765
+ entries = []
766
+ for t in scan_types:
767
+ pend = pending_dir(t)
768
+ try:
769
+ for name in os.listdir(pend):
770
+ entries.append((name, pend))
771
+ except FileNotFoundError:
772
+ continue
773
+ entries.sort(key=lambda e: e[0])
774
+ for name, pend in entries:
775
+ if not name.endswith(".json") or name.endswith(".tmp"):
776
+ continue
777
+ src = os.path.join(pend, name)
778
+ dst = os.path.join(running_dir(), name)
779
+ try:
780
+ os.rename(src, dst) # atomic claim; loser of a race gets FileNotFound
781
+ except FileNotFoundError:
782
+ continue
783
+ try:
784
+ with open(dst) as f:
785
+ job = json.load(f)
786
+ except Exception:
787
+ continue
788
+ # Stamp the agent-session pid that holds THIS claim so the reaper spares it
789
+ # for the whole drafting turn (see _agent_session_pid above).
790
+ agent_pid = _agent_session_pid()
791
+ if agent_pid:
792
+ job["claim_pid"] = agent_pid
793
+ job["claimed_at"] = time.time()
794
+ prompt_file = None
795
+ schema_file = None
796
+ if ns.prompt_file:
797
+ prompt_file = os.path.join(queue_root(), f"prompt-{job['job_id']}.md")
798
+ prompt_body = job.get("prompt") or ""
799
+ # Per-type execution notes ride in the sidecar, not the worker
800
+ # prompt, so the worker stays type-blind (see TYPE_TO_WORKER_NOTES).
801
+ notes = TYPE_TO_WORKER_NOTES.get(job.get("type") or "")
802
+ if notes:
803
+ prompt_body = f"{notes}\n\n---\n\n{prompt_body}"
804
+ _atomic_write_text(prompt_file, prompt_body)
805
+ job["prompt_file"] = prompt_file
806
+ schema = job.get("schema")
807
+ if schema:
808
+ schema_file = os.path.join(queue_root(), f"schema-{job['job_id']}.json")
809
+ _atomic_write_text(schema_file, schema)
810
+ job["schema_file"] = schema_file
811
+ # ALWAYS persist the claim back (claim_pid + any prompt/schema sidecars) so
812
+ # the reaper can read claim_pid; previously this only happened on the
813
+ # --prompt-file lane, leaving claim_pid unstamped for inline callers.
814
+ _atomic_write(dst, job)
815
+ _plog(
816
+ f"claimed {job.get('type') or qtype} job {job['job_id']}; "
817
+ + (f"agent-session pid={agent_pid} stamped (reaper will spare it)"
818
+ if agent_pid else
819
+ "agent-session pid NOT found (reaper falls back to newest-spare)")
820
+ )
821
+ # Narrate the scheduled-task worker's drafting turn to the menu bar. This
822
+ # is the lane that actually runs the LLM; it persists until cmd_result
823
+ # clears it (or the kicker's exit trap does). Covers the box's autopilot.
824
+ _act_write(job.get("type") or qtype)
825
+ # Liveness pulse: a routine actually claimed a job. Proves the worker is
826
+ # firing, not just that its SKILL.md exists (see heartbeat_path()).
827
+ _stamp_heartbeat("claim", job.get("type") or qtype)
828
+ # Hand the consumer exactly what it needs to do the work and report back.
829
+ payload = {"job_id": job["job_id"], "type": job["type"]}
830
+ if ns.prompt_file:
831
+ payload["prompt_file"] = prompt_file
832
+ payload["schema_file"] = schema_file
833
+ else:
834
+ payload["prompt"] = job["prompt"]
835
+ payload["schema"] = job.get("schema")
836
+ print(json.dumps(payload))
837
+ return 0
838
+ print(json.dumps({})) # no work
839
+ _maybe_self_reap() # idle turn, no job claimed — safe to retire this session
840
+ return 0
841
+
842
+
843
+ # --------------------------------------------------------------------------- #
844
+ # result (consumer side, run by a scheduled task) #
845
+ # --------------------------------------------------------------------------- #
846
+ def _validate_against_schema(obj, schema_text: str | None) -> str | None:
847
+ """Lenient validation. Returns an error string or None if acceptable.
848
+
849
+ We deliberately avoid a jsonschema dependency (not guaranteed on the box).
850
+ Enforce only what matters: the result is a JSON object and carries the
851
+ schema's top-level required keys. The prompt itself describes the full shape.
852
+ """
853
+ if schema_text:
854
+ try:
855
+ schema = json.loads(schema_text)
856
+ except Exception:
857
+ schema = None
858
+ if isinstance(schema, dict):
859
+ if schema.get("type") == "object" and not isinstance(obj, dict):
860
+ return "result must be a JSON object"
861
+ required = schema.get("required")
862
+ if isinstance(required, list) and isinstance(obj, dict):
863
+ missing = [k for k in required if k not in obj]
864
+ if missing:
865
+ return f"result missing required keys: {missing}"
866
+ return None
867
+
868
+
869
+ def cmd_result(ns) -> int:
870
+ _apply_state_dir_override(ns)
871
+ _ensure_dirs()
872
+ # The worker's drafting turn ends here (success or failure); drop the menu-bar
873
+ # label so nothing lingers. The provider's next enqueue re-asserts the right
874
+ # label for the cycle's following claude call, if any.
875
+ _act_clear()
876
+ # Liveness pulse: a routine completed a drain. Keeps the heartbeat fresh
877
+ # across the whole claim->result span the worker was alive.
878
+ _stamp_heartbeat("result")
879
+ job_id = ns.job
880
+ # Read the produced result (JSON object) from a file or stdin.
881
+ if ns.result_file and ns.result_file != "-":
882
+ with open(ns.result_file) as f:
883
+ raw = f.read()
884
+ else:
885
+ raw = sys.stdin.read()
886
+ raw = raw.strip()
887
+
888
+ running = None
889
+ # Locate the claimed job to recover its schema (filename carries job_id).
890
+ schema_text = None
891
+ cleanup_files: list[str] = []
892
+ try:
893
+ for name in os.listdir(running_dir()):
894
+ if name.endswith(f"_{job_id}.json"):
895
+ with open(os.path.join(running_dir(), name)) as f:
896
+ job = json.load(f)
897
+ schema_text = job.get("schema")
898
+ cleanup_files = [
899
+ p for p in (job.get("prompt_file"), job.get("schema_file")) if p
900
+ ]
901
+ running = os.path.join(running_dir(), name)
902
+ break
903
+ except FileNotFoundError:
904
+ running = None
905
+
906
+ if ns.error:
907
+ _atomic_write(
908
+ os.path.join(result_dir(), f"{job_id}.json"),
909
+ {"status": "error", "error": raw or "unspecified"},
910
+ )
911
+ if running and os.path.exists(running):
912
+ os.remove(running)
913
+ for p in cleanup_files:
914
+ try:
915
+ os.remove(p)
916
+ except OSError:
917
+ pass
918
+ print(f"[claude_job] recorded error for job {job_id}", file=sys.stderr)
919
+ _maybe_self_reap() # error recorded, turn done — safe to retire this session
920
+ return 0
921
+
922
+ try:
923
+ obj = json.loads(raw)
924
+ except Exception as e:
925
+ print(
926
+ f"[claude_job] result for job {job_id} is not valid JSON: {e}",
927
+ file=sys.stderr,
928
+ )
929
+ return 2
930
+
931
+ err = _validate_against_schema(obj, schema_text)
932
+ if err:
933
+ print(f"[claude_job] result for job {job_id} rejected: {err}", file=sys.stderr)
934
+ return 3
935
+
936
+ _atomic_write(
937
+ os.path.join(result_dir(), f"{job_id}.json"),
938
+ {"status": "done", "result": obj},
939
+ )
940
+ if running and os.path.exists(running):
941
+ os.remove(running)
942
+ for p in cleanup_files:
943
+ try:
944
+ os.remove(p)
945
+ except OSError:
946
+ pass
947
+ print(f"[claude_job] stored result for job {job_id}", file=sys.stderr)
948
+ _maybe_self_reap() # result delivered to disk — safe to retire this session
949
+ return 0
950
+
951
+
952
+ def main() -> int:
953
+ p = argparse.ArgumentParser(description="claude -p queue shim")
954
+ sub = p.add_subparsers(dest="cmd", required=True)
955
+
956
+ pp = sub.add_parser("provider", help="enqueue + block-poll (run by run_claude.sh)")
957
+ pp.add_argument("--tag", required=True)
958
+ pp.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_S)
959
+ pp.add_argument("--state-dir", default=None, help="override $S4L_STATE_DIR")
960
+ pp.add_argument("claude_args", nargs=argparse.REMAINDER)
961
+ pp.set_defaults(func=cmd_provider)
962
+
963
+ pn = sub.add_parser("next", help="claim oldest pending job of a type")
964
+ pn.add_argument("--type", required=True)
965
+ pn.add_argument("--state-dir", default=None, help="override $S4L_STATE_DIR")
966
+ pn.add_argument(
967
+ "--prompt-file",
968
+ action="store_true",
969
+ help="write the prompt/schema to sidecar files and print their paths",
970
+ )
971
+ pn.set_defaults(func=cmd_next)
972
+
973
+ pr = sub.add_parser("result", help="store a job's result")
974
+ pr.add_argument("--job", required=True)
975
+ pr.add_argument("--result-file", default="-", help="path to JSON, or - for stdin")
976
+ pr.add_argument("--error", action="store_true", help="record a failure")
977
+ pr.add_argument("--state-dir", default=None, help="override $S4L_STATE_DIR")
978
+ pr.set_defaults(func=cmd_result)
979
+
980
+ ns = p.parse_args()
981
+ # argparse.REMAINDER keeps a leading "--"; drop it.
982
+ if getattr(ns, "claude_args", None) and ns.claude_args and ns.claude_args[0] == "--":
983
+ ns.claude_args = ns.claude_args[1:]
984
+ return ns.func(ns)
985
+
986
+
987
+ if __name__ == "__main__":
988
+ sys.exit(main())