@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,891 @@
1
+ """Data layer for the S4L menu bar app.
2
+
3
+ Pure stdlib (no third-party deps; rumps lives only in s4l_menubar.py). Two
4
+ sources, in priority order:
5
+
6
+ 1. The MCP server's loopback panel server, when Claude Desktop is running.
7
+ panel-endpoint.json (written by the server at boot) records its url; we
8
+ POST /tool/<name> to replay the exact same tool handlers the in-chat
9
+ dashboard uses. This gives the full, live snapshot (projects, X handle,
10
+ stats) with zero logic duplication.
11
+
12
+ 2. Direct reads of the owned state dir, when Claude Desktop is closed. The
13
+ onboarding ledger (onboarding-progress.json) and runtime.json are plain
14
+ files, so setup progress + the current blocker (State B, the whole point
15
+ of the menu bar during onboarding) are available with nothing running.
16
+
17
+ Everything is best-effort: any failure degrades to "unknown / open Claude".
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ import threading
25
+ import time
26
+ import urllib.request
27
+ from pathlib import Path
28
+
29
+ # SAPS_->S4L_ env mirror (brand rename 2026-07-03): old launchd plists still
30
+ # export SAPS_*; this module reads S4L_* (STATE_DIR / REPO_DIR / ACTIVITY_TTL).
31
+ # The repo dir must be read tolerantly INLINE (old name included) because the
32
+ # mirror module itself lives in $REPO/scripts and isn't importable before the
33
+ # sys.path insertion below. Best-effort: failure degrades to defaults.
34
+ _repo_for_env = os.environ.get("S4L_REPO_DIR") or os.environ.get("SAPS_REPO_DIR")
35
+ if _repo_for_env:
36
+ _scripts_for_env = os.path.join(_repo_for_env, "scripts")
37
+ if _scripts_for_env not in sys.path:
38
+ sys.path.insert(0, _scripts_for_env)
39
+ try:
40
+ import s4l_env # noqa: E402
41
+
42
+ s4l_env.mirror()
43
+ except Exception:
44
+ pass
45
+
46
+ # Serializes read-modify-write on approved-queue.json. The menu bar's main thread
47
+ # (approve click / restart resume) and the post-worker thread (status updates)
48
+ # both mutate it; without this a concurrent interleave would drop an approval.
49
+ _approved_lock = threading.Lock()
50
+
51
+ # Mirrors shared/onboarding-ledger.cjs MILESTONES (same order).
52
+ MILESTONES = [
53
+ "environment_checked",
54
+ "runtime_ready",
55
+ "x_connected",
56
+ "profile_scanned",
57
+ "mode_chosen",
58
+ "project_ready",
59
+ "topics_seeded",
60
+ "tasks_scheduled",
61
+ ]
62
+
63
+ # Mirrors panel.ts MILESTONE_LABELS.
64
+ MILESTONE_LABELS = {
65
+ "environment_checked": "Environment checked",
66
+ "runtime_ready": "Runtime ready",
67
+ "x_connected": "X connected",
68
+ "profile_scanned": "Profile scanned",
69
+ "mode_chosen": "Mode chosen",
70
+ "project_ready": "Project ready",
71
+ "topics_seeded": "Topics seeded",
72
+ "tasks_scheduled": "Tasks scheduled",
73
+ }
74
+
75
+ # Mirrors index.ts TWITTER_AUTOPILOT_LABEL.
76
+ AUTOPILOT_LABEL = "com.m13v.social-twitter-cycle"
77
+
78
+
79
+ def state_dir() -> str:
80
+ return os.environ.get("S4L_STATE_DIR") or str(
81
+ Path.home() / ".social-autoposter-mcp"
82
+ )
83
+
84
+
85
+ def read_json(name: str):
86
+ try:
87
+ return json.loads((Path(state_dir()) / name).read_text())
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ # ---- direct file reads (work with Claude Desktop closed) -------------------
93
+ def read_onboarding():
94
+ """Re-derive onboarding-ledger.cjs publicSnapshot() from the raw ledger."""
95
+ d = read_json("onboarding-progress.json")
96
+ if not d or not isinstance(d.get("milestones"), dict):
97
+ return None
98
+ ms = d["milestones"]
99
+
100
+ # mode_chosen (added 2026-06-26) won't exist in ledgers written before it.
101
+ # Mirror the server's backfill so adding this milestone never flips an already-
102
+ # onboarded box back to "Setting up…" in the offline view: treat it complete
103
+ # when the user has picked a mode (mode.json exists) OR the install is already
104
+ # past setup (project_ready complete = a legacy onboard).
105
+ def _status(mid):
106
+ st = (ms.get(mid) or {}).get("status")
107
+ if mid == "mode_chosen" and st != "complete":
108
+ mode_picked = (Path(state_dir()) / MODE_FILE).exists()
109
+ past_setup = (ms.get("project_ready") or {}).get("status") == "complete"
110
+ if mode_picked or past_setup:
111
+ return "complete"
112
+ return st
113
+
114
+ milestones = [
115
+ {"id": mid, **(ms.get(mid) or {}), "status": _status(mid)} for mid in MILESTONES
116
+ ]
117
+ complete = all(_status(mid) == "complete" for mid in MILESTONES)
118
+ return {
119
+ "complete": complete,
120
+ "milestones": milestones,
121
+ "current_blocker": d.get("current_blocker"),
122
+ }
123
+
124
+
125
+ def runtime_ready() -> bool:
126
+ rt = read_json("runtime.json")
127
+ if not rt or not rt.get("ready"):
128
+ return False
129
+ py = rt.get("python")
130
+ return bool(py and os.path.exists(py))
131
+
132
+
133
+ def version():
134
+ ep = read_json("panel-endpoint.json") or {}
135
+ return ep.get("version")
136
+
137
+
138
+ def _launchctl_list() -> str:
139
+ try:
140
+ return subprocess.run(
141
+ ["launchctl", "list"], capture_output=True, text=True, timeout=10
142
+ ).stdout
143
+ except Exception:
144
+ return ""
145
+
146
+
147
+ def autopilot_loaded() -> bool:
148
+ # Autopilot is now the Claude Desktop scheduled task, not the legacy launchd job.
149
+ cfg = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(str(Path.home()), ".claude")
150
+ return os.path.exists(
151
+ os.path.join(cfg, "scheduled-tasks", "social-autoposter-autopilot", "SKILL.md")
152
+ )
153
+
154
+
155
+ # ---- loopback panel server (live, when Claude Desktop is running) ----------
156
+ def _endpoint_url():
157
+ ep = read_json("panel-endpoint.json")
158
+ url = (ep or {}).get("url")
159
+ if not url:
160
+ return None
161
+ try:
162
+ with urllib.request.urlopen(url + "health", timeout=1.5) as r:
163
+ if r.status == 200:
164
+ return url
165
+ except Exception:
166
+ return None
167
+ return None
168
+
169
+
170
+ def loopback_reachable() -> bool:
171
+ return _endpoint_url() is not None
172
+
173
+
174
+ def _parse_tool_result(obj):
175
+ """Normalize an MCP tool result (structuredContent or a JSON text block)."""
176
+ if isinstance(obj, dict):
177
+ sc = obj.get("structuredContent")
178
+ if isinstance(sc, dict):
179
+ snap = sc.get("snapshot")
180
+ if isinstance(snap, str):
181
+ try:
182
+ return json.loads(snap)
183
+ except Exception:
184
+ pass
185
+ return sc
186
+ content = obj.get("content")
187
+ if isinstance(content, list):
188
+ for c in content:
189
+ if isinstance(c, dict) and c.get("type") == "text" and c.get("text"):
190
+ try:
191
+ return json.loads(c["text"])
192
+ except Exception:
193
+ return {"_raw": c["text"]}
194
+ return obj
195
+
196
+
197
+ def loopback_tool(name: str, args=None, timeout: float = 20.0):
198
+ url = _endpoint_url()
199
+ if not url:
200
+ return None
201
+ try:
202
+ data = json.dumps(args or {}).encode()
203
+ req = urllib.request.Request(
204
+ url + "tool/" + name,
205
+ data=data,
206
+ headers={"Content-Type": "application/json"},
207
+ method="POST",
208
+ )
209
+ with urllib.request.urlopen(req, timeout=timeout) as r:
210
+ return _parse_tool_result(json.loads(r.read().decode()))
211
+ except Exception:
212
+ return None
213
+
214
+
215
+ # ---- the snapshot the menu bar renders ------------------------------------
216
+ # Background snapshot cache. scripts/snapshot.py reads files but may spawn the
217
+ # X-status subprocess (setup_twitter_auth.py -> CDP to Chrome), which must NEVER
218
+ # run on the menu bar's UI thread — a hung Chrome would freeze the menu. So a
219
+ # daemon thread recomputes and snapshot() returns the last cached value INSTANTLY.
220
+ _snap_cache = {"val": None, "at": 0.0}
221
+ _snap_lock = threading.Lock()
222
+ _snap_refreshing = [False]
223
+
224
+
225
+ def _compute_snapshot_full():
226
+ repo = (
227
+ os.environ.get("S4L_REPO_DIR")
228
+ or os.environ.get("SAPS_REPO_DIR") # pre-rename plists (2026-07-03)
229
+ or str(Path.home() / "social-autoposter")
230
+ )
231
+ scripts = os.path.join(repo, "scripts")
232
+ if scripts not in sys.path:
233
+ sys.path.insert(0, scripts)
234
+ import snapshot as _snapshot_mod # scripts/snapshot.py
235
+ return _snapshot_mod.compute()
236
+
237
+
238
+ def _refresh_snapshot_bg():
239
+ try:
240
+ snap = _compute_snapshot_full()
241
+ if isinstance(snap, dict) and "projects_total" in snap:
242
+ with _snap_lock:
243
+ _snap_cache["val"] = snap
244
+ _snap_cache["at"] = time.time()
245
+ except Exception:
246
+ pass
247
+ finally:
248
+ _snap_refreshing[0] = False
249
+
250
+
251
+ def snapshot():
252
+ """Full snapshot computed DIRECTLY from the stateful files via
253
+ scripts/snapshot.py — the SAME single-source module the MCP shells out to, so
254
+ the two surfaces can't diverge. NO loopback / MCP dependency, so a restarting
255
+ or closed Claude can't freeze or stale the menu (the old tier-1 `loopback_tool`
256
+ blocked the UI thread up to 20s and was the freeze). The heavy compute runs on
257
+ a BACKGROUND thread; this returns the last cached result instantly.
258
+
259
+ Tiers: (1) the background-computed local snapshot; (2) the server's last
260
+ persisted `status-summary.json`; (3) the onboarding ledger."""
261
+ now = time.time()
262
+ with _snap_lock:
263
+ cached = _snap_cache["val"]
264
+ age = now - _snap_cache["at"]
265
+ if (cached is None or age > 4.0) and not _snap_refreshing[0]:
266
+ _snap_refreshing[0] = True
267
+ threading.Thread(target=_refresh_snapshot_bg, daemon=True).start()
268
+ if cached is not None:
269
+ out = dict(cached)
270
+ out["_live"] = True
271
+ return out
272
+ summ = read_json("status-summary.json")
273
+ if isinstance(summ, dict) and "projects_total" in summ:
274
+ summ["_live"] = False
275
+ summ["_from_summary"] = True
276
+ return summ
277
+ ob = read_onboarding()
278
+ return {
279
+ "_live": False,
280
+ "runtime_ready": runtime_ready(),
281
+ "onboarding": ob,
282
+ "autopilot_on": autopilot_loaded(),
283
+ "x_connected": False, # unknowable offline; State derives from onboarding
284
+ "x_handle": None,
285
+ "projects_ready": 0,
286
+ "projects_total": 0,
287
+ "version": version(),
288
+ "update_available": False,
289
+ "latest_version": None,
290
+ }
291
+
292
+
293
+ def stats_7d():
294
+ """7-day post stats; loopback only (the DB read needs the owned runtime)."""
295
+ res = loopback_tool("get_stats", {"days": 7})
296
+ if not isinstance(res, dict):
297
+ return None
298
+ projects = res.get("projects")
299
+ proj = projects[0] if isinstance(projects, list) and projects else None
300
+ p = (proj or {}).get("posts")
301
+ if not p:
302
+ return None
303
+ return {
304
+ "posts": p.get("total", 0),
305
+ "views": p.get("views_period_total", p.get("views", 0)),
306
+ "replies": p.get("comments_period_total", p.get("comments", 0)),
307
+ }
308
+
309
+
310
+ # set_autopilot() (the launchd toggle) was removed: the autopilot is now the Claude
311
+ # Desktop scheduled task `social-autoposter-autopilot`, managed in the Scheduled tab,
312
+ # so the menu bar no longer toggles a launchd job (it mirrors the dashboard instead).
313
+
314
+
315
+ def panel_url():
316
+ """The loopback dashboard url if reachable, else None."""
317
+ return _endpoint_url()
318
+
319
+
320
+ # ---- Accessibility (TCC) permission ---------------------------------------
321
+ # Posting keystrokes via AppleScript needs the Accessibility permission, granted
322
+ # PER responsible-process identity. So this must be called from inside the menu
323
+ # bar process to reflect the menu bar (not some parent). AXIsProcessTrusted() is
324
+ # TCC's own check — the reliable signal, reached via ctypes (no third-party dep).
325
+ def accessibility_trusted() -> bool:
326
+ try:
327
+ import ctypes
328
+ import ctypes.util
329
+
330
+ lib = ctypes.util.find_library("ApplicationServices")
331
+ if not lib:
332
+ return False
333
+ ap = ctypes.cdll.LoadLibrary(lib)
334
+ ap.AXIsProcessTrusted.restype = ctypes.c_bool
335
+ return bool(ap.AXIsProcessTrusted())
336
+ except Exception:
337
+ return False
338
+
339
+
340
+ def request_accessibility() -> bool:
341
+ """Pop the system Accessibility prompt for THIS process (registers it in the
342
+ list so the user can toggle it on) and open the Settings pane. Returns the
343
+ current trust state. Safe to call when already trusted (no prompt shown)."""
344
+ trusted = False
345
+ try:
346
+ import ctypes
347
+ import ctypes.util
348
+
349
+ cf = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation"))
350
+ ap = ctypes.cdll.LoadLibrary(ctypes.util.find_library("ApplicationServices"))
351
+ prompt_key = ctypes.c_void_p.in_dll(ap, "kAXTrustedCheckOptionPrompt")
352
+ cf_true = ctypes.c_void_p.in_dll(cf, "kCFBooleanTrue")
353
+ cf.CFDictionaryCreate.restype = ctypes.c_void_p
354
+ cf.CFDictionaryCreate.argtypes = [
355
+ ctypes.c_void_p,
356
+ ctypes.POINTER(ctypes.c_void_p),
357
+ ctypes.POINTER(ctypes.c_void_p),
358
+ ctypes.c_long,
359
+ ctypes.c_void_p,
360
+ ctypes.c_void_p,
361
+ ]
362
+ keys = (ctypes.c_void_p * 1)(prompt_key)
363
+ vals = (ctypes.c_void_p * 1)(cf_true)
364
+ d = cf.CFDictionaryCreate(None, keys, vals, 1, None, None)
365
+ ap.AXIsProcessTrustedWithOptions.restype = ctypes.c_bool
366
+ ap.AXIsProcessTrustedWithOptions.argtypes = [ctypes.c_void_p]
367
+ trusted = bool(ap.AXIsProcessTrustedWithOptions(d))
368
+ except Exception:
369
+ pass
370
+ try:
371
+ subprocess.run(
372
+ [
373
+ "open",
374
+ "x-apple.systempreferences:com.apple.preference.security"
375
+ "?Privacy_Accessibility",
376
+ ],
377
+ capture_output=True,
378
+ timeout=10,
379
+ )
380
+ except Exception:
381
+ pass
382
+ return trusted
383
+
384
+
385
+ # ---- draft review (pop-up cards) ------------------------------------------
386
+ # draft_cycle writes review-request.json when a fresh batch is ready; we read
387
+ # the linked plan file (the /tmp/twitter_cycle_plan_<batch>.json the pipeline
388
+ # produced), present the cards, then post the approved subset via the loopback
389
+ # post_drafts tool. The chat-table review still works in parallel; both surfaces
390
+ # de-dup on the plan's per-candidate `posted` flag.
391
+ # How long an activity signal may go un-refreshed before the menu bar treats it
392
+ # as idle. This is the SELF-HEAL for a frozen spinner: a writer can set a label
393
+ # (e.g. the queue worker writing "drafting replies" on job-claim, or a kicker
394
+ # writing "scanning") and then die WITHOUT clearing it — the leaked-worker reaper
395
+ # SIGKILLs a draft worker before it can call `claude_job.py result`, a divergent
396
+ # lane runs the cycle with no exit-trap clear, or a process crashes mid-phase. In
397
+ # every such case the clear never runs and the old code showed the label forever.
398
+ # Live work keeps `since` fresh well under this window (the queue provider's poll
399
+ # loop heartbeats every ~10s, the kicker re-stamps "scanning" every ~30s, and the
400
+ # poster writes per post), so a signal older than this can only be a stuck stamp.
401
+ ACTIVITY_TTL_SECONDS = float(os.environ.get("S4L_ACTIVITY_TTL_S", "120"))
402
+
403
+
404
+ def _activity_is_stale(act) -> bool:
405
+ """True when act['since'] is older than ACTIVITY_TTL_SECONDS. A missing/unparsable
406
+ `since` is treated as NOT stale (fail open: never hide a label we can't age)."""
407
+ try:
408
+ import datetime
409
+
410
+ since = (act or {}).get("since")
411
+ if not since:
412
+ return False
413
+ s = since.replace("Z", "+00:00")
414
+ ts = datetime.datetime.fromisoformat(s)
415
+ if ts.tzinfo is None:
416
+ ts = ts.replace(tzinfo=datetime.timezone.utc)
417
+ age = (datetime.datetime.now(datetime.timezone.utc) - ts).total_seconds()
418
+ return age > ACTIVITY_TTL_SECONDS
419
+ except Exception:
420
+ return False
421
+
422
+
423
+ def read_activity():
424
+ """What the server is doing right now: {state, label} or None when idle.
425
+ Written by long-running tools (scanning/drafting/posting/…); drives the
426
+ menu-bar loading spinner.
427
+
428
+ Stale signals are reported as idle (None): see ACTIVITY_TTL_SECONDS. This is
429
+ what keeps the spinner from freezing on a label whose writer died before
430
+ clearing it."""
431
+ act = read_json("activity.json")
432
+ if act and _activity_is_stale(act):
433
+ return None
434
+ return act
435
+
436
+
437
+ def write_activity(state: str, label: str):
438
+ """Best-effort local activity update. The MCP server normally owns this file,
439
+ but the menu-bar posting queue knows the whole approved-card burst while the
440
+ server only sees one post_drafts call at a time."""
441
+ try:
442
+ p = Path(state_dir()) / "activity.json"
443
+ p.parent.mkdir(parents=True, exist_ok=True)
444
+ p.write_text(
445
+ json.dumps(
446
+ {
447
+ "state": state,
448
+ "label": label,
449
+ "since": time_iso(),
450
+ }
451
+ )
452
+ + "\n"
453
+ )
454
+ except Exception:
455
+ pass
456
+
457
+
458
+ def time_iso():
459
+ try:
460
+ import datetime
461
+
462
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
463
+ except Exception:
464
+ return ""
465
+
466
+
467
+ def read_review_request():
468
+ return read_json("review-request.json")
469
+
470
+
471
+ def clear_review_request():
472
+ try:
473
+ p = Path(state_dir()) / "review-request.json"
474
+ if p.exists():
475
+ p.unlink()
476
+ except Exception:
477
+ pass
478
+
479
+
480
+ def read_plan(plan_path):
481
+ try:
482
+ return json.loads(Path(plan_path).read_text())
483
+ except Exception:
484
+ return None
485
+
486
+
487
+ def review_queue_posted_count():
488
+ """Posts that have LANDED in the review-queue plan — the durable, cross-process
489
+ truth. Independent of the menu bar's in-memory burst queue (which dies on a
490
+ restart) and of WHICH process is posting (the menu bar worker, the autopilot,
491
+ or a host agent draining via post_drafts). Returns the posted count, or None
492
+ when the plan can't be read. Drives the menu-bar posting indicator so progress
493
+ stays visible regardless of how the drain is driven."""
494
+ plan_path = None
495
+ req = read_review_request()
496
+ if req:
497
+ plan_path = req.get("plan_path")
498
+ if not plan_path:
499
+ plan_path = "/tmp/twitter_cycle_plan_review-queue.json"
500
+ plan = read_plan(plan_path)
501
+ cands = (plan or {}).get("candidates")
502
+ if not cands:
503
+ return None
504
+ return sum(1 for c in cands if c.get("posted") is True)
505
+
506
+
507
+ def review_drafts(plan, batch="review-queue"):
508
+ """Flatten a plan into the card model: only UNDECIDED candidates. A card that's
509
+ posted, terminal (rejected/dead), or already approved is a settled decision and
510
+ must never be re-presented for review (approved ones proceed to post).
511
+
512
+ Also excludes cards with ANY durable decision (approved, edited, rejected, or a
513
+ decided-but-failed post) via review_settled_ns(). approve/reject/edit are now
514
+ IDENTICAL: each writes a durable local record the INSTANT the user clicks, so a
515
+ decided card never re-presents even if the loopback (Claude Desktop) is down
516
+ when the decision's plan-flag write is attempted. The main plan's
517
+ `approved`/`terminal`/`posted` flags are only stamped once the loopback write
518
+ lands, so without this a card the user just decided would re-present (the exact
519
+ "I already decided these" bug)."""
520
+ settled_ns = review_settled_ns(batch)
521
+ out = []
522
+ for i, c in enumerate(((plan or {}).get("candidates") or [])):
523
+ if c.get("posted") is True or c.get("terminal") is True or c.get("approved") is True:
524
+ continue
525
+ if (i + 1) in settled_ns:
526
+ continue
527
+ out.append(
528
+ {
529
+ "n": i + 1, # 1-based, matches post_drafts numbering
530
+ "thread_author": c.get("thread_author"),
531
+ "thread_text": c.get("thread_text"),
532
+ "reply_text": c.get("reply_text") or "",
533
+ "link_url": c.get("link_url"),
534
+ # Ride-along context for the review-events feedback rail: the
535
+ # card copies these onto each decision so the shipped event can
536
+ # be joined back to the twitter_candidates row and scoped to a
537
+ # project without re-reading the plan.
538
+ "candidate_id": c.get("candidate_id"),
539
+ "project": c.get("matched_project") or c.get("project"),
540
+ # Thread permalink + discovery-time stats (author followers,
541
+ # thread engagement), stamped by merge_review_queue.py from data
542
+ # the pipeline already captured. The card renders these as
543
+ # profile/thread links and a stats line; both may be absent on
544
+ # plans written before the enrichment shipped.
545
+ "thread_url": c.get("candidate_url")
546
+ or c.get("tweet_url")
547
+ or c.get("thread_url"),
548
+ "stats": c.get("stats") or {},
549
+ }
550
+ )
551
+ # The review queue is append-only, so the highest stable index is newest and
552
+ # most likely to still be live on X.
553
+ out.sort(key=lambda d: d["n"], reverse=True)
554
+ return out
555
+
556
+
557
+ # ---- durable approved-card queue ------------------------------------------
558
+ # Card approvals MUST survive a menu bar / Claude restart. The in-memory post
559
+ # queue does not: a restart strands every approved-but-unposted card, which then
560
+ # re-presents for approval (the system had no record the user already approved
561
+ # it). This file is the durable record, owned SOLELY by the menu bar — persisting
562
+ # the approval in the main plan instead would race with the autopilot, which
563
+ # rewrites that plan continuously and would silently drop the flag. Status flow:
564
+ # queued -> posting -> posted | failed. review_drafts() excludes queued/posting
565
+ # so an approved card is never re-shown while it drains; a restart re-enqueues
566
+ # queued/posting items instead of re-presenting them.
567
+ APPROVED_QUEUE = "approved-queue.json"
568
+
569
+
570
+ def read_approved_queue():
571
+ d = read_json(APPROVED_QUEUE)
572
+ if not isinstance(d, dict) or not isinstance(d.get("items"), list):
573
+ return {"items": []}
574
+ return d
575
+
576
+
577
+ def _write_approved_queue(d):
578
+ try:
579
+ p = Path(state_dir()) / APPROVED_QUEUE
580
+ p.parent.mkdir(parents=True, exist_ok=True)
581
+ tmp = p.with_suffix(".json.tmp")
582
+ tmp.write_text(json.dumps(d))
583
+ os.replace(str(tmp), str(p)) # atomic: a crash never leaves a half file
584
+ except Exception:
585
+ pass
586
+
587
+
588
+ # ---- Engagement mode (2026-06-26, dual-flag 2026-06-29) -------------------
589
+ # Two INDEPENDENT lanes the menu bar toggles separately:
590
+ # personal_brand (default ON) -> link-free organic engagement for the user's
591
+ # own brand (forced persona project)
592
+ # promotion (default OFF) -> the product-marketing pipeline (link replies)
593
+ # Both can be ON (the cycle then splits 50/50). State is ONE file the cycle
594
+ # wrapper also reads via scripts/saps_mode.py; keep the shape in lockstep with it.
595
+ MODE_FILE = "mode.json"
596
+ MODE_PROMOTION = "promotion"
597
+ MODE_PERSONAL_BRAND = "personal_brand"
598
+ _VALID_MODES = (MODE_PROMOTION, MODE_PERSONAL_BRAND)
599
+ # 2026-06-29 default flip: personal brand on out of the box, promotion opt-in.
600
+ _DEFAULT_FLAGS = {"personal_brand": True, "promotion": False}
601
+
602
+
603
+ def read_flags():
604
+ """Current lane flags {"personal_brand": bool, "promotion": bool}.
605
+
606
+ Mirrors scripts/saps_mode.py get_flags(): explicit flag keys win; else map a
607
+ legacy {"mode": ...} string; else the default (personal ON / promotion OFF).
608
+ """
609
+ d = read_json(MODE_FILE)
610
+ if isinstance(d, dict):
611
+ if "personal_brand" in d or "promotion" in d:
612
+ return {
613
+ "personal_brand": bool(d.get("personal_brand")),
614
+ "promotion": bool(d.get("promotion")),
615
+ }
616
+ m = str(d.get("mode") or "").strip()
617
+ if m == MODE_PERSONAL_BRAND:
618
+ return {"personal_brand": True, "promotion": False}
619
+ if m == MODE_PROMOTION:
620
+ return {"personal_brand": False, "promotion": True}
621
+ return dict(_DEFAULT_FLAGS)
622
+
623
+
624
+ def read_mode():
625
+ """Derived legacy single-mode string (personal_brand wins when on). Kept so
626
+ older menu-bar callers that expect one value keep working."""
627
+ f = read_flags()
628
+ return MODE_PERSONAL_BRAND if f.get("personal_brand") else MODE_PROMOTION
629
+
630
+
631
+ def write_flags(personal_brand, promotion):
632
+ """Persist both lane flags atomically (plus the derived legacy `mode`).
633
+ Returns the written flags. Never raises — a menu click must not crash."""
634
+ flags = {"personal_brand": bool(personal_brand), "promotion": bool(promotion)}
635
+ try:
636
+ payload = dict(flags)
637
+ payload["mode"] = MODE_PERSONAL_BRAND if flags["personal_brand"] else MODE_PROMOTION
638
+ p = Path(state_dir()) / MODE_FILE
639
+ p.parent.mkdir(parents=True, exist_ok=True)
640
+ tmp = p.with_suffix(".json.tmp")
641
+ tmp.write_text(json.dumps(payload))
642
+ os.replace(str(tmp), str(p))
643
+ except Exception:
644
+ pass
645
+ return flags
646
+
647
+
648
+ def write_mode(mode):
649
+ """Legacy single-mode setter: named lane ON, the other OFF (compat)."""
650
+ if mode not in _VALID_MODES:
651
+ return read_flags()
652
+ return write_flags(
653
+ personal_brand=(mode == MODE_PERSONAL_BRAND),
654
+ promotion=(mode == MODE_PROMOTION),
655
+ )
656
+
657
+
658
+ def toggle_lane(lane):
659
+ """Flip ONE lane (personal_brand|promotion) and return the new flags."""
660
+ if lane not in _VALID_MODES:
661
+ return read_flags()
662
+ f = read_flags()
663
+ f[lane] = not f.get(lane)
664
+ return write_flags(f["personal_brand"], f["promotion"])
665
+
666
+
667
+ def toggle_mode():
668
+ """Legacy whole-mode flip (mutually exclusive). Kept for old callers."""
669
+ new = (
670
+ MODE_PROMOTION
671
+ if read_mode() == MODE_PERSONAL_BRAND
672
+ else MODE_PERSONAL_BRAND
673
+ )
674
+ return write_mode(new)
675
+
676
+
677
+ def review_reject_add(batch, n):
678
+ """Record a REJECT the INSTANT the user clicks, mirroring approved_queue_add.
679
+ Reject and approve are now IDENTICAL: both write a durable local record before
680
+ any loopback call, so review_drafts() suppresses the card even if the loopback
681
+ (Claude Desktop) is down when the reject's plan `terminal` write is attempted.
682
+ Dedups on (batch, n); a reject is FINAL and overrides any earlier status."""
683
+ with _approved_lock:
684
+ d = read_approved_queue()
685
+ for it in d["items"]:
686
+ if it.get("batch") == batch and it.get("n") == n:
687
+ if it.get("status") != "rejected":
688
+ it.update(status="rejected", error=None, ts=time_iso())
689
+ _write_approved_queue(d)
690
+ return
691
+ d["items"].append({
692
+ "batch": batch, "n": n, "text": "", "edited": False,
693
+ "drop_link": False, "candidate_url": "", "status": "rejected",
694
+ "error": None, "ts": time_iso(),
695
+ })
696
+ _write_approved_queue(d)
697
+
698
+
699
+ def approved_queue_add(batch, n, text="", edited=False, candidate_url="", drop_link=False):
700
+ """Record an approval the INSTANT the user clicks, before any posting. Dedups
701
+ on (batch, n): re-approving a card that's still queued/posting/posted is a
702
+ no-op; a previously FAILED card is reset to queued so it retries.
703
+
704
+ drop_link carries the user's "I deleted the link while editing" intent so a
705
+ restart-resumed post honors it too (else the poster re-appends the link)."""
706
+ with _approved_lock:
707
+ d = read_approved_queue()
708
+ for it in d["items"]:
709
+ if it.get("batch") == batch and it.get("n") == n:
710
+ if it.get("status") == "failed":
711
+ it.update(status="queued", text=text, edited=bool(edited),
712
+ drop_link=bool(drop_link), error=None, ts=time_iso())
713
+ _write_approved_queue(d)
714
+ return
715
+ d["items"].append({
716
+ "batch": batch, "n": n, "text": text, "edited": bool(edited),
717
+ "drop_link": bool(drop_link),
718
+ "candidate_url": candidate_url, "status": "queued",
719
+ "error": None, "ts": time_iso(),
720
+ })
721
+ _write_approved_queue(d)
722
+
723
+
724
+ def approved_queue_set_status(batch, n, status, error=None):
725
+ with _approved_lock:
726
+ d = read_approved_queue()
727
+ changed = False
728
+ for it in d["items"]:
729
+ if it.get("batch") == batch and it.get("n") == n:
730
+ it.update(status=status, error=error, ts=time_iso())
731
+ changed = True
732
+ if changed:
733
+ _write_approved_queue(d)
734
+
735
+
736
+ def approved_queue_pending():
737
+ """Approvals not yet confirmed posted (queued or posting). Re-enqueued by the
738
+ menu bar on startup so a restart RESUMES the drain instead of re-presenting."""
739
+ return [it for it in read_approved_queue()["items"]
740
+ if it.get("status") in ("queued", "posting")]
741
+
742
+
743
+ def approved_queue_active_ns(batch):
744
+ """Plan indices the user has already approved for this batch — review_drafts()
745
+ excludes these so an approved card is never re-shown. Covers queued/posting
746
+ (in flight) AND posted: relying on the plan's posted flag alone leaves a window
747
+ (and breaks if the plan is regenerated), so the durable queue excludes posted
748
+ cards independently. `failed` is intentionally NOT excluded, so a failed post
749
+ falls back to manual review rather than silently vanishing."""
750
+ return {it.get("n") for it in read_approved_queue()["items"]
751
+ if it.get("batch") == batch and it.get("status") in ("queued", "posting", "posted")}
752
+
753
+
754
+ def review_settled_ns(batch):
755
+ """Plan indices with ANY durable user DECISION for this batch — review_drafts()
756
+ excludes these so approve, edit, and reject behave IDENTICALLY: a decided card
757
+ never re-presents for review. Covers queued/posting/posted (approved, in flight
758
+ or landed), `rejected`, AND `failed` (a decided-but-failed post is surfaced via
759
+ the failure notification + dashboard, NOT by re-showing it as a fresh review
760
+ card — that re-show was the "I already decided these came back" bug)."""
761
+ return {it.get("n") for it in read_approved_queue()["items"]
762
+ if it.get("batch") == batch
763
+ and it.get("status") in ("queued", "posting", "posted", "failed", "rejected")}
764
+
765
+
766
+ def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
767
+ """Post / reject drafts via the loopback tool. `post` = 1-based numbers to post
768
+ as-is; `edits` = [{n, text}] to rewrite then post; `reject` = numbers to mark
769
+ DONE so they're never shown for review again (not posted); `clear_link` =
770
+ numbers whose link the user removed while editing, so the poster clears
771
+ link_url and does NOT re-append it. Returns the parsed result, or None if the
772
+ loopback is unreachable (Claude Desktop closed)."""
773
+ args = {"batch_id": batch_id, "post": post or [], "edits": edits or [], "reject": reject or [], "clear_link": clear_link or []}
774
+ if activity_label:
775
+ args["__saps_activity_label"] = activity_label
776
+ return loopback_tool("post_drafts", args, timeout=timeout)
777
+
778
+
779
+ # ---- review-events outbox (2026-07-02) --------------------------------------
780
+ # Every card decision (approve/reject, with reason chips, link-click
781
+ # interactions, and dwell time) ships to POST /api/v1/review-events so the
782
+ # feedback-digest job can distill human rejections into each project's
783
+ # learned_preferences config block. The outbox JSONL is the durability layer:
784
+ # append locally first, flush to the API in the background with retry. Events
785
+ # carry a client-generated event_uuid and the server upserts ON CONFLICT DO
786
+ # NOTHING, so a crash between POST and truncate only produces duplicates the
787
+ # server drops — never lost events, never double rows.
788
+ REVIEW_EVENTS_OUTBOX = "review-events-outbox.jsonl"
789
+ _outbox_lock = threading.Lock()
790
+ _outbox_flush_lock = threading.Lock()
791
+
792
+
793
+ def _outbox_path():
794
+ return Path(state_dir()) / REVIEW_EVENTS_OUTBOX
795
+
796
+
797
+ def review_event_add(event):
798
+ """Append one decision event to the durable outbox and kick an async flush.
799
+ Never raises — a telemetry failure must not break the card flow."""
800
+ import uuid
801
+
802
+ ev = dict(event or {})
803
+ ev.setdefault("event_uuid", str(uuid.uuid4()))
804
+ ev.setdefault("client_ts", time_iso())
805
+ try:
806
+ with _outbox_lock:
807
+ p = _outbox_path()
808
+ p.parent.mkdir(parents=True, exist_ok=True)
809
+ with open(p, "a") as f:
810
+ f.write(json.dumps(ev) + "\n")
811
+ except Exception:
812
+ pass
813
+ flush_review_events_async()
814
+
815
+
816
+ def flush_review_events_async():
817
+ threading.Thread(target=flush_review_events, daemon=True).start()
818
+
819
+
820
+ def flush_review_events():
821
+ """Flush the outbox to /api/v1/review-events in batches. Failed batches stay
822
+ in the outbox for the next kick (next decision, review close, or menubar
823
+ start). Serialized: a second concurrent flush returns immediately."""
824
+ if not _outbox_flush_lock.acquire(blocking=False):
825
+ return
826
+ try:
827
+ try:
828
+ with _outbox_lock:
829
+ p = _outbox_path()
830
+ if not p.exists():
831
+ return
832
+ lines = [ln for ln in p.read_text().splitlines() if ln.strip()]
833
+ except Exception:
834
+ return
835
+ events = []
836
+ for ln in lines:
837
+ try:
838
+ ev = json.loads(ln)
839
+ if isinstance(ev, dict) and ev.get("event_uuid"):
840
+ events.append(ev)
841
+ except Exception:
842
+ continue # corrupt line: dropped on the next rewrite
843
+ if not events:
844
+ if lines: # only corrupt lines left — clear the file
845
+ _outbox_remove(set())
846
+ return
847
+ # scripts/ is on sys.path (S4L_REPO_DIR insertion at menubar boot);
848
+ # import lazily so a missing pipeline repo degrades to buffer-only.
849
+ try:
850
+ from http_api import api_post
851
+ except Exception:
852
+ return
853
+ shipped = set()
854
+ for i in range(0, len(events), 100):
855
+ batch = events[i : i + 100]
856
+ try:
857
+ api_post("/api/v1/review-events", {"events": batch})
858
+ shipped.update(e["event_uuid"] for e in batch)
859
+ except Exception:
860
+ break # network/API down: keep the rest for the next kick
861
+ _outbox_remove(shipped, keep_only_valid=True)
862
+ finally:
863
+ _outbox_flush_lock.release()
864
+
865
+
866
+ def _outbox_remove(shipped_uuids, keep_only_valid=False):
867
+ """Rewrite the outbox dropping shipped (and, optionally, corrupt) lines.
868
+ Runs under _outbox_lock so appends that landed mid-flush are preserved."""
869
+ try:
870
+ with _outbox_lock:
871
+ p = _outbox_path()
872
+ if not p.exists():
873
+ return
874
+ remaining = []
875
+ for ln in p.read_text().splitlines():
876
+ if not ln.strip():
877
+ continue
878
+ try:
879
+ ev = json.loads(ln)
880
+ except Exception:
881
+ if not keep_only_valid:
882
+ remaining.append(ln)
883
+ continue
884
+ if not isinstance(ev, dict) or ev.get("event_uuid") in shipped_uuids:
885
+ continue
886
+ remaining.append(json.dumps(ev))
887
+ tmp = p.with_suffix(".jsonl.tmp")
888
+ tmp.write_text("\n".join(remaining) + ("\n" if remaining else ""))
889
+ os.replace(str(tmp), str(p))
890
+ except Exception:
891
+ pass