@m13v/s4l 1.6.197-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,2439 @@
1
+ """S4L menu bar app — a tiny live mini-dashboard for social-autoposter.
2
+
3
+ A status-bar companion that mirrors the in-chat dashboard's three states, but
4
+ much smaller: the menu bar title carries the at-a-glance state and the dropdown
5
+ is a flat native list. It NEVER duplicates pipeline logic — it reads state via
6
+ s4l_state (loopback tools when Claude Desktop is up, raw state files when it's
7
+ down).
8
+
9
+ The one capability it cannot have is injecting a prompt into the Claude Desktop
10
+ chat (that bridge only exists for the inline panel iframe). So the model-driven
11
+ actions (Set up, Re-arm schedule) degrade to copying the prompt to the clipboard
12
+ + focusing Claude Desktop; the no-model actions (open dashboard) work standalone.
13
+
14
+ Runs as a LaunchAgent off the owned venv (rumps is installed there by the
15
+ runtime install step). No .app bundle, so notifications go through osascript
16
+ rather than rumps.notification (which needs a bundle id).
17
+ """
18
+
19
+ import glob
20
+ import json
21
+ import os
22
+ import queue
23
+ import re
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import threading
28
+ import time
29
+
30
+ # --- Sentry bootstrap --------------------------------------------------------
31
+ # The menu bar runs as a standalone KeepAlive LaunchAgent off the owned venv,
32
+ # a separate process from the MCP server, so it was a Sentry blind spot: a crash
33
+ # (most often rumps missing/broken in the venv -> "menu bar didn't start") only
34
+ # ever landed in the local menubar.err.log. Wire it in BEFORE importing rumps so
35
+ # even an import-time failure of the menu bar's heaviest dependency is reported.
36
+ # sentry_init lives in the pipeline's scripts/ dir (S4L_REPO_DIR is exported by
37
+ # the launchd plist) and sentry-sdk is in the owned venv (requirements.txt). All
38
+ # best-effort: a missing repo path or SDK degrades to a silent no-op.
39
+ _sentry = None
40
+ try:
41
+ # Tolerate the pre-rename plist name (SAPS_REPO_DIR) inline: this read runs
42
+ # BEFORE scripts/ is on sys.path, so the s4l_env mirror can't help yet.
43
+ _repo = os.environ.get("S4L_REPO_DIR") or os.environ.get("SAPS_REPO_DIR")
44
+ if _repo:
45
+ _scripts = os.path.join(_repo, "scripts")
46
+ if _scripts not in sys.path:
47
+ sys.path.insert(0, _scripts)
48
+ # SAPS_->S4L_ env mirror (brand rename 2026-07-03): old launchd plists still
49
+ # export SAPS_*; everything below (and every subprocess) reads S4L_*.
50
+ try:
51
+ import s4l_env # noqa: E402
52
+
53
+ s4l_env.mirror()
54
+ except Exception:
55
+ pass
56
+ import sentry_init as _sentry # noqa: E402
57
+
58
+ _sentry.init()
59
+ except Exception:
60
+ _sentry = None
61
+
62
+ # Ship this process's stderr to the Cloud Run log relay (same endpoint the
63
+ # .mcpb server uses for pipeline subprocess output). Without this, every
64
+ # [s4l-card] / [s4l-menubar] line only ever existed in the local
65
+ # menubar.err.log and the review-surface incidents were invisible centrally.
66
+ # Installed after the S4L_REPO_DIR sys.path insertion above (the relay needs
67
+ # scripts/http_api.py for the X-Installation identity). Best-effort.
68
+ try:
69
+ import s4l_log_relay # noqa: E402
70
+
71
+ s4l_log_relay.install()
72
+ except Exception:
73
+ pass
74
+
75
+
76
+ def _capture(err, **tags):
77
+ """Report a handled menu-bar error to Sentry (component=menubar) without ever
78
+ raising into the caller. No-op if the Sentry bootstrap above failed."""
79
+ try:
80
+ if _sentry is not None:
81
+ tags.setdefault("component", "menubar")
82
+ _sentry.capture_exception(err, tags=tags)
83
+ except Exception:
84
+ pass
85
+
86
+
87
+ def _capture_msg(message, level="warning", **tags):
88
+ """Report a handled menu-bar CONDITION (not an exception) to Sentry so we get
89
+ fleet-wide signal on operational states like an orphaned/disabled/rate-limited
90
+ draft schedule. capture_exception only covers thrown errors; this covers the
91
+ "nothing crashed but the autopilot isn't running" case. No-op if the Sentry
92
+ bootstrap failed."""
93
+ try:
94
+ if _sentry is not None:
95
+ tags.setdefault("component", "menubar")
96
+ _sentry.capture_message(message, level=level, tags=tags)
97
+ except Exception:
98
+ pass
99
+
100
+
101
+ def _flush():
102
+ try:
103
+ if _sentry is not None:
104
+ _sentry.flush()
105
+ except Exception:
106
+ pass
107
+
108
+
109
+ try:
110
+ import rumps # noqa: E402
111
+ except Exception as _import_err:
112
+ # rumps missing/broken in the owned venv is THE "menu bar didn't start" case.
113
+ # Report it explicitly, flush, then re-raise so launchd records the crash too.
114
+ _capture(_import_err, phase="import_rumps")
115
+ _flush()
116
+ raise
117
+
118
+ import s4l_state as st # noqa: E402
119
+
120
+ # AppKit is available in the owned venv (PyObjC is a rumps dependency). We use it
121
+ # only to pull the accessory (LSUIElement) app to the front before showing an
122
+ # NSAlert: an agent app that isn't the active app has its rumps.alert appear
123
+ # BEHIND the frontmost window ("modal doesn't show on top"), because runModal
124
+ # doesn't activate the app for us. Guarded so a missing AppKit never breaks the
125
+ # menu bar — the alert still shows, just possibly not front-most.
126
+ try:
127
+ from AppKit import NSApplication # noqa: E402
128
+ except Exception:
129
+ NSApplication = None
130
+
131
+
132
+ def _activate_front():
133
+ """Bring this accessory app to the front so the next NSAlert (rumps.alert)
134
+ opens on top of whatever was frontmost, instead of behind it. Best-effort."""
135
+ try:
136
+ if NSApplication is not None:
137
+ NSApplication.sharedApplication().activateIgnoringOtherApps_(True)
138
+ except Exception:
139
+ pass
140
+
141
+
142
+ CLAUDE_APP = "Claude"
143
+ POLL_SECONDS = 5
144
+
145
+ # Our own LaunchAgent. Quit boots it out and deletes the plist so the tray is
146
+ # genuinely gone (KeepAlive can't respawn it, RunAtLoad can't resurrect it at
147
+ # next login). Keep the label in sync with MENUBAR_LABEL in mcp/src/runtime.ts.
148
+ MENUBAR_LABEL = "com.m13v.social-autoposter.menubar"
149
+ MENUBAR_PLIST = os.path.join(
150
+ os.path.expanduser("~"), "Library", "LaunchAgents", f"{MENUBAR_LABEL}.plist"
151
+ )
152
+ # Stop sentinel read by the MCP server's ensureMenubar()/provision paths: while
153
+ # present, no auto-start path may reinstall the tray. Cleared only by explicit
154
+ # start actions (restart_menubar tool, queue_setup re-arm). Keep the filename in
155
+ # sync with MENUBAR_STOP_FLAG in mcp/src/runtime.ts.
156
+ STOP_FLAG = os.path.join(st.state_dir(), "stopped.flag")
157
+
158
+ # Autopilot scheduled tasks. Queue workers must RUN in a dedicated folder
159
+ # (~/.s4l-worker) so their once-a-minute sessions don't flood the user's
160
+ # interactive Claude Code history (Claude buckets sessions by cwd). s4l-worker
161
+ # is the universal type-blind worker (2026-07-02, one task drains every job
162
+ # type); task ids are USER-VISIBLE in the Routines UI, so the canonical id
163
+ # carries the S4L brand, never the internal "saps" prefix. The phase1/phase2b
164
+ # pair (and the short-lived staging "saps-worker" from rc.2/rc.3) are legacy.
165
+ # The single pre-queue autopilot task is deprecated and removed outright. Keep
166
+ # this in sync with queueWorkerCwd()/QUEUE_WORKERS/LEGACY_QUEUE_WORKER_TASK_IDS
167
+ # in mcp/src/index.ts and scripts/s4l_box_update.sh.
168
+ WORKER_TASK_IDS = ("s4l-worker", "saps-worker", "saps-phase1-query", "saps-phase2b-draft")
169
+ # The universal worker every install converges on. Every legacy id is
170
+ # CONSOLIDATED into it by the same one-restart self-heal that fixes task
171
+ # folders: the registry rewrite (while Claude is down) replaces all legacy
172
+ # entries with one s4l-worker entry. Until a box walks that path, the legacy
173
+ # tasks still work (their SKILL.md is refreshed to the universal body on boot).
174
+ WORKER_TASK_ID = "s4l-worker"
175
+ LEGACY_WORKER_TASK_IDS = ("saps-worker", "saps-phase1-query", "saps-phase2b-draft")
176
+ DEPRECATED_TASK_IDS = ("social-autoposter-autopilot",)
177
+ WORKER_CWD = os.path.join(os.path.expanduser("~"), ".s4l-worker")
178
+ # "Claude*": the host app can run with a custom --user-data-dir (per-account
179
+ # dirs like "Claude-mediar"), putting the live registry outside plain "Claude/".
180
+ # Keep in sync with scripts/schedule_state.py::SCHED_REGISTRY_GLOB.
181
+ SCHED_REGISTRY_GLOB = os.path.join(
182
+ os.path.expanduser("~"), "Library", "Application Support", "Claude*",
183
+ "claude-code-sessions", "*", "*", "scheduled-tasks.json",
184
+ )
185
+
186
+ GLYPH = {"complete": "✓", "in_progress": "…", "blocked": "✗"}
187
+
188
+ # Menu-bar title spinner shown while a post is in flight.
189
+ SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
190
+
191
+ # Prompts the model-driven menu items type into the Claude Desktop composer.
192
+ # SETUP_PROMPT mirrors the in-chat panel's Setup button (panel.ts) verbatim so
193
+ # both entry points kick off the same end-to-end flow.
194
+ SETUP_PROMPT = (
195
+ "Set up the S4L plugin end to end now. Inspect and repair the runtime, "
196
+ "auto-detect and connect my X session, scan my profile, discover and research "
197
+ "my product, then infer and save a complete project with seeded search topics. "
198
+ "Keep going without asking me to approve each safe setup step. Ask only if I "
199
+ "must interactively sign in or no product can be identified."
200
+ )
201
+ UPDATE_PROMPT = "Update the S4L plugin to the latest version."
202
+ # Re-arm goes through the HOST create_scheduled_task path (the same one onboarding
203
+ # uses) — it registers the routines under whatever account is logged in and shows
204
+ # up in Routines. The host tool only runs inside an agent chat, so the menu bar
205
+ # hands Claude this prompt (auto-typed, clipboard+paste fallback). We do NOT write
206
+ # scheduled-tasks.json directly — that can't reliably target a just-switched-into
207
+ # account, which is exactly the bug it caused.
208
+ REARM_PROMPT = (
209
+ "Set up the S4L draft autopilot schedule for this Claude account. "
210
+ "If queue_setup is available, call it; then for s4l-worker call the host tool "
211
+ "create_scheduled_task with taskId, cronExpression \"* * * * *\", and the prompt "
212
+ "— read it from ~/.claude/scheduled-tasks/s4l-worker/SKILL.md (already on disk). "
213
+ "Do not redo my X connection or project setup — only register the scheduled task. "
214
+ "Keep replies short."
215
+ )
216
+
217
+ # A pending draft job older than this (seconds) with nothing claiming it means no
218
+ # routine is draining the queue — the worker would claim within a minute if it
219
+ # were firing. False-positive-free: an idle queue has no pending job at all, so a
220
+ # quiet pipeline (no candidates) never trips this. Comfortably above the host
221
+ # scheduler's per-minute cadence + a slow claim.
222
+ AUTOPILOT_STALL_SECONDS = 180
223
+
224
+ # A job CLAIMED but never finished (sits in running/ this long) means a worker
225
+ # picked it up and then wedged mid-run (the claude -p drafting child died / never
226
+ # spawned). Generous enough that the longest real drafting turn never trips it.
227
+ # Keep in sync with RUNNING_STALL_SECONDS (scripts/autopilot_stall_watch.py).
228
+ AUTOPILOT_RUNNING_STALL_SECONDS = 900
229
+
230
+ # The "firing" window (how fresh lastRunAt must be) lives in the single source of
231
+ # truth, scripts/schedule_state.py (FIRING_WINDOW there). _schedule_state delegates
232
+ # to it, so it is intentionally NOT redefined here.
233
+
234
+ # How long the producer can sit narrating "drafting replies (Nm)" before we treat
235
+ # the draft as STUCK rather than healthy. The producer writes that label the whole
236
+ # time it blocks waiting for a worker to return a result (up to its 30-min queue
237
+ # timeout). A healthy drain clears in ~1-2 min; if the label has been "drafting"
238
+ # this long, the worker keeps dying mid-run (host inactivity-kill) or never claims,
239
+ # and nothing is draining — so we flip the menu bar from a reassuring spinner to
240
+ # ⚠ instead of letting the stale "drafting (8m)" lie persist. Well above any
241
+ # healthy single drain (the worker itself dies at ~2 min today).
242
+ DRAFT_STUCK_SECONDS = 300
243
+
244
+ # Unattended-review watchdog. A card stack is open with pending drafts and the
245
+ # user has not decided or clicked anything on it for REVIEW_UNATTENDED_SECONDS:
246
+ # treat that as "the user is not seeing this window" regardless of what AppKit
247
+ # reports (a card can be fully drawn yet parked on a display corner nobody
248
+ # looks at, which hid 12 drafts for 3 hours on 2026-07-02). The response is
249
+ # SELF-HEALING, not a prompt: move the card to the pointer's screen and raise
250
+ # it, then keep re-healing every REVIEW_HEAL_EVERY_SECONDS while the drought
251
+ # lasts. One notification per episode; one Sentry event after
252
+ # REVIEW_UNATTENDED_SENTRY_SECONDS so silently-ignored review surfaces are
253
+ # visible fleet-wide.
254
+ REVIEW_UNATTENDED_SECONDS = float(
255
+ os.environ.get("S4L_REVIEW_UNATTENDED_S", "1200")
256
+ )
257
+ REVIEW_HEAL_EVERY_SECONDS = float(
258
+ os.environ.get("S4L_REVIEW_HEAL_EVERY_S", "600")
259
+ )
260
+ REVIEW_UNATTENDED_SENTRY_SECONDS = 3600.0
261
+
262
+
263
+ def _label_elapsed_secs(label):
264
+ """Parse the trailing duration the producer encodes in a drafting activity
265
+ label — 'drafting replies (8m)', '... (queued 18m)', '... (45s)' — into
266
+ seconds. Returns 0 when there's no parseable duration. _fmt_dur (claude_job.py)
267
+ only ever emits '<n>s' (<60s) or '<n>m', so this mirror stays trivial."""
268
+ if not label:
269
+ return 0
270
+ import re
271
+ matches = re.findall(r"(\d+)\s*([sm])\b", str(label))
272
+ if not matches:
273
+ return 0
274
+ n, unit = matches[-1]
275
+ return int(n) * (60 if unit == "m" else 1)
276
+
277
+
278
+ def _glyph(status):
279
+ return GLYPH.get(status, "·")
280
+
281
+
282
+ def _osa_quote(s):
283
+ """Escape a Python string for an AppleScript double-quoted literal."""
284
+ return s.replace("\\", "\\\\").replace('"', '\\"')
285
+
286
+
287
+ def _claude_send_script(prompt):
288
+ """AppleScript that focuses Claude, pastes `prompt` into the focused composer,
289
+ and presses Return. Uses the clipboard (saved + restored) rather than slow
290
+ per-character keystrokes, and waits longer on a cold launch so the window is
291
+ ready before pasting."""
292
+ p = _osa_quote(prompt)
293
+ return "\n".join(
294
+ [
295
+ 'set prevClip to ""',
296
+ "try",
297
+ " set prevClip to (the clipboard as text)",
298
+ "end try",
299
+ f'set the clipboard to "{p}"',
300
+ 'tell application "System Events" to set wasRunning to (exists process "Claude")',
301
+ 'tell application "Claude" to activate',
302
+ "if wasRunning then",
303
+ " delay 0.5",
304
+ "else",
305
+ " delay 2.5",
306
+ "end if",
307
+ 'tell application "System Events"',
308
+ ' keystroke "v" using {command down}',
309
+ " delay 0.15",
310
+ " key code 36",
311
+ "end tell",
312
+ "delay 0.3",
313
+ 'if prevClip is not "" then set the clipboard to prevClip',
314
+ ]
315
+ )
316
+
317
+
318
+ class S4LMenuBar(rumps.App):
319
+ def __init__(self):
320
+ super().__init__("S4L", quit_button=None)
321
+ self._last_blocker_code = None
322
+ self._sig = None # last rendered state signature; skip rebuild if unchanged
323
+ self._review_active = False # a review-card sequence is on screen
324
+ # Signature of the pending drafts last presented. We de-dup on the CONTENT
325
+ # of the pending set, NOT on the batch_id: the server intentionally reuses
326
+ # a constant batch_id ("review-queue") so a continuous autopilot's drafts
327
+ # accumulate into one queue. Keying de-dup on that constant suppressed every
328
+ # later batch for the life of this process (only a restart cleared it),
329
+ # which is exactly the "drafts queued but no cards" bug.
330
+ self._last_review_sig = None
331
+ # Per-card posting. Each approved card posts the INSTANT it's approved,
332
+ # serialized through one persistent worker so two posts never drive the
333
+ # shared harness Chrome at once (the poster lock fails a concurrent peer
334
+ # after 45s rather than queuing it, which would land the 2nd card 0/N).
335
+ # `_review_active` stays true while the panel is open OR posts are still
336
+ # draining, so a half-posted set is never re-presented as fresh cards.
337
+ self._post_q = queue.Queue()
338
+ self._post_worker = None
339
+ self._review_lock = threading.Lock()
340
+ self._panel_open = False
341
+ # Unattended-review watchdog state (_maybe_heal_review).
342
+ self._review_heal_at = 0.0
343
+ self._review_unattended_notified = False
344
+ self._review_unattended_captured = False
345
+ self._posts_outstanding = 0
346
+ self._posting_batch_total = 0
347
+ self._posting_batch_done = 0
348
+ self._spin_i = 0
349
+ self._spinner = None # fast rumps.Timer animating the title while busy
350
+ # Durable posting progress, derived from the review-queue PLAN rather than
351
+ # this process's in-memory burst queue. The in-memory counter dies on a
352
+ # menu bar restart and is blind to posts driven by the autopilot/agent, so
353
+ # the title used to fall back to plain "S4L" mid-drain. These track a drain
354
+ # by the plan's posted count climbing, with hysteresis across the multi-
355
+ # second gaps between individual posts so the indicator never blinks off.
356
+ self._posting_label = None
357
+ self._drain_baseline = None # posted count just before this drain started
358
+ self._drain_last_posted = None
359
+ self._drain_last_change = 0.0
360
+ # One-shot: on the first tick where the loopback is reachable, re-enqueue
361
+ # any approvals the durable queue recorded but never confirmed posted (a
362
+ # restart wiped the in-memory _post_q). Deferred until the loopback is up
363
+ # so post_drafts can actually reach the server.
364
+ self._resumed = False
365
+ # Reliable self-check of our own Accessibility (TCC) grant — this is the
366
+ # faithful reading (our launchd process identity, not a parent's). Logged
367
+ # so menubar.err.log records whether keystroke posting will work.
368
+ sys.stderr.write(
369
+ f"[s4l-menubar] accessibility_trusted={st.accessibility_trusted()}\n"
370
+ )
371
+ sys.stderr.flush()
372
+ self._timer = rumps.Timer(self._tick, POLL_SECONDS)
373
+ self._timer.start()
374
+ # Light 1s poll for server activity (scanning/drafting/posting/…); it
375
+ # spins up the fast title-spinner on demand. Idle cost is one tiny file
376
+ # read per second.
377
+ self._act_poll = rumps.Timer(self._poll_activity, 1.0)
378
+ self._act_poll.start()
379
+ # Update availability comes from ONE source: scripts/snapshot.py's
380
+ # _latest_published (GitHub releases/latest first, npm fallback — boxes
381
+ # have no npm; same probe as mcp/src/version.ts), surfaced in the
382
+ # snapshot as update_available/latest_version. _tick copies those
383
+ # snapshot fields onto these attrs every poll. No second GitHub/manifest
384
+ # check here anymore (it diverged from the header and once showed an
385
+ # "update to an OLDER version" because it polled a different registry).
386
+ self._update_available = False
387
+ self._latest_version = None
388
+ # Release channel + resolved release tag for this box (from the snapshot;
389
+ # stable = releases/latest, staging = newest release overall). The tag is
390
+ # what a staging update downloads from. See scripts/s4l_channel.py.
391
+ self._channel = "stable"
392
+ self._latest_tag = None
393
+ # Self-heal (modal-first): if the autopilot scheduled tasks are running in
394
+ # the wrong folder (or the deprecated single autopilot task still exists),
395
+ # OFFER to relocate them to ~/.s4l-worker so their once-a-minute runs stop
396
+ # polluting the user's interactive Claude Code history. The fix needs a
397
+ # single Claude restart (the app caches the registry in memory), so we ASK
398
+ # first with a modal — same consent pattern as Quit/re-arm — and never
399
+ # restart Claude out from under the user silently. Prompt at most once per
400
+ # process; a 'Later' is re-offered as a menu item.
401
+ self._relocating = False
402
+ self._cwd_healed = False
403
+ self._reloc_prompted = False
404
+ self._reloc_needed = False
405
+ # One-shot guard so the "autopilot not running" notification fires once per
406
+ # stall episode, not every poll. Reset when the stall clears.
407
+ self._stall_notified = False
408
+ # Cached stall flag (set each _tick) so the 1s activity poll can suppress a
409
+ # stale "drafting" spinner that would otherwise mask the ⚠ in the title.
410
+ self._stalled = False
411
+ # Cached (kind, detail) explaining why a SCHEDULED autopilot isn't draining
412
+ # ('rate_limited' -> wait/switch, no setup button; 'failing' -> generic).
413
+ self._stall_reason_info = ("", "")
414
+ # Cached schedule state for the current account: 'missing'/'disabled'/'ok'/
415
+ # 'unknown'. PRIMARY driver of the menu's attention section.
416
+ self._schedule_state_cache = "ok"
417
+ self._reloc_timer = rumps.Timer(self._maybe_relocate_tasks, 90)
418
+ self._reloc_timer.start()
419
+ self._tick(None)
420
+
421
+ # ---- side effects -----------------------------------------------------
422
+ def _open_claude(self, _=None):
423
+ subprocess.run(["open", "-a", CLAUDE_APP], capture_output=True)
424
+
425
+ def _copy_to_clipboard(self, text):
426
+ """Put text on the clipboard via pbcopy. Unlike the AppleScript keystroke
427
+ paste, this needs NO Accessibility grant, so it's the always-works fallback
428
+ when automation can't run. Returns True on success."""
429
+ try:
430
+ p = subprocess.run(["pbcopy"], input=text, text=True, timeout=10)
431
+ return p.returncode == 0
432
+ except Exception:
433
+ return False
434
+
435
+ def _manual_paste_fallback(self, prompt, reason):
436
+ """Automation couldn't paste (no Accessibility, or osascript failed). Don't
437
+ dead-end: drop the prompt on the clipboard and open Claude so the user can
438
+ paste it themselves (Cmd+V, Enter). This is what makes re-arm/setup usable
439
+ even when the TCC grant is stale (granted but the running process still
440
+ reads untrusted until restart)."""
441
+ copied = self._copy_to_clipboard(prompt)
442
+ self._open_claude()
443
+ if copied:
444
+ self._notify(
445
+ "S4L · prompt copied to clipboard",
446
+ f"{reason} Paste it into Claude (⌘V) and press Enter to continue.",
447
+ )
448
+ else:
449
+ self._notify("S4L", f"{reason} Open Claude and type your request there.")
450
+ return False
451
+
452
+ def _clipboard_prompt(self, prompt, title, action_desc):
453
+ """The menu bar's UNIVERSAL way to hand an agent-driven action to Claude
454
+ without depending on the MCP/loopback being up (the menu bar can't inject
455
+ into the chat like the panel's sendMessage): copy the prompt to the
456
+ clipboard, show a modal, and open Claude so the user pastes it (Cmd+V,
457
+ Enter). This is the same reliable pattern re-arm has always used,
458
+ generalized to every agent action. NO auto-type (focus/timing/Accessibility
459
+ flaky — that path was the source of frozen-looking menus), NO MCP call."""
460
+ copied = self._copy_to_clipboard(prompt)
461
+ if copied:
462
+ msg = ("The prompt is copied to your clipboard.\n\nClick OK, then click "
463
+ "into the Claude chat, paste it (Cmd+V), and press Enter — "
464
+ + action_desc + ".")
465
+ else:
466
+ msg = "Click OK, then open Claude and ask it to " + action_desc + "."
467
+ try:
468
+ _activate_front()
469
+ rumps.alert(title=title, message=msg, ok="OK")
470
+ except Exception:
471
+ self._notify("S4L · prompt copied" if copied else "S4L",
472
+ "Paste the prompt into Claude (Cmd+V) and press Enter.")
473
+ self._open_claude()
474
+ return True
475
+
476
+ def _send_to_claude(self, prompt):
477
+ """Back-compat shim: every agent-driven menu action now uses the reliable
478
+ clipboard-prompt model (no flaky auto-type). Delegates to _clipboard_prompt."""
479
+ return self._clipboard_prompt(
480
+ prompt, "Send to Claude", "Claude will take it from there"
481
+ )
482
+
483
+ # Agent-driven action: hand the full setup prompt to Claude via the clipboard.
484
+ def _setup(self, _=None):
485
+ self._clipboard_prompt(
486
+ SETUP_PROMPT,
487
+ "Set up S4L in Claude",
488
+ "Claude will set up your runtime, connect X, configure your project, and "
489
+ "schedule the autopilot",
490
+ )
491
+
492
+
493
+ def _rearm(self, _=None):
494
+ """Register the draft schedule for the CURRENT account via the host
495
+ create_scheduled_task flow (same as onboarding) — it registers under
496
+ whatever account is logged in and shows in Routines. The host tool only
497
+ runs inside a chat turn, and the menu bar CANNOT inject into the chat (that
498
+ bridge is panel-only), so the reliable path here is: copy the prompt to the
499
+ clipboard, open Claude, and tell the user to paste it. (The dashboard
500
+ widget's button does this in one click via app.sendMessage — no paste.) We
501
+ do NOT auto-type (focus/timing flaky) and do NOT write the registry directly
502
+ (can't reliably target a just-switched-into account)."""
503
+ self._clipboard_prompt(
504
+ REARM_PROMPT,
505
+ "Set up the draft schedule",
506
+ "that schedules the draft tasks for this account",
507
+ )
508
+
509
+ # ---- schedule-state detection ----------------------------------------
510
+ def _schedule_state(self):
511
+ """Is the draft schedule registered AND running for the live account?
512
+ Returns 'ok' | 'disabled' | 'missing'. Delegates to the SINGLE source of
513
+ truth, scripts/schedule_state.py (shared with the Node MCP server, which
514
+ shells out to the same script), so the firing-detection algorithm lives in
515
+ exactly one place and the menu bar + dashboard can never drift. The script
516
+ is on sys.path via the S4L_REPO_DIR/scripts insertion near the top of this
517
+ file. Any failure -> 'missing' (safe: never a false 'ok')."""
518
+ try:
519
+ import schedule_state
520
+ return schedule_state.compute()
521
+ except Exception as e:
522
+ _capture(e, phase="schedule_state")
523
+ return "missing"
524
+
525
+ # ---- autopilot liveness (the false-green fix) -------------------------
526
+ def _autopilot_stalled(self):
527
+ """True when setup is done but no scheduled-task routine is draining the
528
+ draft queue — the signature of a Claude account switch orphaning the
529
+ routines while their global SKILL.md files (the old "autopilot_on" proxy)
530
+ stay put. Two complementary signals, OR'd; best-effort, pure file reads:
531
+
532
+ (1) LATCHED: the producer's drain-status shows >=1 consecutive timeout
533
+ with no successful drain since. Persists across the gap between cycles
534
+ (the producer removes the job on timeout, so there's no pending file
535
+ to see between cycles) -> the ⚠ stays on continuously instead of
536
+ flickering off. This is the durable signal.
537
+ (2) FAST: a draft job has sat unclaimed in pending/ past
538
+ AUTOPILOT_STALL_SECONDS -> catches a fresh stall ~3 min in, before
539
+ the first full producer timeout has even latched (1).
540
+ (3) IN-FLIGHT: a draft job was claimed (moved to running/) but never
541
+ finished within AUTOPILOT_RUNNING_STALL_SECONDS -> the worker picked
542
+ it up and then wedged mid-run. Self-clearing (the file is removed on
543
+ result or swept next cycle), so unlike the abandoned drain latch it
544
+ does NOT stay stale after recovery.
545
+
546
+ NOTE: kept in sync with scripts/autopilot_stall_watch.py (the fleet Sentry
547
+ backstop). The menu-bar ⚠ itself is driven by _schedule_state, NOT this
548
+ method — the attention/⚠ path keys off schedule_state so a firing-but-
549
+ momentarily-empty queue stays green (an earlier drain-latch ⚠ stayed stale
550
+ after recovery and was deliberately removed). This method exists for the
551
+ watcher-parity contract and _stall_reason.
552
+ """
553
+ qroot = os.path.join(st.state_dir(), "claude-queue")
554
+ # (1) latched producer drain-status
555
+ try:
556
+ with open(os.path.join(qroot, "drain-status.json")) as f:
557
+ if int((json.load(f) or {}).get("consecutive_timeouts", 0) or 0) >= 1:
558
+ return True
559
+ except Exception:
560
+ pass
561
+ # (2) fast pending-age
562
+ try:
563
+ oldest = None
564
+ for sub in glob.glob(os.path.join(qroot, "pending", "*")):
565
+ # feedback-digest jobs are latency-insensitive (hourly kicker,
566
+ # retried forever) and may wait behind a long draft job; their
567
+ # age is not an autopilot stall. Mirrors autopilotStalled() in
568
+ # mcp/src/index.ts.
569
+ if os.path.basename(sub) == "feedback-digest":
570
+ continue
571
+ for jf in glob.glob(os.path.join(sub, "*.json")):
572
+ if jf.endswith(".tmp"):
573
+ continue
574
+ try:
575
+ m = os.path.getmtime(jf)
576
+ except OSError:
577
+ continue
578
+ if oldest is None or m < oldest:
579
+ oldest = m
580
+ if oldest is not None and (time.time() - oldest) > AUTOPILOT_STALL_SECONDS:
581
+ return True
582
+ except Exception:
583
+ pass
584
+ # (3) in-flight running-age (claimed then wedged). running/ is flat.
585
+ try:
586
+ oldest = None
587
+ for jf in glob.glob(os.path.join(qroot, "running", "*.json")):
588
+ if jf.endswith(".tmp"):
589
+ continue
590
+ try:
591
+ m = os.path.getmtime(jf)
592
+ except OSError:
593
+ continue
594
+ if oldest is None or m < oldest:
595
+ oldest = m
596
+ if oldest is not None and (time.time() - oldest) > AUTOPILOT_RUNNING_STALL_SECONDS:
597
+ return True
598
+ except Exception:
599
+ pass
600
+ return False
601
+
602
+ def _recent_worker_outcome(self, window=600):
603
+ """Inspect worker transcripts written in the last `window` seconds (the
604
+ ~/.s4l-worker bucket). Returns (ran, rate_limit_msg):
605
+ ran — a routine actually EXECUTED recently (a worker that runs
606
+ leaves a transcript; an orphaned/not-firing account leaves
607
+ none). This is what tells "routines fire but fail" apart
608
+ from "routines gone".
609
+ rate_limit_msg— set when a recent run hit the Claude weekly/usage limit
610
+ (re-arm cannot fix that); carries a short 'resets …' string.
611
+ Account-agnostic on purpose: it keys off actual execution, not a per-account
612
+ lastRunAt that freezes (and lies) after an account switch."""
613
+ ran = False
614
+ limit_msg = None
615
+ try:
616
+ now = time.time()
617
+ files = glob.glob(
618
+ os.path.expanduser("~/.claude/projects/*s4l-worker*/*.jsonl")
619
+ )
620
+ recent = [f for f in files if (now - os.path.getmtime(f)) <= window]
621
+ recent.sort(key=os.path.getmtime, reverse=True)
622
+ if recent:
623
+ ran = True
624
+ for f in recent[:5]:
625
+ try:
626
+ txt = open(f).read()
627
+ except Exception:
628
+ continue
629
+ low = txt.lower()
630
+ # CRITICAL: only treat this as a limit when it is an actual API
631
+ # ERROR in this run — NEVER loose prose anywhere in the transcript.
632
+ # The drafting prompt embeds candidate threads + the feedback report,
633
+ # which frequently contain phrases like "weekly limit" / "rate limit"
634
+ # as CONTENT (an AI-product timeline is full of them — a 'claude-meter'
635
+ # example post "reached your weekly limit by tuesday" false-tripped the
636
+ # old prose match on 2026-06-29). The api-error markers below are set
637
+ # by the SDK only on real errors, so they can't appear in content.
638
+ #
639
+ # (a) HTTP-429 rate_limit — the definitive routines-lane shape:
640
+ # {"error":"rate_limit",...,"isApiErrorMessage":true,"apiErrorStatus":429}
641
+ if '"apierrorstatus":429' in low or '"error":"rate_limit"' in low:
642
+ limit_msg = "Claude rate limit reached (429)"
643
+ break
644
+ # (b) Weekly/usage-limit prose, but ONLY when carried inside a real
645
+ # API-error message (gated on the marker so content can't trip it).
646
+ if '"isapierrormessage":true' in low and (
647
+ "weekly limit" in low or "usage limit" in low or "hit your limit" in low
648
+ ):
649
+ import re
650
+ m = re.search(r"resets [^\"\\]{0,40}", txt)
651
+ limit_msg = m.group(0).strip().rstrip(".") if m else "Claude usage limit reached"
652
+ break
653
+ except Exception:
654
+ pass
655
+ return ran, limit_msg
656
+
657
+ def _stall_reason(self):
658
+ """Why drafts aren't draining, so the menu offers the RIGHT action:
659
+ ('orphaned', '') routines aren't firing -> Re-arm fixes it.
660
+ ('rate_limited', msg) routines fire but the account hit its Claude
661
+ limit -> Re-arm is useless; wait/switch account.
662
+ ('failing', '') routines fire but drafts fail for another reason.
663
+ Only meaningful when _autopilot_stalled() is True."""
664
+ ran, limit_msg = self._recent_worker_outcome()
665
+ if limit_msg:
666
+ return ("rate_limited", limit_msg)
667
+ if not ran:
668
+ return ("orphaned", "")
669
+ return ("failing", "")
670
+
671
+ def _toggle_lane(self, lane):
672
+ """Flip ONE engagement lane (personal_brand|promotion). Pure local state
673
+ write (no model, no network): the cycle reads mode.json on its next run.
674
+ Rebuild the menu right away so the checkmarks reflect the change instantly."""
675
+ flags = st.toggle_lane(lane)
676
+ pb, pr = flags.get("personal_brand"), flags.get("promotion")
677
+ if pb and pr:
678
+ msg = "Personal brand + promotion both on (cycles split 50/50)"
679
+ elif pb:
680
+ msg = "Personal brand only: organic, link-free"
681
+ elif pr:
682
+ msg = "Promotion only: marketing your products"
683
+ else:
684
+ msg = "Both lanes off (cycle falls back to personal brand)"
685
+ self._notify("S4L engagement lanes", msg)
686
+ # Force the next tick to rebuild (flags are in the signature, but null it
687
+ # so the rebuild can't be skipped) and rebuild now for snappy feedback.
688
+ self._sig = None
689
+ try:
690
+ self._tick(None)
691
+ except Exception as e:
692
+ sys.stderr.write(f"[s4l-menubar] lane toggle rebuild failed: {e}\n")
693
+ sys.stderr.flush()
694
+
695
+ def _toggle_personal(self, _=None):
696
+ self._toggle_lane(st.MODE_PERSONAL_BRAND)
697
+
698
+ def _toggle_promotion(self, _=None):
699
+ self._toggle_lane(st.MODE_PROMOTION)
700
+
701
+ # ---- factory reset (menu-bar driven) ----------------------------------
702
+ def _reset_machine(self, _=None):
703
+ """One-click 'reset this test machine to factory-fresh'. Runs the repo's
704
+ scripts/reset-test-machine.sh, whose one standard path quits Claude
705
+ Desktop, removes the Desktop extension + scheduled tasks, wipes the
706
+ state dir, then restarts Claude Desktop fresh.
707
+
708
+ CRITICAL self-kill avoidance: that script does `pkill -f s4l_menubar.py`
709
+ and boots out the menubar LaunchAgent (steps near line 124/141). If we ran
710
+ it as a direct child it would kill itself mid-wipe. So we detach it into its
711
+ own session (start_new_session=True) with a distinct command line — `pkill`
712
+ can't match it and the menubar dying doesn't take it down. The menubar is a
713
+ launchd process (NOT a Claude child), so it's the right place to drive a
714
+ reset that has to outlive Claude Desktop. Output streams to a log the user
715
+ can inspect after the menubar disappears."""
716
+ repo = os.environ.get("S4L_REPO_DIR") or ""
717
+ script = os.path.join(repo, "scripts", "reset-test-machine.sh")
718
+ if not repo or not os.path.exists(script):
719
+ self._alert("Uninstall unavailable",
720
+ "Couldn't find reset-test-machine.sh. S4L_REPO_DIR isn't "
721
+ "pointing at a pipeline source on this machine.")
722
+ return
723
+ # ok=1 (plugin reset, keeps X login + browser layer), other=-1 (deep wipe),
724
+ # cancel=0. See rumps.alert: default=1, alternate=0, other=-1.
725
+ _activate_front()
726
+ choice = rumps.alert(
727
+ title="Uninstall S4L?",
728
+ message=(
729
+ "This quits Claude Desktop, removes the S4L extension + its "
730
+ "scheduled tasks, and wipes the state dir, then restarts Claude "
731
+ "Desktop fresh (without S4L). This does NOT delete Claude Desktop "
732
+ "itself. The menu bar will disappear during the uninstall.\n\n"
733
+ "Uninstall: keep your X login + browser layer (quick uninstall).\n"
734
+ "Deep wipe: also remove the shared browser profiles + toolchain."
735
+ ),
736
+ ok="Uninstall & Restart Claude", cancel="Cancel", other="Deep wipe",
737
+ )
738
+ if choice == 0: # cancel
739
+ return
740
+ deep = (choice == -1)
741
+ args = ["bash", script, "--yes"] + (["--deep"] if deep else [])
742
+ log_path = "/tmp/s4l-reset.log"
743
+ try:
744
+ log = open(log_path, "ab", buffering=0)
745
+ subprocess.Popen(
746
+ args,
747
+ stdin=subprocess.DEVNULL,
748
+ stdout=log,
749
+ stderr=log,
750
+ start_new_session=True, # detach: survive pkill + menubar bootout
751
+ close_fds=True,
752
+ cwd=repo,
753
+ )
754
+ except Exception as e:
755
+ _capture(e, action="reset_machine")
756
+ self._alert("Uninstall failed to start", str(e)[:200])
757
+ return
758
+ # Best-effort heads-up before the menubar gets pkilled by the script.
759
+ self._notify(
760
+ "S4L uninstall started",
761
+ "Uninstalling" + (" (deep)" if deep else "") +
762
+ "… the menu bar will vanish and Claude Desktop will restart when "
763
+ "done; log at " + log_path,
764
+ )
765
+
766
+ def _alert(self, title, message):
767
+ try:
768
+ _activate_front()
769
+ rumps.alert(title=title, message=message, ok="OK")
770
+ except Exception:
771
+ pass
772
+
773
+ # ---- disable scheduled tasks (menu-bar driven) ------------------------
774
+ def _has_scheduled_tasks(self):
775
+ """Read-only: True if any S4L worker/autopilot task is registered in any
776
+ scheduled-tasks.json. Gates whether the 'Disable scheduled tasks' item is
777
+ worth showing."""
778
+ try:
779
+ wanted = set(WORKER_TASK_IDS) | set(DEPRECATED_TASK_IDS)
780
+ for f in glob.glob(SCHED_REGISTRY_GLOB):
781
+ try:
782
+ with open(f) as fh:
783
+ d = json.load(fh)
784
+ except Exception:
785
+ continue
786
+ for t in d.get("scheduledTasks", []):
787
+ if t.get("id") in wanted:
788
+ return True
789
+ except Exception:
790
+ pass
791
+ return False
792
+
793
+ def _quit_app(self, _=None):
794
+ """The single Quit path. Quitting stops the autopilot completely: the
795
+ draft/query scheduled tasks are removed so they no longer fire, AND the
796
+ tray itself goes away for good (stop flag + plist removal + self
797
+ bootout — see _quit_work). Claude Desktop OWNS the live schedule and
798
+ caches the registry in memory, clobbering any live edit on the next
799
+ fire — so the only reliable way to disable them is to quit Claude,
800
+ strip the tasks while it's down, then relaunch. We warn the user with a
801
+ modal FIRST that Claude Desktop will restart, since the app window will
802
+ close and reopen under them."""
803
+ _activate_front()
804
+ choice = rumps.alert(
805
+ title="Quit the S4L autoposter?",
806
+ message=(
807
+ "Quitting stops the autoposter completely: the draft + query "
808
+ "scheduled tasks are removed so nothing fires anymore, and this "
809
+ "menu bar icon goes away and stays away.\n\n"
810
+ "Claude Desktop will quit and restart to apply this — its window "
811
+ "will close and reopen in a moment. Your X login, browser layer, "
812
+ "and config all stay.\n\n"
813
+ "To start S4L again later, open Claude and say \"start S4L\" "
814
+ "(or re-run setup)."
815
+ ),
816
+ ok="Quit & restart Claude", cancel="Cancel",
817
+ )
818
+ if choice != 1: # only default button (OK) proceeds
819
+ return
820
+ self._notify("S4L", "Quitting… Claude will restart in a moment.")
821
+ threading.Thread(target=self._quit_work, daemon=True).start()
822
+
823
+ def _remove_scheduled_tasks(self):
824
+ """Strip ALL S4L worker + deprecated tasks from every scheduled-tasks.json
825
+ registry, and remove their on-disk task dirs. Caller MUST invoke this only
826
+ while Claude is DOWN (the running app caches the registry and clobbers a
827
+ live edit on the next fire). Best-effort; never raises. Sibling of
828
+ _rewrite_scheduled_task_cwd, but it DELETES the worker tasks instead of
829
+ relocating them."""
830
+ wanted = set(WORKER_TASK_IDS) | set(DEPRECATED_TASK_IDS)
831
+ try:
832
+ for f in glob.glob(SCHED_REGISTRY_GLOB):
833
+ try:
834
+ with open(f) as fh:
835
+ d = json.load(fh)
836
+ except Exception:
837
+ continue
838
+ tasks = d.get("scheduledTasks") or []
839
+ kept = [t for t in tasks if t.get("id") not in wanted]
840
+ if len(kept) == len(tasks):
841
+ continue
842
+ d["scheduledTasks"] = kept
843
+ try:
844
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(f))
845
+ with os.fdopen(fd, "w") as fh:
846
+ json.dump(d, fh, indent=2)
847
+ os.replace(tmp, f)
848
+ except Exception:
849
+ pass
850
+ except Exception:
851
+ pass
852
+ # Remove the on-disk task dirs (prompt/SKILL.md) so a stale file can't
853
+ # re-register them.
854
+ try:
855
+ import shutil
856
+ base = os.path.join(os.path.expanduser("~"), ".claude", "scheduled-tasks")
857
+ for tid in wanted:
858
+ shutil.rmtree(os.path.join(base, tid), ignore_errors=True)
859
+ except Exception:
860
+ pass
861
+
862
+ def _quit_work(self):
863
+ """Quit/kill Claude, strip the scheduled tasks while it's down, relaunch
864
+ Claude, then take THIS tray down for good. Mirror of
865
+ _relocate_restart_work's restart block. The menu bar is a separate
866
+ launchd process, so killing Claude does not kill us.
867
+
868
+ The stop flag is written FIRST: the relaunched Claude boots the MCP
869
+ server, whose ensureMenubar() would otherwise reinstall the tray
870
+ unconditionally (the reappearing-icon bug). The plist is deleted so
871
+ RunAtLoad can't resurrect us at next login, and the final bootout
872
+ removes the KeepAlive job — which kills this process, so it must be
873
+ the last thing we do."""
874
+ try:
875
+ # Capture while Claude is still alive (see _claude_user_data_dirs):
876
+ # the post-quit relaunch must preserve custom --user-data-dirs.
877
+ user_data_dirs = self._claude_user_data_dirs()
878
+ try:
879
+ with open(STOP_FLAG, "w") as fh:
880
+ fh.write(f"user quit via menu bar at {time.strftime('%Y-%m-%dT%H:%M:%S%z')}\n")
881
+ except Exception as e:
882
+ _capture(e, action="quit_stop_flag")
883
+ self._quit_claude_and_wait()
884
+ self._remove_scheduled_tasks()
885
+ try:
886
+ os.remove(MENUBAR_PLIST)
887
+ except FileNotFoundError:
888
+ pass
889
+ except Exception as e:
890
+ _capture(e, action="quit_remove_plist")
891
+ self._relaunch_claude(user_data_dirs)
892
+ self._notify("S4L", "S4L stopped. Say \"start S4L\" in Claude to bring it back.")
893
+ except Exception as e:
894
+ _capture(e, action="quit_app")
895
+ self._notify("S4L", "Couldn't fully stop S4L — see logs.")
896
+ finally:
897
+ # Boot out our own KeepAlive agent. launchd kills this process as
898
+ # part of the bootout, so nothing after this line is guaranteed to
899
+ # run. Runs even if the Claude restart above failed: the user asked
900
+ # for the tray to be gone.
901
+ subprocess.run(
902
+ ["launchctl", "bootout", f"gui/{os.getuid()}/{MENUBAR_LABEL}"],
903
+ capture_output=True, timeout=15,
904
+ )
905
+ # Only reached if bootout didn't kill us (e.g. dev run outside
906
+ # launchd). Exit 0: KeepAlive {SuccessfulExit: false} treats a clean
907
+ # exit as final. os._exit because we're on a background thread.
908
+ os._exit(0)
909
+
910
+ def _update(self, _=None):
911
+ self._send_to_claude(UPDATE_PROMPT)
912
+
913
+ # ---- .mcpb self-update (menu-bar driven) ------------------------------
914
+ @staticmethod
915
+ def _ext_dir():
916
+ """Resolve this plugin's Claude Desktop extension dir.
917
+
918
+ Claude derives the extension id from the manifest author, so it changed
919
+ `local.mcpb.m13v.social-autoposter` ->
920
+ `local.mcpb.s4l.ai.social-autoposter` when the author became "S4L.ai". A
921
+ hardcoded id silently breaks the self-update button on every fresh
922
+ install (the update unzips into a dir that doesn't exist, so the version
923
+ never advances and fixes never land). Pick the newest `*social-autoposter`
924
+ extension dir that actually has a manifest.json; fall back to the
925
+ historical id so old boxes are unaffected.
926
+
927
+ Scans "Claude*" app-support roots, not just "Claude": the host app can
928
+ run with a custom --user-data-dir (per-account dirs like
929
+ "Claude-mediar"), and the live extension lives under THAT dir while
930
+ plain "Claude/" may have no Claude Extensions at all. Same blind spot
931
+ family as scripts/schedule_state.py::SCHED_REGISTRY_GLOB (fixed
932
+ 2026-07-02); here it made the update button unzip into a void and fail
933
+ verification on such machines.
934
+ """
935
+ app_support = os.path.expanduser("~/Library/Application Support")
936
+ best, best_mtime = None, -1.0
937
+ for root in glob.glob(os.path.join(app_support, "Claude*", "Claude Extensions")):
938
+ try:
939
+ for name in os.listdir(root):
940
+ if not name.endswith("social-autoposter"):
941
+ continue
942
+ d = os.path.join(root, name)
943
+ if not os.path.exists(os.path.join(d, "manifest.json")):
944
+ continue
945
+ m = os.path.getmtime(d)
946
+ if m > best_mtime:
947
+ best, best_mtime = d, m
948
+ except OSError:
949
+ continue
950
+ return best or os.path.join(
951
+ app_support, "Claude", "Claude Extensions",
952
+ "local.mcpb.m13v.social-autoposter",
953
+ )
954
+
955
+ MCPB_URL = (
956
+ "https://github.com/m13v/s4l/releases/latest/download/"
957
+ "social-autoposter.mcpb"
958
+ )
959
+ RELEASE_API = (
960
+ "https://api.github.com/repos/m13v/s4l/releases/latest"
961
+ )
962
+
963
+ def _mcpb_url(self):
964
+ """Download URL for THIS box's channel. Stable uses releases/latest
965
+ (server-resolved); staging pulls the specific resolved tag, since
966
+ releases/latest excludes the prerelease a staging box wants. Falls back to
967
+ releases/latest whenever the tag is unknown."""
968
+ if self._channel == "staging" and self._latest_tag:
969
+ return (
970
+ "https://github.com/m13v/s4l/releases/download/"
971
+ "%s/social-autoposter.mcpb" % self._latest_tag
972
+ )
973
+ return self.MCPB_URL
974
+
975
+
976
+ def _do_mcpb_update(self, _=None):
977
+ """User clicked 'Update now & restart Claude Desktop'. Pull the latest .mcpb, unpack it over
978
+ the Desktop extension dir in place, and restart Claude so the new server
979
+ loads. The menu bar is a launchd process (not a Claude child), so the
980
+ restart is clean. Heavy work runs on a background thread."""
981
+ self._notify("S4L", "Updating… Claude will restart in a moment.")
982
+ threading.Thread(target=self._mcpb_update_work, daemon=True).start()
983
+
984
+ @staticmethod
985
+ def _claude_user_data_dirs():
986
+ """The --user-data-dir of every RUNNING Claude instance, in ps order;
987
+ a default-profile instance (no flag) is recorded as None. Empty when
988
+ no Claude is running.
989
+
990
+ Must be captured BEFORE quitting Claude: a bare `open -a Claude`
991
+ relaunch drops the flag and boots the DEFAULT-profile instance,
992
+ stranding users who run Claude with a per-account data dir (found
993
+ 2026-07-02: the update restart landed in the wrong profile). killall
994
+ takes down EVERY profile's instance, so all of them must be captured
995
+ and relaunched, not just the first ps match. The value can contain
996
+ spaces (…/Application Support/…), so parse the ps line with a regex
997
+ up to the next ` --` flag, not by token split.
998
+ """
999
+ dirs = []
1000
+ try:
1001
+ out = subprocess.run(["ps", "-axo", "command"], capture_output=True,
1002
+ text=True, timeout=10).stdout
1003
+ for line in out.splitlines():
1004
+ if "/Claude.app/Contents/MacOS/Claude" not in line:
1005
+ continue
1006
+ m = re.search(r"--user-data-dir=(.+?)(?= --|$)", line)
1007
+ d = m.group(1).strip() if m else None
1008
+ if d not in dirs:
1009
+ dirs.append(d)
1010
+ except Exception:
1011
+ pass
1012
+ return dirs
1013
+
1014
+ @classmethod
1015
+ def _claude_user_data_dir(cls):
1016
+ """First custom --user-data-dir among running instances, or None.
1017
+ Prefer _claude_user_data_dirs when relaunching after a kill — the
1018
+ kill takes every profile down, not just this one."""
1019
+ return next((d for d in cls._claude_user_data_dirs() if d), None)
1020
+
1021
+ @staticmethod
1022
+ def _claude_running():
1023
+ """True while a Claude Desktop main process is alive. pgrep -x matches
1024
+ the binary name exactly, so 'Claude Helper …' renderers and claude-code
1025
+ CLI children don't count."""
1026
+ try:
1027
+ return subprocess.run(["pgrep", "-x", "Claude"],
1028
+ capture_output=True, timeout=10).returncode == 0
1029
+ except Exception:
1030
+ return False
1031
+
1032
+ def _quit_claude_and_wait(self, grace_sec=300):
1033
+ """Ask Claude to quit and return only once every instance is gone,
1034
+ escalating to killall if the graceful quit stalls.
1035
+
1036
+ The quit Apple event doesn't get its reply until the app finishes
1037
+ tearing down, which can take minutes with claude-code sessions open.
1038
+ 2026-07-02: teardown outlived the old inline block's 20s subprocess
1039
+ timeout, the TimeoutExpired flew past the caller's relaunch step, and
1040
+ Claude finished quitting on its own with nothing left to restart it.
1041
+ A timeout on the osascript call is expected and harmless; process
1042
+ polling is the real completion signal."""
1043
+ try:
1044
+ subprocess.run(["osascript", "-e", 'tell application "Claude" to quit'],
1045
+ capture_output=True, timeout=20)
1046
+ except subprocess.TimeoutExpired:
1047
+ pass
1048
+ deadline = time.time() + grace_sec
1049
+ while self._claude_running() and time.time() < deadline:
1050
+ time.sleep(3)
1051
+ if self._claude_running():
1052
+ subprocess.run(["killall", "Claude"], capture_output=True) # quit stalled
1053
+ time.sleep(2)
1054
+ if self._claude_running():
1055
+ subprocess.run(["killall", "-9", "Claude"], capture_output=True)
1056
+ time.sleep(1)
1057
+
1058
+ def _relaunch_claude(self, user_data_dirs=None):
1059
+ """Reopen Claude, preserving each custom --user-data-dir captured
1060
+ before the kill (accepts a single dir or a list). `open -n` forces a
1061
+ fresh instance per profile — without it LaunchServices focuses the
1062
+ first instance and drops the args, so only one profile would return.
1063
+ Retries once if no process appears: right after a kill, `open` can
1064
+ no-op while LaunchServices still thinks the app is running. Keep in
1065
+ sync with the other relaunch sites."""
1066
+ if isinstance(user_data_dirs, str):
1067
+ user_data_dirs = [user_data_dirs]
1068
+
1069
+ def _open_all():
1070
+ for d in (user_data_dirs or [None]):
1071
+ if d:
1072
+ subprocess.run(
1073
+ ["open", "-n", "-a", CLAUDE_APP, "--args",
1074
+ f"--user-data-dir={d}"],
1075
+ capture_output=True, timeout=20)
1076
+ else:
1077
+ subprocess.run(["open", "-a", CLAUDE_APP],
1078
+ capture_output=True, timeout=20)
1079
+
1080
+ _open_all()
1081
+ time.sleep(5)
1082
+ if not self._claude_running():
1083
+ time.sleep(5)
1084
+ _open_all()
1085
+
1086
+ def _mcpb_update_work(self):
1087
+ tmpd = tempfile.mkdtemp(prefix="s4l-update-")
1088
+ mcpb = os.path.join(tmpd, "social-autoposter.mcpb")
1089
+ try:
1090
+ # Capture while Claude is still alive; unreadable after the kill.
1091
+ user_data_dirs = self._claude_user_data_dirs()
1092
+ r = subprocess.run(["curl", "-fLs", "-m", "300", self._mcpb_url(), "-o", mcpb],
1093
+ capture_output=True, timeout=320)
1094
+ if r.returncode != 0 or not os.path.exists(mcpb) or os.path.getsize(mcpb) < 100000:
1095
+ self._notify("S4L update failed", "Couldn't download the update — check your connection.")
1096
+ return
1097
+ r = subprocess.run(["unzip", "-oq", mcpb, "-d", self._ext_dir()],
1098
+ capture_output=True, timeout=180)
1099
+ if r.returncode != 0:
1100
+ self._notify("S4L update failed", "Couldn't unpack the update.")
1101
+ return
1102
+ # Record what we just installed so the tick loop can verify the
1103
+ # EFFECTIVE version actually advanced after the restart. The old
1104
+ # flow claimed success unconditionally, which lied on boxes whose
1105
+ # pipeline repo was pinned (e.g. by a stray git checkout): the
1106
+ # extension dir updated but the running install stayed old.
1107
+ target = ""
1108
+ try:
1109
+ with open(os.path.join(self._ext_dir(), "manifest.json")) as f:
1110
+ target = str((json.load(f) or {}).get("version") or "")
1111
+ except Exception:
1112
+ target = ""
1113
+ if target:
1114
+ try:
1115
+ with open(self._update_verify_path(), "w") as f:
1116
+ json.dump({"target": target, "started_at": time.time()}, f)
1117
+ except Exception:
1118
+ pass
1119
+ # Restart Claude so the refreshed server loads (we're decoupled from it).
1120
+ self._quit_claude_and_wait()
1121
+ # Claude is fully down now — relocate the autopilot scheduled tasks'
1122
+ # cwd so their once-a-minute runs stop flooding the user's interactive
1123
+ # `claude --resume` history. MUST happen while Claude is down (it caches
1124
+ # the registry in memory and clobbers live edits). See queueWorkerCwd()
1125
+ # in mcp/src/index.ts and the same routine in scripts/s4l_box_update.sh.
1126
+ self._rewrite_scheduled_task_cwd()
1127
+ if target:
1128
+ # The graceful quit can eat minutes; restart the verify clock
1129
+ # now that Claude is actually down so UPDATE_VERIFY_GRACE_SEC
1130
+ # measures server boot, not app teardown.
1131
+ try:
1132
+ with open(self._update_verify_path(), "w") as f:
1133
+ json.dump({"target": target, "started_at": time.time()}, f)
1134
+ except Exception:
1135
+ pass
1136
+ self._relaunch_claude(user_data_dirs)
1137
+ self._update_available = False
1138
+ self._sig = None
1139
+ if target:
1140
+ # Honest phrasing: the verdict (success OR the real blocker)
1141
+ # comes from _check_update_verdict once the new server settles.
1142
+ self._notify("S4L update", f"v{target} installed; Claude is restarting. I'll confirm once it's live.")
1143
+ else:
1144
+ self._notify("S4L updated", "Claude restarted on the latest version.")
1145
+ except Exception as e:
1146
+ self._notify("S4L update failed", str(e)[:140])
1147
+ finally:
1148
+ try:
1149
+ import shutil
1150
+ shutil.rmtree(tmpd, ignore_errors=True)
1151
+ except Exception:
1152
+ pass
1153
+
1154
+ # ---- post-update verification (marker + tick-driven verdict) ----------
1155
+ # _mcpb_update_work writes a marker with the version it unpacked; the tick
1156
+ # loop (which survives the Claude restart, and also runs in the REPLACEMENT
1157
+ # menu bar process if the server reloads this agent) compares it against the
1158
+ # version the pipeline actually resolves to. Success notifies honestly;
1159
+ # failure names the real blocker (a stray git checkout pinning the repo)
1160
+ # instead of the old unconditional "restarted on the latest version" toast.
1161
+ UPDATE_VERIFY_GRACE_SEC = 240
1162
+
1163
+ @staticmethod
1164
+ def _update_verify_path():
1165
+ return os.path.join(st.state_dir(), "update-verify.json")
1166
+
1167
+ @staticmethod
1168
+ def _effective_version():
1169
+ """Return (version, repo_dir) the install actually runs, reading the
1170
+ same sources snapshot.py uses. runtime.json's repo_dir is authoritative
1171
+ (it is what the server re-points after healing a stray checkout); the
1172
+ env / ~/social-autoposter fallbacks mirror the legacy resolution."""
1173
+ repo = None
1174
+ try:
1175
+ with open(os.path.join(st.state_dir(), "runtime.json")) as f:
1176
+ rd = (json.load(f) or {}).get("repo_dir")
1177
+ if rd and os.path.isdir(os.path.join(rd, "scripts")):
1178
+ repo = rd
1179
+ except Exception:
1180
+ pass
1181
+ if not repo:
1182
+ repo = os.environ.get("S4L_REPO_DIR") or os.path.expanduser(
1183
+ "~/social-autoposter"
1184
+ )
1185
+ for rel in (("mcp", "dist", "version.json"), ("package.json",)):
1186
+ try:
1187
+ with open(os.path.join(repo, *rel)) as f:
1188
+ v = (json.load(f) or {}).get("version")
1189
+ if v:
1190
+ return str(v), repo
1191
+ except Exception:
1192
+ continue
1193
+ return None, repo
1194
+
1195
+ @staticmethod
1196
+ def _ver_tuple(v):
1197
+ out = []
1198
+ for part in str(v).split("-")[0].split("+")[0].split("."):
1199
+ try:
1200
+ out.append(int(part))
1201
+ except ValueError:
1202
+ out.append(0)
1203
+ return tuple(out)
1204
+
1205
+ def _check_update_verdict(self):
1206
+ p = self._update_verify_path()
1207
+ if not os.path.exists(p):
1208
+ return
1209
+ try:
1210
+ with open(p) as f:
1211
+ marker = json.load(f) or {}
1212
+ except Exception:
1213
+ marker = {}
1214
+ target = str(marker.get("target") or "")
1215
+ try:
1216
+ started = float(marker.get("started_at") or 0)
1217
+ except (TypeError, ValueError):
1218
+ started = 0.0
1219
+ if not target:
1220
+ self._drop_update_marker(p)
1221
+ return
1222
+ effective, repo = self._effective_version()
1223
+ if effective and self._ver_tuple(effective) >= self._ver_tuple(target):
1224
+ self._drop_update_marker(p)
1225
+ self._notify("S4L updated", f"Now on v{effective}.")
1226
+ return
1227
+ if time.time() - started < self.UPDATE_VERIFY_GRACE_SEC:
1228
+ return # Claude restart + server boot + pipeline refresh still settling
1229
+ self._drop_update_marker(p)
1230
+ if repo and os.path.isdir(os.path.join(repo, ".git")):
1231
+ self._notify(
1232
+ "S4L update did not take effect",
1233
+ f"Still v{effective or 'unknown'}: the install is pinned by a git "
1234
+ f"checkout at {repo}. Remove or rename that folder, then update again.",
1235
+ )
1236
+ else:
1237
+ self._notify(
1238
+ "S4L update did not take effect",
1239
+ f"Still v{effective or 'unknown'} (target v{target}). "
1240
+ "Try updating again from the menu.",
1241
+ )
1242
+
1243
+ @staticmethod
1244
+ def _drop_update_marker(p):
1245
+ try:
1246
+ os.remove(p)
1247
+ except OSError:
1248
+ pass
1249
+
1250
+ @staticmethod
1251
+ def _scheduled_task_cwd_needs_fix():
1252
+ """Read-only: True if any worker task runs in the wrong folder, the
1253
+ deprecated autopilot task still exists, OR a legacy per-type worker
1254
+ registration remains (to be consolidated into the single s4l-worker).
1255
+ Drives the one-shot self-heal."""
1256
+ try:
1257
+ for f in glob.glob(SCHED_REGISTRY_GLOB):
1258
+ try:
1259
+ with open(f) as fh:
1260
+ d = json.load(fh)
1261
+ except Exception:
1262
+ continue
1263
+ for t in d.get("scheduledTasks", []):
1264
+ tid = t.get("id")
1265
+ if tid in DEPRECATED_TASK_IDS:
1266
+ return True
1267
+ if tid in LEGACY_WORKER_TASK_IDS:
1268
+ return True
1269
+ if tid in WORKER_TASK_IDS and t.get("cwd") != WORKER_CWD:
1270
+ return True
1271
+ except Exception:
1272
+ pass
1273
+ return False
1274
+
1275
+ @staticmethod
1276
+ def _ensure_worker_skill_md():
1277
+ """Make sure ~/.claude/scheduled-tasks/s4l-worker/SKILL.md exists before
1278
+ we register a task that points at it. The MCP writes it on every boot
1279
+ (create-if-missing), so normally this is a no-op; as a belt-and-suspenders
1280
+ fallback we clone a legacy worker's file (same universal body since
1281
+ prompt v7) and fix the frontmatter name."""
1282
+ base = os.path.join(os.path.expanduser("~"), ".claude", "scheduled-tasks")
1283
+ dst = os.path.join(base, WORKER_TASK_ID, "SKILL.md")
1284
+ if os.path.exists(dst):
1285
+ return True
1286
+ for tid in LEGACY_WORKER_TASK_IDS:
1287
+ src = os.path.join(base, tid, "SKILL.md")
1288
+ try:
1289
+ with open(src) as fh:
1290
+ body = fh.read()
1291
+ except Exception:
1292
+ continue
1293
+ try:
1294
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
1295
+ with open(dst, "w") as fh:
1296
+ fh.write(body.replace(f"name: {tid}", f"name: {WORKER_TASK_ID}", 1))
1297
+ return True
1298
+ except Exception:
1299
+ continue
1300
+ return False
1301
+
1302
+ def _rewrite_scheduled_task_cwd(self):
1303
+ """Registry self-heal, run ONLY while Claude is DOWN (the running app
1304
+ caches the registry in memory and clobbers a live edit on the next
1305
+ fire). Three fixes in one pass, across every scheduled-tasks.json:
1306
+ 1. Point worker tasks' cwd at ~/.s4l-worker.
1307
+ 2. REMOVE the deprecated single autopilot task.
1308
+ 3. CONSOLIDATE every legacy worker entry into ONE s4l-worker entry
1309
+ (the universal type-blind worker): drop the legacy entries and,
1310
+ if no s4l-worker is registered there yet, add one inheriting the
1311
+ legacy cron/enabled state. This is the migration path for installs
1312
+ that predate the universal queue.
1313
+ Best-effort: never raises. Kept in sync with scripts/s4l_box_update.sh
1314
+ and queueWorkerCwd()/QUEUE_WORKERS in mcp/src/index.ts."""
1315
+ try:
1316
+ os.makedirs(WORKER_CWD, exist_ok=True)
1317
+ except Exception:
1318
+ pass
1319
+ worker_skill_ok = self._ensure_worker_skill_md()
1320
+ try:
1321
+ for f in glob.glob(SCHED_REGISTRY_GLOB):
1322
+ try:
1323
+ with open(f) as fh:
1324
+ d = json.load(fh)
1325
+ except Exception:
1326
+ continue
1327
+ tasks = d.get("scheduledTasks") or []
1328
+ legacy = [t for t in tasks if t.get("id") in LEGACY_WORKER_TASK_IDS]
1329
+ has_worker = any(t.get("id") == WORKER_TASK_ID for t in tasks)
1330
+ new_tasks = []
1331
+ dirty = False
1332
+ for t in tasks:
1333
+ tid = t.get("id")
1334
+ if tid in DEPRECATED_TASK_IDS:
1335
+ dirty = True # drop it
1336
+ continue
1337
+ if tid in LEGACY_WORKER_TASK_IDS and worker_skill_ok:
1338
+ dirty = True # consolidated into s4l-worker below
1339
+ continue
1340
+ if tid in WORKER_TASK_IDS and t.get("cwd") != WORKER_CWD:
1341
+ t["cwd"] = WORKER_CWD
1342
+ dirty = True
1343
+ new_tasks.append(t)
1344
+ if legacy and not has_worker and worker_skill_ok:
1345
+ tmpl = legacy[0]
1346
+ new_tasks.append({
1347
+ "id": WORKER_TASK_ID,
1348
+ "cronExpression": tmpl.get("cronExpression") or "* * * * *",
1349
+ "enabled": bool(tmpl.get("enabled", True)),
1350
+ "filePath": os.path.join(
1351
+ os.path.expanduser("~"), ".claude",
1352
+ "scheduled-tasks", WORKER_TASK_ID, "SKILL.md",
1353
+ ),
1354
+ # Fresh createdAt keeps schedule_state's CREATED_GRACE
1355
+ # treating the never-yet-fired task as "ok" until its
1356
+ # first fire lands (no ⚠ flap during the restart).
1357
+ "createdAt": int(time.time() * 1000),
1358
+ "cwd": WORKER_CWD,
1359
+ })
1360
+ dirty = True
1361
+ if not dirty:
1362
+ continue
1363
+ d["scheduledTasks"] = new_tasks
1364
+ try:
1365
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(f))
1366
+ with os.fdopen(fd, "w") as fh:
1367
+ json.dump(d, fh, indent=2)
1368
+ os.replace(tmp, f)
1369
+ except Exception:
1370
+ pass
1371
+ except Exception:
1372
+ pass
1373
+ # Remove retired tasks' on-disk SKILL.md dirs too, so they can't be
1374
+ # re-registered from a stale prompt file (and the MCP's boot refresh
1375
+ # stops resurrecting the legacy prompts).
1376
+ try:
1377
+ import shutil
1378
+ retired = list(DEPRECATED_TASK_IDS)
1379
+ if worker_skill_ok:
1380
+ retired += list(LEGACY_WORKER_TASK_IDS)
1381
+ for tid in retired:
1382
+ shutil.rmtree(os.path.join(os.path.expanduser("~"), ".claude",
1383
+ "scheduled-tasks", tid), ignore_errors=True)
1384
+ except Exception:
1385
+ pass
1386
+
1387
+ def _maybe_relocate_tasks(self, _=None):
1388
+ """Timer callback: detect, then ASK. If the autopilot tasks are in the wrong
1389
+ folder (or the deprecated task lingers), prompt the user before relocating —
1390
+ the fix needs a single Claude restart (the app caches the registry in
1391
+ memory), so we never restart silently. Prompts at most once per process;
1392
+ 'Later' stops the auto-prompt but the fix stays available from the menu
1393
+ ('Tidy autopilot history'). Runs on the main thread (rumps timer), so the
1394
+ modal is safe to raise here."""
1395
+ if self._relocating or self._cwd_healed or self._reloc_prompted:
1396
+ return
1397
+ try:
1398
+ if not self._scheduled_task_cwd_needs_fix():
1399
+ self._reloc_needed = False
1400
+ return
1401
+ self._reloc_needed = True
1402
+ self._reloc_prompted = True
1403
+ try:
1404
+ self._reloc_timer.stop() # one auto-prompt per process
1405
+ except Exception:
1406
+ pass
1407
+ self._prompt_relocate_tasks()
1408
+ except Exception:
1409
+ pass
1410
+
1411
+ def _prompt_relocate_tasks(self, _=None):
1412
+ """Modal-first relocate. Warns (like Quit does) that Claude restarts once,
1413
+ then runs the kill -> rewrite cwd -> relaunch on a background thread. Wired
1414
+ to both the auto-detect timer and the 'Tidy autopilot history' menu item
1415
+ (the `_` arg), so 'Later' is never a dead end."""
1416
+ if self._relocating:
1417
+ return
1418
+ if not self._scheduled_task_cwd_needs_fix():
1419
+ self._reloc_needed = False
1420
+ self._cwd_healed = True
1421
+ return
1422
+ _activate_front()
1423
+ choice = rumps.alert(
1424
+ title="Tidy the S4L background tasks?",
1425
+ message=(
1426
+ "S4L can tidy its background tasks: merge the old draft + query "
1427
+ "tasks into ONE universal worker (s4l-worker) and make sure "
1428
+ "their once-a-minute runs stay in a dedicated folder instead of "
1429
+ "cluttering your `claude --resume` history.\n\n"
1430
+ "Claude Desktop will restart once to apply — its window will "
1431
+ "close and reopen in a moment. Your X login, drafts, and config "
1432
+ "all stay."
1433
+ ),
1434
+ ok="Tidy & restart Claude", cancel="Later",
1435
+ )
1436
+ if choice != 1: # only default button (OK) proceeds
1437
+ return
1438
+ self._relocating = True
1439
+ self._notify("S4L", "Tidying autopilot… Claude will restart once.")
1440
+ threading.Thread(target=self._relocate_restart_work, daemon=True).start()
1441
+
1442
+ def _relocate_restart_work(self):
1443
+ """Restart Claude with the tasks relocated. Mirror of _mcpb_update_work's
1444
+ restart block: quit/kill Claude, rewrite the registry while it's down, then
1445
+ relaunch. The menu bar is a separate launchd process, so killing Claude does
1446
+ not kill us."""
1447
+ try:
1448
+ # Capture while Claude is still alive (see _claude_user_data_dirs):
1449
+ # a bare relaunch drops a custom --user-data-dir and boots the
1450
+ # wrong profile, which orphans these very tasks from the registry.
1451
+ user_data_dirs = self._claude_user_data_dirs()
1452
+ self._quit_claude_and_wait()
1453
+ self._rewrite_scheduled_task_cwd()
1454
+ self._relaunch_claude(user_data_dirs)
1455
+ time.sleep(8) # let Claude reload the registry before we re-check
1456
+ if not self._scheduled_task_cwd_needs_fix():
1457
+ self._cwd_healed = True
1458
+ self._reloc_needed = False
1459
+ # Push a fresh heartbeat now so the server/dashboard reflects the
1460
+ # corrected scheduled-task folder state within seconds instead of
1461
+ # waiting up to ~15 min for the next MCP heartbeat. Best-effort.
1462
+ self._fire_heartbeat()
1463
+ except Exception:
1464
+ pass
1465
+ finally:
1466
+ self._relocating = False
1467
+
1468
+ def _fire_heartbeat(self):
1469
+ """Best-effort: run the npx-lane heartbeat.sh once so the install's
1470
+ scheduled_tasks sample updates centrally right after a relocation. Never
1471
+ raises; a missing repo/script or network hiccup is silently ignored (the
1472
+ MCP's own ~15-min heartbeat is the durable channel)."""
1473
+ try:
1474
+ repo = os.environ.get("S4L_REPO_DIR") or ""
1475
+ hb = os.path.join(repo, "scripts", "heartbeat.sh")
1476
+ if not (repo and os.path.exists(hb)):
1477
+ return
1478
+ env = dict(os.environ, REPO_DIR=repo)
1479
+ subprocess.run(["bash", hb], capture_output=True, timeout=30, env=env)
1480
+ except Exception:
1481
+ pass
1482
+
1483
+ def _open_dashboard(self, _=None):
1484
+ # Prefer the LIVE MCP loopback panel (full interactivity — its buttons
1485
+ # reach the MCP tool handlers) when Claude is up. When it's NOT, fall back
1486
+ # to the always-on menu-bar dashboard server, which serves the SAME
1487
+ # panel.html and answers read tools from scripts/snapshot.py — so "Open
1488
+ # dashboard" renders the status view even with Claude closed (actions there
1489
+ # degrade to "open Claude", except the local mode toggle).
1490
+ url = st.panel_url() if st.loopback_reachable() else None
1491
+ if not url:
1492
+ try:
1493
+ import dashboard_server # mcp/menubar/dashboard_server.py
1494
+ url = dashboard_server.url() or dashboard_server.start()
1495
+ except Exception:
1496
+ url = None
1497
+ if url:
1498
+ subprocess.run(["open", url], capture_output=True)
1499
+ else:
1500
+ self._open_claude()
1501
+
1502
+ def _notify(self, title, message):
1503
+ # rumps.notification needs an .app bundle id; a bare launchd script has
1504
+ # none, so drive Notification Center through osascript instead.
1505
+ script = (
1506
+ f"display notification {json.dumps(message)} "
1507
+ f"with title {json.dumps(title)}"
1508
+ )
1509
+ try:
1510
+ subprocess.run(["osascript", "-e", script], capture_output=True, timeout=10)
1511
+ except Exception:
1512
+ pass
1513
+
1514
+ # _toggle_ap removed: autopilot is the Claude Desktop scheduled task now, managed
1515
+ # in the Scheduled tab. The menu bar mirrors the dashboard (no launchd toggle).
1516
+
1517
+ # ---- activity spinner -------------------------------------------------
1518
+ # The server writes activity.json while a tool runs (scanning/drafting/
1519
+ # posting/…). _poll_activity (1s) starts the fast spinner; _spin (0.12s)
1520
+ # animates the title with the label and stops itself when activity clears.
1521
+ # Both run on the main thread (rumps timers).
1522
+ def _poll_activity(self, _):
1523
+ # Refresh the durable plan-based posting label (cheap: one small file read)
1524
+ # so the title can show steady posting progress even when the server's
1525
+ # per-post activity.json is momentarily empty between posts.
1526
+ self._posting_label = self._compute_posting_label()
1527
+ # While the autopilot is stalled, the server's "drafting"/"scanning" label is
1528
+ # stale (the producer re-asserts it for the whole time it blocks on a job no
1529
+ # routine will claim). Don't let the spinner own the title with that lie —
1530
+ # drop it and paint the ⚠ stall state. A genuine posting drain (durable
1531
+ # posting label) is real work, so it still shows.
1532
+ if self._stalled and not self._posting_label:
1533
+ if self._spinner is not None:
1534
+ self._spinner.stop()
1535
+ self._spinner = None
1536
+ self.title = "S4L ⚠"
1537
+ return
1538
+ act = st.read_activity()
1539
+ has_label = bool((act and act.get("label")) or self._posting_label)
1540
+ if has_label and self._spinner is None:
1541
+ self._spin_i = 0
1542
+ self._spinner = rumps.Timer(self._spin, 0.12)
1543
+ self._spinner.start()
1544
+
1545
+ def _compute_posting_label(self):
1546
+ """Posting progress from the durable review-queue plan, with hysteresis.
1547
+
1548
+ A drain is detected by the plan's posted count INCREASING (or the server's
1549
+ activity.json reporting a post in flight) — never by the raw unposted
1550
+ backlog, which sits non-zero for drafts merely awaiting review. The label
1551
+ is held for a grace window after the last post so the indicator doesn't
1552
+ blink back to "S4L" during the multi-second gaps between posts. Survives a
1553
+ menu bar restart and reflects posts driven by the autopilot/agent, not just
1554
+ this process's own approval queue."""
1555
+ now = time.time()
1556
+ try:
1557
+ posted = st.review_queue_posted_count()
1558
+ except Exception:
1559
+ posted = None
1560
+ act = st.read_activity()
1561
+ act_posting = bool(act and "posting" in str(act.get("label") or ""))
1562
+ if posted is None:
1563
+ posted = self._drain_last_posted # ride through a transient read miss
1564
+ if posted is None:
1565
+ return None
1566
+ if self._drain_last_posted is None:
1567
+ self._drain_last_posted = posted
1568
+ advanced = posted > self._drain_last_posted
1569
+ if advanced or act_posting:
1570
+ if self._drain_baseline is None:
1571
+ # New drain: baseline is the count just BEFORE its first post.
1572
+ self._drain_baseline = self._drain_last_posted
1573
+ self._drain_last_change = now
1574
+ if advanced:
1575
+ self._drain_last_posted = posted
1576
+ if self._drain_baseline is None:
1577
+ return None
1578
+ # Drain is over once the server is idle AND no new post landed for a grace
1579
+ # window (covers the long gaps between the last few slow posts).
1580
+ if not act_posting and now - self._drain_last_change > 45.0:
1581
+ self._drain_baseline = None
1582
+ return None
1583
+ sent = max(0, posted - self._drain_baseline)
1584
+ return f"posting · {sent} sent"
1585
+
1586
+ def _spin(self, _):
1587
+ # Stall beats a stale activity label: bail (and self-stop) so the title
1588
+ # falls back to "S4L ⚠" rather than a "drafting" lie. _poll_activity also
1589
+ # stops us within 1s; this makes the switch immediate.
1590
+ if self._stalled and not self._posting_label:
1591
+ if self._spinner is not None:
1592
+ self._spinner.stop()
1593
+ self._spinner = None
1594
+ self.title = "S4L ⚠"
1595
+ return
1596
+ act = st.read_activity()
1597
+ label = act.get("label") if act else None
1598
+ act_state = act.get("state") if act else None
1599
+ # For POSTING, prefer the menu bar's durable cumulative label over the
1600
+ # server's per-call "1/1" so the count climbs smoothly and the indicator
1601
+ # holds through the gaps between posts. Non-posting activity (scanning /
1602
+ # drafting) keeps its own server label.
1603
+ if self._posting_label and (
1604
+ not label or act_state == "posting" or "posting" in str(label)
1605
+ ):
1606
+ label = self._posting_label
1607
+ if label:
1608
+ # The update arrow must stay visible even while a tool runs, so the
1609
+ # "update available" signal is never masked by activity. _tick skips the
1610
+ # title repaint while the spinner owns it, so the arrow is injected here.
1611
+ head = "S4L ⬆" if self._update_available else "S4L"
1612
+ # A "✓" label (e.g. "posted 3/10 ✓") is a momentary confirmation, not
1613
+ # ongoing work — show it without the spinner glyph so it reads as done.
1614
+ if "✓" in label:
1615
+ self.title = f"{head} {label}"
1616
+ else:
1617
+ self._spin_i = (self._spin_i + 1) % len(SPINNER)
1618
+ self.title = f"{head} {label} {SPINNER[self._spin_i]}"
1619
+ return
1620
+ try:
1621
+ if self._spinner is not None:
1622
+ self._spinner.stop()
1623
+ except Exception:
1624
+ pass
1625
+ self._spinner = None
1626
+ self.title = "S4L"
1627
+ self._sig = None # force the next tick to repaint title + menu
1628
+
1629
+ def _resume_approved_queue(self):
1630
+ """Restart recovery: re-enqueue approvals that were recorded durably but
1631
+ never confirmed posted (the in-memory _post_q died with the old process).
1632
+ Skip any the plan already shows as posted, so a card that landed on X just
1633
+ before the kill — but whose status update was lost — isn't posted twice."""
1634
+ pending = st.approved_queue_pending()
1635
+ if not pending:
1636
+ return
1637
+ posted_ns = set()
1638
+ try:
1639
+ req = st.read_review_request()
1640
+ plan_path = (req or {}).get("plan_path") or "/tmp/twitter_cycle_plan_review-queue.json"
1641
+ plan = st.read_plan(plan_path)
1642
+ for i, c in enumerate(((plan or {}).get("candidates") or [])):
1643
+ if c.get("posted") is True:
1644
+ posted_ns.add(i + 1)
1645
+ except Exception:
1646
+ pass
1647
+ resumed = 0
1648
+ for it in pending:
1649
+ batch, n = it.get("batch"), it.get("n")
1650
+ if n in posted_ns:
1651
+ st.approved_queue_set_status(batch, n, "posted") # reconcile lost update
1652
+ continue
1653
+ decision = {
1654
+ "n": n,
1655
+ "approved": True,
1656
+ "text": it.get("text") or "",
1657
+ "edited": bool(it.get("edited")),
1658
+ "drop_link": bool(it.get("drop_link")),
1659
+ }
1660
+ with self._review_lock:
1661
+ self._posts_outstanding += 1
1662
+ self._posting_batch_total += 1
1663
+ self._review_active = True
1664
+ self._write_posting_activity_locked()
1665
+ self._post_q.put((batch, decision))
1666
+ resumed += 1
1667
+ if resumed:
1668
+ self._ensure_post_worker()
1669
+ sys.stderr.write(
1670
+ f"[s4l-menubar] resumed {resumed} approved-but-unposted draft(s) after restart\n"
1671
+ )
1672
+ sys.stderr.flush()
1673
+ self._notify("S4L", f"Resuming {resumed} approved draft(s) after restart…")
1674
+
1675
+ # ---- tick: read state, set title, (re)build menu ----------------------
1676
+ def _tick(self, _):
1677
+ # Post-update verdict: cheap (a single stat when no update is pending).
1678
+ try:
1679
+ self._check_update_verdict()
1680
+ except Exception:
1681
+ pass
1682
+ # Restart recovery (one-shot, once the loopback is up so posting can reach
1683
+ # the server): resume any approved-but-unposted drafts the durable queue
1684
+ # recorded, instead of stranding them and re-presenting the cards.
1685
+ if not self._resumed and st.loopback_reachable():
1686
+ self._resumed = True
1687
+ try:
1688
+ self._resume_approved_queue()
1689
+ except Exception as e:
1690
+ sys.stderr.write(f"[s4l-menubar] resume approved queue failed: {e}\n")
1691
+ sys.stderr.flush()
1692
+ # Drain any review-events the outbox buffered while offline / before
1693
+ # the last restart. Async + idempotent (server dedups event_uuid).
1694
+ try:
1695
+ st.flush_review_events_async()
1696
+ except Exception:
1697
+ pass
1698
+ # The activity spinner owns the TITLE while a tool runs (we don't fight it at
1699
+ # 0.12s), but the menu + update indicator must still refresh mid-run —
1700
+ # otherwise the "Update now & restart Claude Desktop" item never appears on a box that's always
1701
+ # busy (continuous autopilot). So we no longer bail out wholesale when busy;
1702
+ # we only skip the title repaint and the review pop-up.
1703
+ busy = self._spinner is not None
1704
+ snap = st.snapshot()
1705
+ ob = snap.get("onboarding") or st.read_onboarding()
1706
+ runtime_ready = bool(snap.get("runtime_ready"))
1707
+ if "setup_complete" in snap:
1708
+ # Single source of truth: the server computes setup_complete (runtime +
1709
+ # a ready project + X connected) and we read it the SAME way whether it
1710
+ # came live from the loopback or from the persisted status-summary.json.
1711
+ # This is what stops the old 7/8-vs-"set up" flip-flop between the live
1712
+ # and offline paths — they no longer use different rules.
1713
+ setup_complete = bool(snap.get("setup_complete"))
1714
+ elif snap.get("_live"):
1715
+ # Legacy live server (pre-setup_complete) during a version skew.
1716
+ setup_complete = (
1717
+ runtime_ready
1718
+ and snap.get("projects_ready", 0) > 0
1719
+ and bool(snap.get("x_connected"))
1720
+ )
1721
+ else:
1722
+ # Truly fresh install, no summary yet: the ledger's "complete" is the proxy.
1723
+ setup_complete = bool(ob and ob.get("complete"))
1724
+ blocker = (ob or {}).get("current_blocker")
1725
+ blocker_code = (blocker or {}).get("code")
1726
+ # --- Autopilot health (only meaningful once setup is complete) --------
1727
+ # SINGLE signal: is the draft schedule registered AND firing for the live
1728
+ # account (schedule_state)? 'ok' = the host is running the tasks -> healthy,
1729
+ # NO warning (even if no draft has drained yet — that's just an empty queue
1730
+ # between cycles, not a setup problem). 'missing'/'disabled' = not running
1731
+ # for this account -> show re-arm. We deliberately do NOT drive the menu off
1732
+ # the drain-status latch anymore: it stayed stale after recovery and made a
1733
+ # firing, healthy autopilot look "not set up".
1734
+ # Always read the REAL schedule state (no setup-gated "ok" fallback that
1735
+ # lied). The re-arm WARNING still only fires once setup is complete, so we
1736
+ # never nag the user mid-onboarding — only the value is now always honest.
1737
+ schedule_state = self._schedule_state()
1738
+ self._schedule_state_cache = schedule_state
1739
+ attention = setup_complete and schedule_state in ("missing", "disabled")
1740
+ # Routines-lane rate limit (429): the draft tasks ARE registered and firing
1741
+ # for this account, but every run dies on a Claude rate limit, so nothing
1742
+ # drafts. Re-arm can't fix that — surface it as its own ⚠ attention state
1743
+ # with a "rate-limited" reason. Only meaningful when the schedule is firing
1744
+ # ('ok'); the missing/disabled case already owns the ⚠. Throttled (~30s):
1745
+ # scanning the worker-transcript bucket is glob-heavy and changes slowly.
1746
+ if setup_complete and schedule_state == "ok":
1747
+ now_rl = time.time()
1748
+ if now_rl - getattr(self, "_rl_checked_at", 0.0) >= 30:
1749
+ self._rl_checked_at = now_rl
1750
+ reason, msg = self._stall_reason()
1751
+ self._stall_reason_info = (reason, msg) if reason == "rate_limited" else ("", "")
1752
+ if self._stall_reason_info[0] == "rate_limited":
1753
+ attention = True
1754
+ else:
1755
+ self._stall_reason_info = ("", "")
1756
+ # Draft worker stuck/killed: the producer narrates "drafting replies (Nm)"
1757
+ # the whole time it blocks waiting for a worker to return a result, with NO
1758
+ # idea the worker died. A healthy drain clears in ~1-2 min; once that label
1759
+ # has been "drafting" past DRAFT_STUCK_SECONDS the worker keeps getting
1760
+ # killed mid-run (or never claims) and nothing is draining — flip to ⚠
1761
+ # instead of leaving the reassuring "drafting (8m)" spinner up. Skip when a
1762
+ # more specific cause (rate limit) already owns the reason.
1763
+ if setup_complete and self._stall_reason_info[0] != "rate_limited":
1764
+ _act = st.read_activity()
1765
+ if (
1766
+ _act
1767
+ and _act.get("state") == "drafting"
1768
+ and _label_elapsed_secs(_act.get("label")) >= DRAFT_STUCK_SECONDS
1769
+ ):
1770
+ attention = True
1771
+ self._stall_reason_info = ("draft_stuck", _act.get("label") or "")
1772
+ # Drop the stale "drafting" spinner while we need attention so the ⚠ shows.
1773
+ self._stalled = attention
1774
+
1775
+ # Spinner owns the title while busy; _spin already keeps the ⬆ visible there.
1776
+ if not busy:
1777
+ self._render_title(setup_complete, ob, blocker, attention)
1778
+
1779
+ # Blocker notification only on transition into a new blocker.
1780
+ if blocker and blocker_code != self._last_blocker_code:
1781
+ self._notify(
1782
+ "S4L setup needs you",
1783
+ blocker.get("message", "Setup is blocked"),
1784
+ )
1785
+ self._last_blocker_code = blocker_code
1786
+ # Notify once per episode (the draft schedule isn't running for this account).
1787
+ if attention and not self._stall_notified:
1788
+ # Fleet-wide telemetry: the draft autopilot needs attention on THIS
1789
+ # install (orphaned by an account switch, disabled, rate-limited, or a
1790
+ # stuck worker). Only channel that surfaces "customer's autopilot silently
1791
+ # stopped drafting" to us; the cycle log lives only on their machine.
1792
+ # Once per episode (gated by _stall_notified), so it never spams.
1793
+ _reason = (
1794
+ self._stall_reason_info[0]
1795
+ or ("disabled" if schedule_state == "disabled" else "missing")
1796
+ )
1797
+ _capture_msg(
1798
+ f"S4L draft autopilot needs attention: {_reason}",
1799
+ level="warning",
1800
+ phase="draft_schedule",
1801
+ reason=_reason,
1802
+ schedule_state=str(schedule_state),
1803
+ )
1804
+ if self._stall_reason_info[0] == "rate_limited":
1805
+ self._notify(
1806
+ "S4L Claude rate-limited",
1807
+ "Drafts can’t run — this Claude account hit its rate limit. "
1808
+ + (self._stall_reason_info[1] or "Wait for the limit to reset or switch account."),
1809
+ )
1810
+ elif schedule_state == "disabled":
1811
+ self._notify(
1812
+ "S4L draft tasks disabled",
1813
+ "The draft tasks are scheduled but disabled. Open the S4L menu → "
1814
+ "“Set up draft schedule” to re-enable.",
1815
+ )
1816
+ else:
1817
+ self._notify(
1818
+ "S4L draft autopilot not scheduled",
1819
+ "No draft tasks are running on this Claude account (switching "
1820
+ "accounts clears them). Open the S4L menu → “Set up draft schedule”.",
1821
+ )
1822
+ self._stall_notified = True
1823
+ elif not attention:
1824
+ self._stall_notified = False
1825
+
1826
+ # Single-source update signal: copy the snapshot's result (snapshot.py
1827
+ # _latest_published: GitHub releases/latest first, npm fallback; semver >,
1828
+ # surfaced as update_available/latest_version). No separate poll here.
1829
+ self._update_available = bool(snap.get("update_available"))
1830
+ self._latest_version = snap.get("latest_version")
1831
+ self._channel = snap.get("channel") or "stable"
1832
+ self._latest_tag = snap.get("latest_tag")
1833
+
1834
+ # Only rebuild the menu when something user-visible changed, so an open
1835
+ # menu isn't torn down under the user's cursor every poll.
1836
+ done = (
1837
+ sum(1 for m in ob["milestones"] if m.get("status") == "complete")
1838
+ if ob
1839
+ else 0
1840
+ )
1841
+ # _update_available / _latest_version are in the signature so a freshly
1842
+ # detected update rebuilds the menu (adding "Update now & restart Claude Desktop") even mid-run.
1843
+ sig = (
1844
+ runtime_ready,
1845
+ setup_complete,
1846
+ blocker_code,
1847
+ done,
1848
+ bool(snap.get("autopilot_on")),
1849
+ snap.get("version"),
1850
+ snap.get("update_available"),
1851
+ self._update_available,
1852
+ self._latest_version,
1853
+ snap.get("x_handle"),
1854
+ snap.get("projects_ready"),
1855
+ snap.get("projects_total"),
1856
+ tuple(sorted((st.read_flags() or {}).items())),
1857
+ attention,
1858
+ schedule_state,
1859
+ self._stall_reason_info,
1860
+ )
1861
+ if sig != self._sig:
1862
+ self._sig = sig
1863
+ self._build_menu(runtime_ready, setup_complete, ob, blocker, snap, attention, schedule_state)
1864
+
1865
+ # Draft-review pop-ups: if a draft cycle left a review request, present the
1866
+ # cards. Don't start a review mid-run (the spinner means a tool is active).
1867
+ if not busy:
1868
+ self._maybe_start_review()
1869
+ # Self-heal an open-but-ignored card (runs even while busy: it only
1870
+ # touches an existing window, never starts a review).
1871
+ self._maybe_heal_review()
1872
+
1873
+ # ---- draft review pop-ups ---------------------------------------------
1874
+ def _posting_activity_label_locked(self):
1875
+ """Progress for the current menu-bar approval burst.
1876
+
1877
+ The server receives one post_drafts call per approved card, so its native
1878
+ view is always 1/1. The menu bar owns the burst queue and can show the
1879
+ useful progress: current approved post / total approved so far.
1880
+ """
1881
+ if self._posts_outstanding <= 0:
1882
+ return None
1883
+ total = max(
1884
+ self._posting_batch_total,
1885
+ self._posting_batch_done + self._posts_outstanding,
1886
+ )
1887
+ current = min(total, self._posting_batch_done + 1)
1888
+ return f"posting {current}/{total}"
1889
+
1890
+ def _write_posting_activity_locked(self):
1891
+ label = self._posting_activity_label_locked()
1892
+ if label:
1893
+ st.write_activity("posting", label)
1894
+ return label
1895
+
1896
+ def _reset_posting_progress_locked(self):
1897
+ self._posting_batch_total = 0
1898
+ self._posting_batch_done = 0
1899
+
1900
+ def _maybe_start_review(self):
1901
+ req = st.read_review_request()
1902
+ if not req:
1903
+ return
1904
+ batch = req.get("batch_id")
1905
+ if not batch:
1906
+ return
1907
+ plan = st.read_plan(req.get("plan_path") or "")
1908
+ drafts = st.review_drafts(plan)
1909
+ # Nothing left to review (empty, missing plan, or all already posted via
1910
+ # the chat surface) — clear the signal and reset the signature so a future
1911
+ # batch is presented fresh.
1912
+ if not drafts:
1913
+ self._last_review_sig = None
1914
+ st.clear_review_request()
1915
+ return
1916
+ # De-dup on the CONTENT of the pending set (each draft's plan index + reply
1917
+ # text), not the constant batch_id. This means: re-present whenever NEW
1918
+ # drafts arrive (the signature changes), but don't re-pop the identical
1919
+ # cards we already showed for this same pending set. No restart is ever
1920
+ # needed for new pending drafts to surface.
1921
+ sig = tuple((d.get("n"), d.get("reply_text") or "") for d in drafts)
1922
+ if sig == self._last_review_sig:
1923
+ return
1924
+ # A review is already in flight. Two cases:
1925
+ # - A card is ON SCREEN (_panel_open): push the newly-queued drafts into
1926
+ # the open card so the "X of N" counter and the reviewable stack grow
1927
+ # live. This is the fix for the "card froze at 1 of 4 while 137 piled
1928
+ # up" bug — drafts that arrived after the card opened used to be
1929
+ # stranded because this method returned early on _review_active.
1930
+ # - Posting is DRAINING with no panel up (_review_active but not
1931
+ # _panel_open): leave the signature untouched so the full pending set
1932
+ # is presented fresh once the drain completes (don't pop a card mid-post).
1933
+ if self._review_active:
1934
+ if self._panel_open:
1935
+ try:
1936
+ import s4l_card
1937
+
1938
+ s4l_card.extend_active(drafts)
1939
+ except Exception as e:
1940
+ sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
1941
+ sys.stderr.flush()
1942
+ self._last_review_sig = sig
1943
+ return
1944
+ with self._review_lock:
1945
+ self._reset_posting_progress_locked()
1946
+ self._review_active = True
1947
+ self._panel_open = True
1948
+ try:
1949
+ import s4l_card
1950
+
1951
+ # The card's 💬 (overall feedback) button opens the composer via the
1952
+ # module-level default handler; register ours before any card shows.
1953
+ s4l_card.set_feedback_handler(self._on_feedback_text)
1954
+ s4l_card.present_review(
1955
+ drafts,
1956
+ on_decision=lambda d: self._on_card_decision(batch, d),
1957
+ on_complete=lambda decisions: self._on_review_closed(batch, decisions),
1958
+ )
1959
+ # Record as shown only AFTER the cards are actually up, so a transient
1960
+ # card-UI failure never permanently suppresses this pending set.
1961
+ self._last_review_sig = sig
1962
+ # A silent pop-up is missable; pair every fresh card stack with a
1963
+ # notification. Extends of an already-open stack stay quiet (the
1964
+ # unattended watchdog owns the ignored-card case).
1965
+ n = len(drafts)
1966
+ self._notify(
1967
+ "S4L drafts ready",
1968
+ f"{n} draft{'s' if n != 1 else ''} ready for review",
1969
+ )
1970
+ except Exception as e:
1971
+ # Card UI unavailable — don't strand the batch; chat review still works.
1972
+ self._review_active = False
1973
+ self._panel_open = False
1974
+ sys.stderr.write(f"[s4l-menubar] review cards failed: {e}\n")
1975
+ sys.stderr.flush()
1976
+ _capture(e, phase="review_cards")
1977
+
1978
+ def _maybe_heal_review(self):
1979
+ """Self-heal an unattended review card. A card can be fully drawn yet
1980
+ outside the user's attention (wrong display, buried corner) and AppKit
1981
+ cannot see attention, so measure the outcome instead: drafts pending
1982
+ with no decision or interaction for REVIEW_UNATTENDED_SECONDS. Heal
1983
+ automatically (move to the pointer's screen, raise, no user action
1984
+ required), re-healing on a throttle while the drought lasts. Notify
1985
+ once per episode; after REVIEW_UNATTENDED_SENTRY_SECONDS emit one
1986
+ Sentry event so ignored review surfaces are visible fleet-wide."""
1987
+ try:
1988
+ import s4l_card
1989
+
1990
+ status = s4l_card.active_status()
1991
+ except Exception:
1992
+ return
1993
+ if not status or not status.get("pending"):
1994
+ self._review_unattended_notified = False
1995
+ self._review_unattended_captured = False
1996
+ return
1997
+ anchor = max(
1998
+ status.get("presented_at") or 0,
1999
+ status.get("last_decision_at") or 0,
2000
+ status.get("last_interaction_at") or 0,
2001
+ )
2002
+ if not anchor:
2003
+ return
2004
+ now = time.time()
2005
+ idle = now - anchor
2006
+ if idle < REVIEW_UNATTENDED_SECONDS:
2007
+ self._review_unattended_notified = False
2008
+ self._review_unattended_captured = False
2009
+ return
2010
+ if now - self._review_heal_at >= REVIEW_HEAL_EVERY_SECONDS:
2011
+ self._review_heal_at = now
2012
+ healed = False
2013
+ try:
2014
+ healed = s4l_card.heal_active()
2015
+ except Exception as e:
2016
+ sys.stderr.write(f"[s4l-menubar] review heal failed: {e}\n")
2017
+ sys.stderr.flush()
2018
+ if healed and not self._review_unattended_notified:
2019
+ self._review_unattended_notified = True
2020
+ self._notify(
2021
+ "S4L drafts waiting",
2022
+ f"{status.get('pending')} drafts have been waiting "
2023
+ f"{int(idle // 60)} min. Moved the review card to your "
2024
+ "screen.",
2025
+ )
2026
+ if (
2027
+ idle >= REVIEW_UNATTENDED_SENTRY_SECONDS
2028
+ and not self._review_unattended_captured
2029
+ ):
2030
+ self._review_unattended_captured = True
2031
+ _capture_msg(
2032
+ "S4L review card unattended",
2033
+ level="warning",
2034
+ phase="review_unattended",
2035
+ pending=str(status.get("pending")),
2036
+ idle_min=str(int(idle // 60)),
2037
+ visible=str(status.get("occlusion_visible")),
2038
+ screen=str(status.get("screen")),
2039
+ )
2040
+
2041
+ def _ship_review_event(self, batch, decision):
2042
+ """Queue the decision (with reason, link clicks, dwell) for the
2043
+ review-events feedback rail. Outbox append + async flush; never raises
2044
+ and never blocks the card UI."""
2045
+ try:
2046
+ cid = decision.get("candidate_id")
2047
+ try:
2048
+ cid = int(cid)
2049
+ except (TypeError, ValueError):
2050
+ cid = None
2051
+ st.review_event_add(
2052
+ {
2053
+ "platform": "twitter",
2054
+ "project": decision.get("project"),
2055
+ "candidate_id": cid,
2056
+ "batch_id": batch,
2057
+ "card_n": decision.get("n"),
2058
+ "decision": "approved" if decision.get("approved") else "rejected",
2059
+ "edited": bool(decision.get("edited")),
2060
+ "drop_link": bool(decision.get("drop_link")),
2061
+ "loved": bool(decision.get("loved")),
2062
+ "reject_category": decision.get("reject_category"),
2063
+ "reject_note": decision.get("reject_note"),
2064
+ "interactions": decision.get("interactions") or [],
2065
+ "dwell_ms": decision.get("dwell_ms"),
2066
+ "thread_url": decision.get("thread_url"),
2067
+ "thread_author": decision.get("thread_author"),
2068
+ "draft_text": decision.get("text"),
2069
+ }
2070
+ )
2071
+ except Exception:
2072
+ pass
2073
+
2074
+ def _on_feedback_text(self, text):
2075
+ """Ship overall feedback (the card's 💬 button or the menu bar's
2076
+ "Send feedback…" item) as a decision='feedback' review event on the
2077
+ same outbox rail as card decisions. project is intentionally omitted
2078
+ (NULL server-side): the feedback digest folds project-less feedback
2079
+ into EVERY configured project's prompt."""
2080
+ body = (text or "").strip()[:2000]
2081
+ if not body:
2082
+ return
2083
+ try:
2084
+ st.review_event_add(
2085
+ {
2086
+ "platform": "twitter",
2087
+ "decision": "feedback",
2088
+ "batch_id": "overall-feedback",
2089
+ "reject_note": body,
2090
+ }
2091
+ )
2092
+ self._notify("S4L", "Feedback sent. It will steer future drafts.")
2093
+ except Exception:
2094
+ pass
2095
+
2096
+ def _menu_feedback(self, _):
2097
+ # Dropdown entry point for the overall-feedback composer. Rumps menu
2098
+ # callbacks run on the main run loop, which present_feedback requires.
2099
+ try:
2100
+ import s4l_card
2101
+
2102
+ s4l_card.present_feedback(self._on_feedback_text)
2103
+ except Exception as e:
2104
+ sys.stderr.write(f"[s4l-menubar] feedback composer failed: {e}\n")
2105
+ _capture(e, phase="feedback_composer")
2106
+
2107
+ def _on_card_decision(self, batch, decision):
2108
+ # Runs on the main thread the INSTANT a card is approved/rejected. An
2109
+ # approved card is enqueued for immediate posting; a REJECTED card is
2110
+ # persisted (marked done so it's never re-shown for review) on a quick
2111
+ # background thread. We never block inline here — posting can take minutes
2112
+ # and would freeze the card UI while the user reviews the rest of the stack.
2113
+ self._ship_review_event(batch, decision)
2114
+ if not decision.get("approved"):
2115
+ n = decision.get("n")
2116
+ # Durable local record FIRST, mirroring approved_queue_add for approvals.
2117
+ # review_drafts() consults this, so the rejected card is suppressed from
2118
+ # re-review IMMEDIATELY and even if the loopback is down when the
2119
+ # background plan-flag write below runs. Without this, a reject was a
2120
+ # fire-and-forget loopback call with a swallowed exception, so rejects
2121
+ # silently vanished and the card "came back" — unlike durable approvals.
2122
+ try:
2123
+ st.review_reject_add(batch, n)
2124
+ except Exception:
2125
+ pass
2126
+
2127
+ def _persist_reject():
2128
+ try:
2129
+ st.post_drafts(batch, reject=[n], timeout=30)
2130
+ except Exception:
2131
+ pass
2132
+
2133
+ threading.Thread(target=_persist_reject, daemon=True).start()
2134
+ return
2135
+ n = decision.get("n")
2136
+ # Persist the approval DURABLY before posting, so a menu bar / Claude
2137
+ # restart resumes the drain instead of stranding it and re-presenting the
2138
+ # card. The in-memory _post_q below is just the fast path; this file is the
2139
+ # source of truth review_drafts() consults to avoid re-showing it.
2140
+ st.approved_queue_add(
2141
+ batch,
2142
+ n,
2143
+ text=decision.get("text") or "",
2144
+ edited=bool(decision.get("edited")),
2145
+ drop_link=bool(decision.get("drop_link")),
2146
+ )
2147
+ with self._review_lock:
2148
+ self._posts_outstanding += 1
2149
+ self._posting_batch_total += 1
2150
+ self._review_active = True
2151
+ self._write_posting_activity_locked()
2152
+ self._post_q.put((batch, decision))
2153
+ self._ensure_post_worker()
2154
+
2155
+ def _on_review_closed(self, batch, decisions):
2156
+ # Fires when the card sequence ends (last card decided or window closed).
2157
+ # The panel is gone, but approved cards may still be draining — keep the
2158
+ # review "active" until the queue empties so the not-yet-posted remainder
2159
+ # isn't re-presented as a fresh batch.
2160
+ with self._review_lock:
2161
+ self._panel_open = False
2162
+ if self._posts_outstanding <= 0:
2163
+ self._review_active = False
2164
+ self._reset_posting_progress_locked()
2165
+ # Only clear the review marker when the queue is actually drained. The old
2166
+ # code cleared it unconditionally, so if the user closed the card with
2167
+ # drafts still undecided (or more had piled up than they reviewed), the
2168
+ # backlog was stranded — presentation is gated on this marker. Keep it when
2169
+ # anything remains so the leftover re-presents fresh on the next tick.
2170
+ remaining = 0
2171
+ try:
2172
+ req = st.read_review_request()
2173
+ if req:
2174
+ remaining = len(st.review_drafts(st.read_plan(req.get("plan_path") or "")))
2175
+ except Exception:
2176
+ remaining = 0
2177
+ if remaining <= 0:
2178
+ st.clear_review_request()
2179
+ # Drop the dedup signature so whatever is left is presented fresh (not
2180
+ # suppressed as "already shown") once posting finishes draining.
2181
+ self._last_review_sig = None
2182
+ # Retry any review-events a per-decision flush left behind (e.g. the
2183
+ # API was briefly unreachable mid-review).
2184
+ try:
2185
+ st.flush_review_events_async()
2186
+ except Exception:
2187
+ pass
2188
+ if not any(d.get("approved") for d in decisions):
2189
+ self._notify("S4L", "No drafts approved — nothing posted.")
2190
+
2191
+ def _ensure_post_worker(self):
2192
+ # One persistent daemon worker drains the approved-card queue. It never
2193
+ # exits (avoids an enqueue-vs-exit race) — an idle parked thread is cheap.
2194
+ if self._post_worker is not None and self._post_worker.is_alive():
2195
+ return
2196
+ self._post_worker = threading.Thread(target=self._post_worker_loop, daemon=True)
2197
+ self._post_worker.start()
2198
+
2199
+ def _post_worker_loop(self):
2200
+ # Serialized poster: one approved card at a time so two posts never drive
2201
+ # the shared harness Chrome simultaneously. The menu bar passes a burst
2202
+ # progress label into post_drafts, so the spinner shows e.g. "posting 17/95"
2203
+ # even though each server call is still one approved draft.
2204
+ while True:
2205
+ batch, decision = self._post_q.get() # blocks until a card is approved
2206
+ n = decision.get("n")
2207
+ try:
2208
+ # No "Posting draft N…" banner: the menu-bar spinner already shows
2209
+ # live posting progress, so a Notification Center toast per approved
2210
+ # card is pure noise. Only failures (below) raise a notification.
2211
+ st.approved_queue_set_status(batch, n, "posting")
2212
+ with self._review_lock:
2213
+ activity_label = self._posting_activity_label_locked()
2214
+ cl = [n] if decision.get("drop_link") else None
2215
+ if decision.get("edited"):
2216
+ res = st.post_drafts(
2217
+ batch,
2218
+ edits=[{"n": n, "text": decision.get("text") or ""}],
2219
+ clear_link=cl,
2220
+ activity_label=activity_label,
2221
+ )
2222
+ else:
2223
+ res = st.post_drafts(batch, post=[n], clear_link=cl, activity_label=activity_label)
2224
+ if res is None:
2225
+ # Loopback unreachable (Claude closed). Mark failed so the card
2226
+ # falls back to manual review rather than silently vanishing.
2227
+ st.approved_queue_set_status(batch, n, "failed", error="loopback_unreachable")
2228
+ self._notify(
2229
+ "S4L", "Couldn't post — open Claude Desktop and try the draft again."
2230
+ )
2231
+ else:
2232
+ posted = res.get("posted") if isinstance(res, dict) else None
2233
+ if posted == 0:
2234
+ st.approved_queue_set_status(batch, n, "failed", error="posted_0")
2235
+ self._notify("S4L", f"Draft {n} didn't post — see the dashboard for why.")
2236
+ else:
2237
+ # Success is silent: the spinner + dashboard already reflect
2238
+ # it. No per-card "Posted draft N." banner.
2239
+ st.approved_queue_set_status(batch, n, "posted")
2240
+ except Exception as e:
2241
+ st.approved_queue_set_status(batch, n, "failed", error=str(e)[:200])
2242
+ sys.stderr.write(f"[s4l-menubar] post draft {n} failed: {e}\n")
2243
+ sys.stderr.flush()
2244
+ _capture(e, phase="post_card")
2245
+ finally:
2246
+ with self._review_lock:
2247
+ self._posting_batch_done += 1
2248
+ self._posts_outstanding -= 1
2249
+ if self._posts_outstanding > 0:
2250
+ self._write_posting_activity_locked()
2251
+ elif not self._panel_open:
2252
+ self._review_active = False
2253
+ self._reset_posting_progress_locked()
2254
+ self._post_q.task_done()
2255
+
2256
+ def _render_title(self, setup_complete, ob, blocker, attention=False):
2257
+ if blocker or attention:
2258
+ self.title = "S4L ⚠" # warning (setup blocked OR autopilot needs attention)
2259
+ elif not setup_complete and ob and not ob.get("complete"):
2260
+ done = sum(1 for m in ob["milestones"] if m.get("status") == "complete")
2261
+ self.title = f"S4L {done}/{len(ob['milestones'])}"
2262
+ elif self._update_available:
2263
+ self.title = "S4L ⬆" # update available — open the menu to update
2264
+ else:
2265
+ self.title = "S4L"
2266
+
2267
+ # ---- menu construction ------------------------------------------------
2268
+ def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok"):
2269
+ self.menu.clear()
2270
+ items = []
2271
+
2272
+ ver = snap.get("version") or st.version()
2273
+ header = rumps.MenuItem(f"S4L · v{ver}" if ver else "S4L")
2274
+ header.set_callback(None) # non-clickable label
2275
+ items.append(header)
2276
+ items.append(rumps.separator)
2277
+
2278
+ # Attention = the draft schedule isn't running for THIS account (missing or
2279
+ # disabled). "Set up draft schedule" fixes it via host create_scheduled_task.
2280
+ # When the schedule IS firing (ok), attention is False and nothing shows here
2281
+ # — a firing autopilot reads as healthy even if no draft has drained yet.
2282
+ if attention:
2283
+ if self._stall_reason_info[0] == "rate_limited":
2284
+ # Routines fire but every run dies on a Claude rate limit (429).
2285
+ # Re-arm can't fix this, so don't offer it — just say what's wrong.
2286
+ items.append(self._label("⚠ Claude rate-limited — drafts can’t run"))
2287
+ items.append(self._label(
2288
+ " " + (self._stall_reason_info[1] or "wait for reset or switch account")
2289
+ ))
2290
+ elif self._stall_reason_info[0] == "draft_stuck":
2291
+ # Routines fire and the producer keeps narrating "drafting" but the
2292
+ # worker keeps getting killed mid-run / never returns a result. Don't
2293
+ # offer Re-arm (routines are fine); state the real problem.
2294
+ items.append(self._label("⚠ Draft not completing — worker keeps getting killed"))
2295
+ items.append(self._label(
2296
+ " " + (self._stall_reason_info[1] or "drafting") + " — no result yet"
2297
+ ))
2298
+ elif schedule_state == "disabled":
2299
+ items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
2300
+ items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
2301
+ else:
2302
+ items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
2303
+ items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
2304
+ items.append(rumps.separator)
2305
+
2306
+ if not runtime_ready:
2307
+ items += self._state_a()
2308
+ elif not setup_complete:
2309
+ items += self._state_b(ob, blocker)
2310
+ else:
2311
+ items += self._state_c(snap)
2312
+
2313
+ # Engagement lanes — ALWAYS visible (every state), not just post-setup, so
2314
+ # the user can see + flip either lane any time. Two INDEPENDENT checkmarks
2315
+ # (both can be on -> the cycle splits 50/50). Single source: snap['flags']
2316
+ # (mode.json), same value the dashboard shows.
2317
+ flags = snap.get("flags") or st.read_flags()
2318
+ personal_on = bool(flags.get("personal_brand"))
2319
+ promo_on = bool(flags.get("promotion"))
2320
+ items.append(rumps.separator)
2321
+ items.append(self._label("Engagement lanes"))
2322
+ pb_item = rumps.MenuItem("Personal brand", callback=self._toggle_personal)
2323
+ pb_item.state = 1 if personal_on else 0
2324
+ items.append(pb_item)
2325
+ items.append(self._label(" organic, link-free engagement"))
2326
+ pr_item = rumps.MenuItem("Product promotion", callback=self._toggle_promotion)
2327
+ pr_item.state = 1 if promo_on else 0
2328
+ items.append(pr_item)
2329
+ items.append(self._label(" promoting your products (link replies)"))
2330
+ if personal_on and promo_on:
2331
+ items.append(self._label(" both on · cycles split 50/50"))
2332
+
2333
+ items.append(rumps.separator)
2334
+ items.append(rumps.MenuItem("Open dashboard", callback=self._open_dashboard))
2335
+ items.append(rumps.MenuItem("Send feedback…", callback=self._menu_feedback))
2336
+ if self._update_available and self._latest_version:
2337
+ items.append(rumps.separator)
2338
+ items.append(self._label(f"⬆ Update available · v{self._latest_version}"))
2339
+ items.append(
2340
+ rumps.MenuItem(
2341
+ "Update now & restart Claude Desktop",
2342
+ callback=self._do_mcpb_update,
2343
+ )
2344
+ )
2345
+ if self._reloc_needed and not self._relocating:
2346
+ items.append(rumps.separator)
2347
+ items.append(rumps.MenuItem("Tidy autopilot history…", callback=self._prompt_relocate_tasks))
2348
+ items.append(rumps.separator)
2349
+ items.append(rumps.MenuItem("Uninstall S4L…", callback=self._reset_machine))
2350
+ items.append(rumps.MenuItem("Quit", callback=self._quit_app))
2351
+
2352
+ # Collapse consecutive/edge separators so an empty section (e.g. State C
2353
+ # now renders no status labels) can't leave a doubled or dangling divider.
2354
+ cleaned = []
2355
+ for it in items:
2356
+ is_sep = it is rumps.separator
2357
+ if is_sep and (not cleaned or cleaned[-1] is rumps.separator):
2358
+ continue
2359
+ cleaned.append(it)
2360
+ while cleaned and cleaned[-1] is rumps.separator:
2361
+ cleaned.pop()
2362
+ for it in cleaned:
2363
+ self.menu.add(it)
2364
+
2365
+ def _label(self, text):
2366
+ item = rumps.MenuItem(text)
2367
+ item.set_callback(None)
2368
+ return item
2369
+
2370
+ # State A — runtime not installed yet.
2371
+ def _state_a(self):
2372
+ return [
2373
+ self._label("Runtime not installed"),
2374
+ rumps.MenuItem("Set up in Claude", callback=self._setup),
2375
+ ]
2376
+
2377
+ # State B — runtime ready, setup running/incomplete (the ramp state).
2378
+ def _state_b(self, ob, blocker):
2379
+ out = []
2380
+ if ob:
2381
+ milestones = ob["milestones"]
2382
+ done = sum(1 for m in milestones if m.get("status") == "complete")
2383
+ total = len(milestones)
2384
+ cur = next(
2385
+ (m for m in milestones if m.get("status") == "in_progress"), None
2386
+ ) or next(
2387
+ (m for m in milestones if m.get("status") != "complete"), None
2388
+ )
2389
+ cur_label = (
2390
+ st.MILESTONE_LABELS.get(cur["id"], cur["id"]).lower() if cur else ""
2391
+ )
2392
+ line = f"Setting up… {done}/{total}"
2393
+ if cur_label:
2394
+ line += f" · {cur_label}"
2395
+ out.append(self._label(line))
2396
+
2397
+ sub = rumps.MenuItem("Setup steps")
2398
+ for m in milestones:
2399
+ sub.add(
2400
+ self._label(
2401
+ f"{_glyph(m.get('status'))} "
2402
+ f"{st.MILESTONE_LABELS.get(m['id'], m['id'])}"
2403
+ )
2404
+ )
2405
+ out.append(sub)
2406
+ else:
2407
+ out.append(self._label("Setting up…"))
2408
+
2409
+ if blocker:
2410
+ out.append(rumps.separator)
2411
+ out.append(
2412
+ rumps.MenuItem(
2413
+ f"⚠ Needs you: {blocker.get('message', '')}",
2414
+ callback=self._setup,
2415
+ )
2416
+ )
2417
+ out.append(rumps.MenuItem("Set up in Claude", callback=self._setup))
2418
+ return out
2419
+
2420
+ # State C — setup complete. The post-setup status readouts (X handle,
2421
+ # projects-ready count, 7-day stats) were removed per user request: that
2422
+ # gray informational text belongs on the dashboard, not the dropdown. The
2423
+ # menu goes straight from the header to the engagement lanes + Open dashboard.
2424
+ # The engagement-mode toggles live in _build_menu (shown in EVERY state), and
2425
+ # there is deliberately no "Run draft cycle" / "Post approved drafts" item
2426
+ # (the autopilot drafts on its own; approving a review card already posts it).
2427
+ def _state_c(self, snap):
2428
+ return []
2429
+
2430
+
2431
+ if __name__ == "__main__":
2432
+ try:
2433
+ S4LMenuBar().run()
2434
+ except Exception as _run_err:
2435
+ # The run loop dying is the other "menu bar didn't start / vanished" case.
2436
+ # Report + flush before the KeepAlive relaunch so it isn't lost on teardown.
2437
+ _capture(_run_err, phase="run")
2438
+ _flush()
2439
+ raise