@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.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- 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
|