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