@m13v/s4l 1.6.197-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1314 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +497 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,4212 @@
1
+ #!/usr/bin/env node
2
+ // social-autoposter MCP server (X/Twitter rail).
3
+ //
4
+ // Core tools:
5
+ // queue_setup - return the two draft-autopilot scheduled-task specs to register
6
+ // via the host create_scheduled_task. The autopilot then drafts
7
+ // on its own (launchd kicker + queue worker); there is no manual
8
+ // "draft now" tool.
9
+ // post_drafts - post the drafts the user chose by number from a batch.
10
+ // get_stats - read-only post + engagement stats.
11
+ //
12
+ // THIN wrapper. The pipeline brain (scan, score, drafting prompts, posting)
13
+ // stays in the Python/shell scripts; we only orchestrate and present.
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { execFileSync } from "node:child_process";
17
+ import { z } from "zod";
18
+ import { screencast, bringBrowserToFront } from "./screencast.js";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import fs from "node:fs";
22
+ import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
23
+ import { applySetup, resolveProject, personaReady, listManagedProjectStatus, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, normalizeStringList, } from "./setup.js";
24
+ import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
25
+ import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, } from "./runtime.js";
26
+ import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
27
+ import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
28
+ import { initSentry, sendHeartbeat, captureError, flushSentry, startLogStreaming, flushLogs } from "./telemetry.js";
29
+ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
30
+ import { fileURLToPath } from "node:url";
31
+ import http from "node:http";
32
+ // MCP Apps control panel. The self-contained HTML is built by vite
33
+ // (vite-plugin-singlefile) into dist/panel.html alongside this compiled file.
34
+ const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
35
+ const PANEL_URI = "ui://social-autoposter/panel.html";
36
+ const PRODUCT_LINK_URI = "ui://social-autoposter/product-link.html";
37
+ // Stable id for the accumulating draft review queue. Each draft cycle appends its
38
+ // drafts here (dedup by tweet URL) so the menu-bar cards PILE UP across a
39
+ // continuous autopilot instead of each run overwriting the last; post_drafts posts
40
+ // the approved subset and marks them posted (filtered out of the cards thereafter).
41
+ const REVIEW_QUEUE_ID = "review-queue";
42
+ // ---- Queue-backed drafting (2026-06-23) -----------------------------------
43
+ // Customer .mcpb boxes have no `claude` CLI, so the deterministic pipeline can't
44
+ // run its `claude -p` steps directly. Instead a launchd job kicks the REAL
45
+ // pipeline (run-twitter-cycle.sh in DRAFT_ONLY mode with S4L_CLAUDE_PROVIDER=
46
+ // queue); each `claude -p` call enqueues onto scripts/claude_job.py's file queue
47
+ // and blocks. Two Claude Desktop scheduled tasks — one per job type — drain that
48
+ // queue, run the pipeline's own prompt as a Claude turn, and write the result
49
+ // back, unblocking the cycle. This reuses the entire pipeline (styles, voice,
50
+ // top-performers, em-dash rules). See scripts/claude_job.py + run_claude.sh's
51
+ // provider seam.
52
+ // Universal type-blind queue worker (2026-07-02): ONE scheduled task drains
53
+ // EVERY job type (`claude_job.py next --type any`). Per-type execution notes
54
+ // ride in the job's prompt sidecar (claude_job.py TYPE_TO_WORKER_NOTES), so
55
+ // the worker prompt never mentions types. Task ids are USER-VISIBLE (Routines
56
+ // UI), so they carry the S4L brand — never the internal "saps" prefix.
57
+ const WORKER_TASK_ID = "s4l-worker";
58
+ // Legacy workers from earlier installs. Not created anymore; their SKILL.md is
59
+ // refreshed to the same universal body on boot (so old boxes keep draining —
60
+ // interchangeable workers racing the same claim is safe, the claim is an
61
+ // atomic rename) until the menubar's one-restart self-heal consolidates them
62
+ // into s4l-worker. "saps-worker" existed only on staging (rc.2/rc.3) before
63
+ // the brand rename.
64
+ const LEGACY_UNIVERSAL_TASK_ID = "saps-worker";
65
+ const PHASE1_TASK_ID = "saps-phase1-query"; // legacy (was: "twitter-query" only)
66
+ const PHASE2B_TASK_ID = "saps-phase2b-draft"; // legacy (was: "twitter-prep" only)
67
+ const TWITTER_AUTOPILOT_LABEL = "com.m13v.social-twitter-cycle";
68
+ const TWITTER_AUTOPILOT_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${TWITTER_AUTOPILOT_LABEL}.plist`);
69
+ // Self-healing reaper for leaked Claude agent-mode worker sessions. The queue
70
+ // autopilot fires two scheduled tasks every ~1 min; each fire spawns a ~200 MB
71
+ // `claude` agent-mode session that finishes its one queue turn but never exits
72
+ // (Desktop keeps the stream-json session warm), so they pile up — 226 procs /
73
+ // 22.5 GB on the test box in ~1h, load 75, near-OOM. We can't change Desktop's
74
+ // teardown, so a 60s launchd job kills the leaked sessions (see the script for
75
+ // the uuid-grouping safety that spares real interactive sessions).
76
+ const REAPER_LABEL = "com.m13v.social-claude-reaper";
77
+ const REAPER_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${REAPER_LABEL}.plist`);
78
+ // Feedback digest: distills the user's card approve/reject decisions
79
+ // (review_events, shipped by the menubar with reason chips + link clicks)
80
+ // into the project's learned_preferences block in config.json, which the
81
+ // prep prompt then reads via ALL_PROJECTS_JSON. Hourly; exits immediately
82
+ // when there are no unprocessed events for this install.
83
+ const FEEDBACK_DIGEST_LABEL = "com.m13v.social-feedback-digest";
84
+ const FEEDBACK_DIGEST_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${FEEDBACK_DIGEST_LABEL}.plist`);
85
+ // Periodic host-resource sampler. Appends one redacted memory/process snapshot
86
+ // per minute to skill/logs/memory-snapshots.jsonl (rotated) so we have local
87
+ // history when SSHing into a box, and so the heartbeat's --summary path has a
88
+ // warm picture. The plist is fully templated (repoDir/$HOME/resolvePython), so
89
+ // unlike the legacy dev-box plist in launchd/ it runs anywhere. Cheap +
90
+ // short-lived (one ps/vm_stat pass, then exits).
91
+ const MEMORY_SNAPSHOT_LABEL = "com.m13v.social-memory-snapshot";
92
+ const MEMORY_SNAPSHOT_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${MEMORY_SNAPSHOT_LABEL}.plist`);
93
+ const MEMORY_SNAPSHOT_INTERVAL_SECS = 60;
94
+ // Autopilot stall watchdog (fleet backstop). The draft autopilot's two scheduled-
95
+ // task routines stop draining the queue when the user switches Claude Desktop
96
+ // accounts (the routines are registered per-account; their global SKILL.md files
97
+ // survive, so the presence-based "autopilot_on" reads a false green). The menu bar
98
+ // surfaces this to the user (S4L ⚠ + Re-arm); this launchd job is the part the
99
+ // user can't see — it emits a Sentry event on a sustained stall so we catch it
100
+ // fleet-wide. Runs off the venv python (needs sentry-sdk). See
101
+ // scripts/autopilot_stall_watch.py.
102
+ const STALL_WATCH_LABEL = "com.m13v.social-autopilot-stall-watch";
103
+ const STALL_WATCH_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${STALL_WATCH_LABEL}.plist`);
104
+ const STALL_WATCH_INTERVAL_SECS = 120;
105
+ // On-screen overlay watcher. The harness status overlay ("S4L running" / idle
106
+ // banner) only renders WHILE `harness_overlay.py watch` is alive. That watcher
107
+ // is fire-and-forget with no supervisor of its own, so when it dies (or the
108
+ // harness Chrome restarts) nothing brings it back and the overlay silently
109
+ // disappears. Promote it to a first-class launchd job, but run the long-lived
110
+ // watcher in the FOREGROUND under KeepAlive (NOT a StartInterval that re-invokes
111
+ // a spawn-and-exit supervisor). The supervisor pattern races launchd on macOS:
112
+ // the instant the kicker shell exits, launchd SIGKILLs the whole job process
113
+ // group and reaps the just-spawned watcher before it can detach. Running the
114
+ // watcher AS the job's main process makes launchd supervise it directly:
115
+ // RunAtLoad starts it at boot, KeepAlive restarts it if it ever exits, and on
116
+ // unload its SIGTERM handler clears the overlay cleanly. Disable with
117
+ // S4L_OVERLAY_WATCH=0.
118
+ const OVERLAY_WATCH_LABEL = "com.m13v.social-overlay-watch";
119
+ const OVERLAY_WATCH_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${OVERLAY_WATCH_LABEL}.plist`);
120
+ // Daily self-updater. Enabled alongside autopilot so a hands-free (headless)
121
+ // install keeps itself current — the interactive `runtime` tool (action:'update')
122
+ // only helps when
123
+ // a human-facing agent session is open, which an autopilot box never has.
124
+ const UPDATER_LABEL = "com.m13v.social-autoposter-update";
125
+ const UPDATER_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${UPDATER_LABEL}.plist`);
126
+ // A sane PATH for launchd jobs (launchd starts with a bare PATH). Include the
127
+ // node bin dir so `npx`/`npm` resolve inside the updater.
128
+ const LAUNCHD_PATH = [
129
+ path.dirname(process.execPath),
130
+ "/opt/homebrew/bin",
131
+ "/usr/local/bin",
132
+ "/usr/bin",
133
+ "/bin",
134
+ "/usr/sbin",
135
+ "/sbin",
136
+ ].join(":");
137
+ // Bin dirs the pipeline must resolve FIRST: the owned uv venv (so the scripts'
138
+ // bare `python3` hits the provisioned interpreter with pipeline deps, not the
139
+ // user's system python) and ~/.local/bin (so `browser-harness`, the CDP scan
140
+ // engine, resolves). resolvePython() is dynamic, so this re-derives per call.
141
+ function ownedBinDirs() {
142
+ const dirs = [];
143
+ const py = resolvePython();
144
+ if (path.isAbsolute(py))
145
+ dirs.push(path.dirname(py));
146
+ dirs.push(path.join(os.homedir(), ".local", "bin"));
147
+ return dirs;
148
+ }
149
+ // PATH for an interactively-spawned pipeline run (draft_cycle): owned bins
150
+ // first, then whatever PATH the MCP server inherited.
151
+ function pipelinePath() {
152
+ return [...ownedBinDirs(), process.env.PATH || LAUNCHD_PATH].join(":");
153
+ }
154
+ // PATH baked into launchd plists (autopilot/cron): owned bins first, then the
155
+ // sane launchd default (launchd starts with a bare PATH).
156
+ function launchdPath() {
157
+ return [...ownedBinDirs(), LAUNCHD_PATH].join(":");
158
+ }
159
+ // Brand rename 2026-07-03 (SAPS_ -> S4L_): duplicate every S4L_* key under its
160
+ // legacy SAPS_* name when emitting env into child processes / plists, so an
161
+ // old pipeline-script version still on disk during a partial update keeps
162
+ // resolving its env. Never overwrites an explicitly-set legacy key. Remove
163
+ // once no pre-rename scripts remain in the field.
164
+ function withSapsEnvCompat(env) {
165
+ const out = { ...env };
166
+ for (const [k, v] of Object.entries(env)) {
167
+ if (k.startsWith("S4L_")) {
168
+ const legacy = "SAPS_" + k.slice(4);
169
+ if (!(legacy in out))
170
+ out[legacy] = v;
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+ function plistXml(opts) {
176
+ const args = opts.programArgs.map((a) => `\t\t<string>${a}</string>`).join("\n");
177
+ const schedule = opts.keepAlive
178
+ ? `\t<key>KeepAlive</key>\n\t<true/>`
179
+ : `\t<key>StartInterval</key>\n\t<integer>${opts.intervalSecs}</integer>`;
180
+ // Background (cron/autopilot) runs get the same Chrome the interactive cycle
181
+ // uses, so a no-sudo ~/Applications install (which the shell's own resolver
182
+ // doesn't scan) is still found off-screen. Omitted when Chrome resolves via
183
+ // PATH, so the shell's _resolve_chrome_bin stays the fallback.
184
+ const chrome = resolveChrome();
185
+ const chromeEnv = chrome
186
+ ? `\n\t\t<key>BH_CHROME_BIN</key>\n\t\t<string>${chrome}</string>`
187
+ : "";
188
+ // Caller-supplied env (e.g. the queue kicker's DRAFT_ONLY / S4L_CLAUDE_PROVIDER).
189
+ // Rendered after the baked-in vars so a caller can also override S4L_STATE_DIR.
190
+ // Dual-named (S4L_* + legacy SAPS_*) so a freshly-written plist still works
191
+ // with any pre-rename script version on disk during a partial update.
192
+ const extraEnv = opts.extraEnv
193
+ ? Object.entries(withSapsEnvCompat(opts.extraEnv))
194
+ .map(([k, v]) => `\n\t\t<key>${k}</key>\n\t\t<string>${v}</string>`)
195
+ .join("")
196
+ : "";
197
+ return `<?xml version="1.0" encoding="UTF-8"?>
198
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
199
+ <plist version="1.0">
200
+ <dict>
201
+ \t<key>Label</key>
202
+ \t<string>${opts.label}</string>
203
+ \t<key>ProgramArguments</key>
204
+ \t<array>
205
+ ${args}
206
+ \t</array>
207
+ ${schedule}
208
+ \t<key>StandardOutPath</key>
209
+ \t<string>${opts.stdoutLog}</string>
210
+ \t<key>StandardErrorPath</key>
211
+ \t<string>${opts.stderrLog}</string>
212
+ \t<key>EnvironmentVariables</key>
213
+ \t<dict>
214
+ \t\t<key>PATH</key>
215
+ \t\t<string>${launchdPath()}</string>
216
+ \t\t<key>HOME</key>
217
+ \t\t<string>${os.homedir()}</string>
218
+ \t\t<key>S4L_REPO_DIR</key>
219
+ \t\t<string>${repoDir()}</string>
220
+ \t\t<key>SAPS_REPO_DIR</key>
221
+ \t\t<string>${repoDir()}</string>
222
+ \t\t<key>S4L_PYTHON</key>
223
+ \t\t<string>${resolvePython()}</string>
224
+ \t\t<key>SAPS_PYTHON</key>
225
+ \t\t<string>${resolvePython()}</string>${chromeEnv}${extraEnv}
226
+ \t</dict>
227
+ \t<key>RunAtLoad</key>
228
+ \t<${opts.runAtLoad ? "true" : "false"}/>
229
+ </dict>
230
+ </plist>
231
+ `;
232
+ }
233
+ // Write a plist only if it does not already exist, so we never clobber a
234
+ // hand-tuned plist (e.g. a dev box with custom EnvironmentVariables). Returns
235
+ // whether it created a new file.
236
+ function ensurePlist(p, xml) {
237
+ if (fs.existsSync(p))
238
+ return false;
239
+ fs.mkdirSync(path.dirname(p), { recursive: true });
240
+ fs.writeFileSync(p, xml, "utf-8");
241
+ return true;
242
+ }
243
+ async function loadPlist(label, plistPath, uid) {
244
+ let res = await run("launchctl", ["bootstrap", `gui/${uid}`, plistPath], { timeoutMs: 15_000 });
245
+ if (res.code !== 0) {
246
+ res = await run("launchctl", ["load", plistPath], { timeoutMs: 15_000 });
247
+ }
248
+ return res;
249
+ }
250
+ async function unloadPlist(label, plistPath, uid) {
251
+ let res = await run("launchctl", ["bootout", `gui/${uid}/${label}`], { timeoutMs: 15_000 });
252
+ if (res.code !== 0) {
253
+ res = await run("launchctl", ["unload", plistPath], { timeoutMs: 15_000 });
254
+ }
255
+ return res;
256
+ }
257
+ // version is resolved at runtime from the real shipped package (see version.ts),
258
+ // so serverInfo.version finally reflects what the user actually has installed
259
+ // instead of a frozen literal.
260
+ const server = new McpServer({
261
+ name: "S4L",
262
+ version: VERSION,
263
+ }, {
264
+ // Host injects this into the agent's system prompt, so be proactive about
265
+ // WHEN this tool earns its keep, not just what it does.
266
+ instructions: "S4L (the social-autoposter server) drafts, reviews, and posts X/Twitter replies for the user's product, and " +
267
+ "can run them hands-free on autopilot.\n\n" +
268
+ "ONBOARDING IS A TERMINAL GOAL. When the user asks to set up, install, configure, or onboard " +
269
+ "social-autoposter, keep taking the next safe action until the owned runtime is ready, a " +
270
+ "project is fully configured with seeded search topics, X is connected with its real handle, AND " +
271
+ "the two draft-autopilot scheduled tasks have been created via queue_setup and are firing " +
272
+ "(see SCHEDULE THE AUTOPILOT below). The owned runtime (private Python + Chromium) provisions " +
273
+ "ITSELF automatically on boot — you do NOT install it. Just poll runtime action:'status' until it " +
274
+ "reports ready; only call runtime action:'install' or action:'doctor' to TROUBLESHOOT if status " +
275
+ "shows the boot provision failed or stalled. Do not ask whether to inspect " +
276
+ "status, repair a failed runtime, choose an auto-detected browser profile, connect " +
277
+ "X, scan the profile, research the product website, save conservative inferred fields, or seed " +
278
+ "topics. Do those things. The " +
279
+ "explicit setup request authorizes runtime repair and importing ONLY x.com/" +
280
+ "twitter.com session cookies into the managed browser: warn that macOS keychain prompts may " +
281
+ "appear, then proceed without a separate yes/no turn. Ask at most one bundled question, only " +
282
+ "when no product can be identified from config, context, the X profile, or public research, " +
283
+ "or pause for an unavoidable interactive login or OS prompt. Never auto-POST during setup. " +
284
+ "DO schedule the draft autopilot as the final step (once running it only queues drafts for " +
285
+ "approval, it never posts on its own). When setup reaches done (" +
286
+ "runtime ready, a project configured with seeded topics, X connected, and the two scheduled tasks " +
287
+ "created and firing), call the `dashboard` tool so the user " +
288
+ "sees the finished setup rendered, then give " +
289
+ "the completion summary.\n\n" +
290
+ "SCHEDULE THE AUTOPILOT (final onboarding step). There is ONE drafting pipeline: a launchd " +
291
+ "job this server installs runs the real cycle in draft-only mode, its Phase-2b Claude turn is " +
292
+ "serviced by a scheduled task that drains a local job queue, and the result is merged into the " +
293
+ "menu-bar approval cards. Drafting is the queue + worker path, fired automatically by a launchd " +
294
+ "kicker this server installs (every few minutes) — it runs on its own once the tasks exist; " +
295
+ "onboarding does NOT need to wait for or verify a draft. Steps, in order: " +
296
+ "(1) call `queue_setup` — it pre-writes the worker task prompt and returns the task spec " +
297
+ "(s4l-worker, the universal queue worker); " +
298
+ "(2) for EACH returned spec, call the HOST tool `create_scheduled_task` with its taskId, cronExpression, and " +
299
+ "prompt VERBATIM (if it says the task already exists, that's fine — move on, do not recreate); " +
300
+ "(3) the launchd kicker + scheduled tasks now run the pipeline on their own — call the `dashboard` " +
301
+ "tool to confirm the schedule is firing (schedule_state 'ok'), then summarize. Do NOT create the " +
302
+ "old 'social-autoposter-autopilot' task — it is deprecated. Do NOT ask the user to review drafts " +
303
+ "or pause for input mid-onboarding; once the tasks are created and firing, setup is done. If the " +
304
+ "host exposes no create_scheduled_task tool, tell the user the autopilot couldn't be scheduled here.\n\n" +
305
+ "BE PROACTIVE ABOUT MARKETING MOMENTS. Whenever the user ships, finishes, merges, or releases " +
306
+ "something worth talking about in this session (a new feature, a launch, a long-awaited fix, a " +
307
+ "milestone), don't wait to be asked: point it out to the user — the draft autopilot runs " +
308
+ "continuously and will surface relevant posts to approve. A feature nobody hears " +
309
+ "about is wasted distribution. Use judgment, suggest it for " +
310
+ "genuinely shareable moments, not routine refactors, chores, or internal cleanup.\n\n" +
311
+ "STAY CURRENT. At the start of a session, and again right after you help the user ship/merge/" +
312
+ "release, call the `runtime` tool (action:'version'). If `update_available` is true, tell the " +
313
+ "user and offer to run `runtime` with action:'update'. The `project_config` tool's status also " +
314
+ "surfaces `update_available` and an `update_hint`.\n\n" +
315
+ "TYPICAL FLOW: `project_config` (connect X + scan the profile) -> `engagement_mode` (after the " +
316
+ "profile scan: personal-brand is ON by default, so ASK the user the ONE question — do they ALSO " +
317
+ "want to promote a product? — and call action:'set' with personal_brand:true and " +
318
+ "promotion:true|false; this provisions the persona) -> IF they wanted promotion, `project_config` " +
319
+ "(configure the product project) -> `queue_setup` + " +
320
+ "`create_scheduled_task` (set up the draft autopilot once) -> the autopilot then runs on its own " +
321
+ "(scans, drafts via the queue + worker, and merges into the approval cards; nothing posts) -> the " +
322
+ "user approves in the menu bar -> `post_drafts` (post the approved ones) -> `get_stats` (see " +
323
+ "performance). Run `project_config` first; the other tools refuse until a " +
324
+ "project is fully configured. To change anything about a project later, call `project_config` " +
325
+ "again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
326
+ "RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
327
+ "(`post_drafts`, `get_stats`, `project_config`), end your turn by " +
328
+ "calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
329
+ "`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.",
330
+ });
331
+ const TOOL_HANDLERS = {};
332
+ const baseRegisterTool = server.registerTool.bind(server);
333
+ // `tool` is TYPED as server.registerTool so every call site keeps the exact
334
+ // same input-schema -> callback-arg inference it had before; the body is `any`
335
+ // and just additionally stashes the callback by name. `appTool` drops the
336
+ // leading `server` arg of registerAppTool (its callback takes no typed args).
337
+ // Tools that take a while: writing activity.json around them makes the menu bar
338
+ // show a spinner + label while they run (either invocation path). draft_cycle is
339
+ // NOT here — it writes finer scanning/drafting phases itself (see produceDrafts).
340
+ const TOOL_ACTIVITY = {
341
+ post_drafts: "posting",
342
+ get_stats: "loading stats",
343
+ };
344
+ function toolActivityLabel(name, args) {
345
+ const fallback = TOOL_ACTIVITY[name];
346
+ if (!fallback)
347
+ return null;
348
+ const override = typeof args?.__saps_activity_label === "string"
349
+ ? args.__saps_activity_label.replace(/\s+/g, " ").trim().slice(0, 80)
350
+ : "";
351
+ return override || fallback;
352
+ }
353
+ function withActivity(name, cb) {
354
+ if (!TOOL_ACTIVITY[name])
355
+ return cb;
356
+ return async (args, extra) => {
357
+ const label = toolActivityLabel(name, args) || TOOL_ACTIVITY[name];
358
+ writeActivity("working", label);
359
+ try {
360
+ return await cb(args, extra);
361
+ }
362
+ finally {
363
+ clearActivity();
364
+ }
365
+ };
366
+ }
367
+ const tool = ((name, config, cb) => {
368
+ const h = withActivity(name, cb);
369
+ TOOL_HANDLERS[name] = h;
370
+ return baseRegisterTool(name, config, h);
371
+ });
372
+ const appTool = ((name, config, cb) => {
373
+ // Wrap every tool handler so any thrown error is reported to Sentry. Single
374
+ // chokepoint for both the MCP SDK path and the local HTTP-panel path (both
375
+ // dispatch through TOOL_HANDLERS / registerAppTool). Re-throws so the caller
376
+ // still formats the error response exactly as before.
377
+ const wrapped = (async (args, extra) => {
378
+ try {
379
+ return await cb(args, extra);
380
+ }
381
+ catch (e) {
382
+ captureError(e, { tool: name });
383
+ throw e;
384
+ }
385
+ });
386
+ const h = withActivity(name, wrapped);
387
+ TOOL_HANDLERS[name] = h;
388
+ return registerAppTool(server, name, config, h);
389
+ });
390
+ function jsonContent(obj) {
391
+ return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
392
+ }
393
+ function textContent(text) {
394
+ return { content: [{ type: "text", text }] };
395
+ }
396
+ // Map a pipeline failure-reason key (from scripts/classify_run_error.py, emitted
397
+ // by run-twitter-cycle.sh as `DRAFT_ONLY_BLOCKED=<reason>`) to a clear,
398
+ // actionable message. The most common one on a fresh machine is
399
+ // claude_not_logged_in: the background `claude` CLI the pipeline shells out to
400
+ // has its OWN login, separate from Claude Desktop, so it can be logged out even
401
+ // though this MCP host is signed in. Without this, an auth failure was silently
402
+ // reported as a benign empty cycle ("all threads already engaged").
403
+ function blockedReasonMessage(reason) {
404
+ switch (reason) {
405
+ case "claude_not_logged_in":
406
+ return ("The background Claude CLI on this machine isn't logged in, so the drafting step " +
407
+ "couldn't run. (It DID find and rank threads, it just couldn't draft replies.) This " +
408
+ "CLI uses its own login, separate from Claude Desktop. To fix it, open a terminal and run:\n\n" +
409
+ " claude\n\n" +
410
+ "then `/login` inside it (or run `claude setup-token`). Once it's logged in, the autopilot will retry on its next scheduled cycle.");
411
+ case "monthly_limit":
412
+ case "daily_limit":
413
+ case "rate_limit_5h":
414
+ return (`The drafting step hit an Anthropic usage limit (${reason}), so no replies were drafted. ` +
415
+ "Wait for the limit to reset, then the autopilot will retry on its next scheduled cycle.");
416
+ case "no_search_topics":
417
+ return ("This project has no search topics yet, so there was nothing to scan. Topics live in the " +
418
+ "DB (project_search_topics) and are seeded from your project's `search_topics` when you " +
419
+ "configure it. Re-run the `project_config` tool for this project with a `search_topics` list " +
420
+ "(comma-separated keywords/phrases your buyers tweet about); it seeds them automatically, then " +
421
+ "the autopilot will retry on its next scheduled cycle.");
422
+ case "topics_api_unreachable":
423
+ return ("Couldn't reach the search-topics service to load this project's topics, so the cycle stopped " +
424
+ "before scanning. This is usually a transient backend/network issue. It should clear on the " +
425
+ "autopilot's next scheduled cycle; if it persists, check connectivity to the autoposter backend.");
426
+ case "credit_balance":
427
+ return ("The drafting step failed because the Anthropic account is out of credits. " +
428
+ "Add credits, then the autopilot will retry on its next scheduled cycle.");
429
+ default:
430
+ return (`The drafting step failed (${reason}) and produced no drafts. ` +
431
+ "Check skill/logs/twitter-cycle-*.log on this machine for details, then the autopilot will retry on its next scheduled cycle.");
432
+ }
433
+ }
434
+ // Turn a raw run-twitter-cycle.sh stdout line into a short, user-facing
435
+ // progress message — or null when the line isn't a milestone worth surfacing.
436
+ // The cycle script logs every phase via `log()` (tee'd to stdout), so we can
437
+ // follow along live instead of going dark for the minutes Phase 2b-prep takes.
438
+ // Keep this list tight: only lines a *user* benefits from seeing, phrased for
439
+ // someone who has no idea what "phase2a" means.
440
+ function cycleProgressMessage(line) {
441
+ const l = line.trim();
442
+ let m;
443
+ if (/=== Twitter Cycle \(batch=/.test(l))
444
+ return "Starting draft cycle…";
445
+ // NB: lines carry a `[HH:MM:SS] ` timestamp prefix, so don't anchor on ^.
446
+ if ((m = /Selected projects?:\s*(.+)$/.exec(l)))
447
+ return `Selected project: ${m[1]}`;
448
+ if (/phase=phase1\b/.test(l) || /Phase 1: drafting queries/.test(l))
449
+ return "Searching X for fresh threads…";
450
+ if ((m = /Phase 1 complete.*?has (\d+) candidates?/.exec(l)))
451
+ return `Found ${m[1]} candidate thread${m[1] === "1" ? "" : "s"} — ranking them…`;
452
+ if (/phase=phase2a\b/.test(l) || /candidates by virality_score selected/.test(l))
453
+ return "Scoring and ranking candidates…";
454
+ if (/Phase 2b-prep: Claude reading threads and drafting replies/.test(l))
455
+ return "Drafting replies (the long step — this can take a few minutes)…";
456
+ if ((m = /Engagement style assigned:.*?style=(\S+)/.exec(l)))
457
+ return `Drafting in style: ${m[1]}…`;
458
+ if (/DRAFT_ONLY_PLAN=/.test(l))
459
+ return "Drafts ready — assembling the review table…";
460
+ if ((m = /DRAFT_ONLY_BLOCKED=([a-z0-9_]+)/.exec(l)))
461
+ return `Cycle stopped (${m[1]}).`;
462
+ return null;
463
+ }
464
+ // Start the twitter-harness on-screen overlay watcher if it isn't already up.
465
+ // The overlay (status banner) only renders WHILE `harness_overlay.py watch`
466
+ // runs. The supervisor script is idempotent (pgrep
467
+ // guard), so calling this on every draft_cycle / autopilot-enable / show-browser
468
+ // is safe: it spawns at most one detached watcher and is a fast no-op otherwise.
469
+ //
470
+ // We thread S4L_PYTHON (the owned uv runtime, so the watcher resolves a
471
+ // playwright-capable interpreter on Lane B / .mcpb installs that have no system
472
+ // python) and S4L_LOG_DIR (the materialized repo's skill/logs, so the watcher
473
+ // reads the SAME cycle logs this run writes to decide busy/idle). Fire-and-forget:
474
+ // a failure here must never break the cycle it's decorating.
475
+ async function ensureOverlayWatch() {
476
+ try {
477
+ await run("bash", ["skill/run-overlay-watch.sh"], {
478
+ timeoutMs: 20_000,
479
+ env: withSapsEnvCompat({
480
+ S4L_PYTHON: resolvePython(),
481
+ S4L_LOG_DIR: path.join(repoDir(), "skill", "logs"),
482
+ TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
483
+ }),
484
+ });
485
+ }
486
+ catch {
487
+ /* best-effort: the overlay is a nicety, never a blocker */
488
+ }
489
+ }
490
+ async function produceDrafts(project, onProgress) {
491
+ // Run the real pipeline in DRAFT_ONLY mode: scan -> score -> draft -> link-gen,
492
+ // then STOP before posting. The script prints `DRAFT_ONLY_PLAN=<path>` and
493
+ // leaves the plan on disk for us to review + post. S4L_FORCE_PROJECT scopes
494
+ // the cycle to one project; TWITTER_PAGE_GEN_RATE=0 keeps link-gen sub-second.
495
+ const env = {
496
+ DRAFT_ONLY: "1",
497
+ TWITTER_PAGE_GEN_RATE: "0",
498
+ // Point the cycle at the resolved repo (a bare .mcpb materializes it under
499
+ // the state dir, NOT ~/social-autoposter); run-twitter-cycle.sh honors
500
+ // S4L_REPO_DIR for its REPO_DIR. And put the owned runtime + ~/.local/bin
501
+ // first on PATH so the script's bare `python3` and `browser-harness` resolve.
502
+ S4L_REPO_DIR: repoDir(),
503
+ PATH: pipelinePath(),
504
+ // Interactive draft_cycle: launch the harness Chrome ON-SCREEN so the user
505
+ // can watch the scan/scrape happen live. Cron/autopilot do NOT set these, so
506
+ // background runs keep the off-screen default in twitter-backend.sh and don't
507
+ // hijack the screen. (Only affects a fresh Chrome launch; an already-running
508
+ // harness window keeps its current position.)
509
+ BH_WINDOW_POS: "60,60",
510
+ BH_WINDOW_SIZE: "1280,900",
511
+ };
512
+ if (project)
513
+ env.S4L_FORCE_PROJECT = project;
514
+ // Point the harness at the Chrome the runtime detected/installed. The cycle's
515
+ // own _resolve_chrome_bin doesn't scan ~/Applications (our no-sudo fallback
516
+ // install target), so without this a non-admin .mcpb install would have Chrome
517
+ // on disk yet still report "no Chrome/Chromium binary found." Only set when
518
+ // resolved; otherwise let the shell resolve Chrome from its own probe list.
519
+ const chrome = resolveChrome();
520
+ if (chrome)
521
+ env.BH_CHROME_BIN = chrome;
522
+ // Bring the on-screen overlay up alongside the live harness window so the user
523
+ // watching the scan/scrape sees status + queued drafts. Idempotent + detached.
524
+ await ensureOverlayWatch();
525
+ let step = 0;
526
+ let lastMsg = "";
527
+ // Granular scan progress for the menu-bar label. Phase 1 logs one
528
+ // `executing N queries` line (the total), then one `ok/err project=… kept=K`
529
+ // line per query. We count those to paint `scanning X · N/M · kept K` instead
530
+ // of a static "scanning X". Best-effort: missing total falls back to a plain
531
+ // count, and any parse miss just leaves the prior label up.
532
+ let scanTotal = 0;
533
+ let scanDone = 0;
534
+ let scanKept = 0;
535
+ // ONE predictable, host-independent place to watch a draft_cycle run, so any
536
+ // agent (or human) debugging "the cycle looks stuck" has an obvious path:
537
+ // ~/social-autoposter/skill/logs/draft_cycle-mcp.log
538
+ // It lives right next to the cycle's own twitter-cycle-*.log. We append the
539
+ // full live cycle output here (not just milestones) plus a clear run banner.
540
+ // Best-effort: a logging failure must never break the cycle.
541
+ const mcpLog = path.join(repoDir(), "skill", "logs", "draft_cycle-mcp.log");
542
+ const appendLog = (s) => {
543
+ try {
544
+ fs.appendFileSync(mcpLog, s);
545
+ }
546
+ catch {
547
+ /* ignore — never fail the cycle over a log write */
548
+ }
549
+ };
550
+ try {
551
+ fs.mkdirSync(path.dirname(mcpLog), { recursive: true });
552
+ }
553
+ catch {
554
+ /* ignore */
555
+ }
556
+ appendLog(`\n===== draft_cycle start ${new Date().toISOString()} ` +
557
+ `project=${project ?? "(default)"} =====\n`);
558
+ // Menu-bar status: scanning first, then drafting once the prep phase begins
559
+ // (switched in onLine below). Cleared before every return.
560
+ writeActivity("scanning", "scanning X");
561
+ const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
562
+ env: withSapsEnvCompat(env),
563
+ timeoutMs: 900_000, // scan+draft can take several minutes
564
+ // Fan every cycle line out to THREE sinks so progress is never a black box:
565
+ // 1. draft_cycle-mcp.log — the stable, documented, host-independent file.
566
+ // 2. this server's stderr — lands in the host's MCP server log
567
+ // (mcp-server-social-autoposter.log on Desktop), which used to show
568
+ // only the JSON-RPC handshake.
569
+ // 3. the live progress sink — milestone messages under the chat spinner.
570
+ onLine: (line) => {
571
+ const t = line.replace(/\s+$/, "");
572
+ if (t.trim()) {
573
+ appendLog(`${t}\n`);
574
+ console.error(`[draft_cycle] ${t}`);
575
+ }
576
+ // Per-query scan progress -> granular menu-bar label. These lines only
577
+ // appear during Phase 1 (before 2b-prep), so they never fight the
578
+ // "drafting" label below.
579
+ let sm;
580
+ if ((sm = /executing (\d+) quer/.exec(t))) {
581
+ scanTotal = parseInt(sm[1], 10) || 0;
582
+ }
583
+ else if ((sm = /^\s*(?:ok|err)\s+project=/.exec(t))) {
584
+ scanDone += 1;
585
+ const km = /kept=(\d+)/.exec(t);
586
+ if (km)
587
+ scanKept += parseInt(km[1], 10) || 0;
588
+ const prog = scanTotal ? `${scanDone}/${scanTotal}` : `${scanDone}`;
589
+ writeActivity("scanning", `scanning X · ${prog} · kept ${scanKept}`);
590
+ }
591
+ if (/Phase 2b-prep/.test(t))
592
+ writeActivity("drafting", "drafting replies");
593
+ if (!onProgress)
594
+ return;
595
+ const msg = cycleProgressMessage(t);
596
+ // Skip consecutive duplicates (a phase can log a couple matching lines).
597
+ if (msg && msg !== lastMsg) {
598
+ lastMsg = msg;
599
+ onProgress(msg, ++step);
600
+ }
601
+ },
602
+ });
603
+ appendLog(`===== draft_cycle end ${new Date().toISOString()} exit=${res.code} =====\n`);
604
+ // Prefer the explicit marker; fall back to the newest plan file on disk.
605
+ const marker = /DRAFT_ONLY_PLAN=\/tmp\/twitter_cycle_plan_(.+)\.json/.exec(res.stdout + "\n" + res.stderr);
606
+ if (marker && marker[1]) {
607
+ clearActivity();
608
+ return { batchId: marker[1] };
609
+ }
610
+ // A real prep-step failure (e.g. the background claude CLI isn't logged in)
611
+ // emits DRAFT_ONLY_BLOCKED=<reason>. Surface that instead of silently falling
612
+ // back to a stale/empty batch and mis-reporting "no fresh candidates".
613
+ const blockedMarker = /DRAFT_ONLY_BLOCKED=([a-z0-9_]+)/.exec(res.stdout + "\n" + res.stderr);
614
+ if (blockedMarker && blockedMarker[1]) {
615
+ clearActivity();
616
+ return { batchId: null, blocked: blockedReasonMessage(blockedMarker[1]) };
617
+ }
618
+ // No `DRAFT_ONLY_PLAN=` marker from THIS run => this run produced no drafts.
619
+ // We MUST NOT fall back to the newest plan file on disk (`latestBatchId()`):
620
+ // that's a *previous* run's batch, so a 5-second empty cycle would echo an old
621
+ // 7-draft batch and report phantom success. Report 0 drafts honestly, with the
622
+ // pipeline's own reason (e.g. cold-start project with no seeded queries).
623
+ clearActivity();
624
+ return {
625
+ batchId: null,
626
+ blocked: `This run produced no drafts (exit ${res.code}). The scan found no fresh ` +
627
+ `candidates for the selected project — usually a cold-start project with ` +
628
+ `no seeded search queries/topics, or a pipeline error. This is NOT a ` +
629
+ `previous batch. Tail:\n` +
630
+ res.stderr.split("\n").slice(-12).join("\n"),
631
+ };
632
+ }
633
+ // Render every draft in a batch as a numbered, human-readable table. This IS the
634
+ // review surface now: the model relays this table to the user and asks which
635
+ // numbers to post / edit, then posts the chosen ones via the `post_drafts` tool.
636
+ //
637
+ // We used to gather approvals through MCP elicitation (a checkbox form), but the
638
+ // desktop "Code tab" host doesn't advertise the `elicitation` capability (only
639
+ // `io.modelcontextprotocol/ui`), so the form never rendered and cycles silently
640
+ // posted nothing. Approval is conversational instead — numbers in chat.
641
+ function renderDraftsTable(plan) {
642
+ const candidates = plan.candidates || [];
643
+ return candidates
644
+ // Number by FULL-array index (matches post_drafts + the menu bar), then drop
645
+ // already-finished entries so the cards only show what's still pending.
646
+ .map((c, i) => ({ c, n: i + 1 }))
647
+ .filter((e) => e.c.posted !== true && e.c.terminal !== true && e.c.approved !== true)
648
+ // The queue is append-only; newest drafts have the highest stable index.
649
+ // Show those first so review starts with likely-live tweets instead of stale
650
+ // low-number drafts that have been sitting around for hours.
651
+ .sort((a, b) => b.n - a.n)
652
+ .map(({ c, n }) => {
653
+ const author = c.thread_author ? `@${c.thread_author}` : "(unknown thread)";
654
+ const style = c.engagement_style ?? "?";
655
+ const reply = c.reply_text ?? "(empty)";
656
+ // The literal tail URL is NOT known yet: at post time a short link is minted
657
+ // from this target (e.g. fazm.ai/cc -> s4l.ai/r/<code>). Approved drafts
658
+ // always carry the link (post_drafts forces TWITTER_TAIL_LINK_RATE=1.0), so
659
+ // this is the target that WILL be appended. Show the TARGET only; never
660
+ // pre-mint the real /r/ code (that would waste pool codes / split clicks).
661
+ const link = c.link_url
662
+ ? `\n + link (appended as a short link at post time): ${c.link_url}`
663
+ : "";
664
+ // The original tweet we're replying to — context the reviewer needs to judge
665
+ // the draft. Already in the plan; just surface it.
666
+ const threadText = c.thread_text
667
+ ? `\n in reply to: ${c.thread_text.replace(/\s+/g, " ").trim().slice(0, 280)}`
668
+ : "";
669
+ return (`[${n}] ${author} (style: ${style})` +
670
+ `${threadText}\n` +
671
+ ` draft: ${reply.replace(/\n/g, "\n ")}` +
672
+ `${link}\n` +
673
+ ` thread url: ${c.candidate_url ?? "?"}`);
674
+ })
675
+ .join("\n\n");
676
+ }
677
+ function parsePostCandidateResults(stdout) {
678
+ const byId = new Map();
679
+ const upsert = (candidateId, outcome, reason, ourUrl) => {
680
+ const prev = byId.get(candidateId);
681
+ // A landed post wins over any earlier noisy line for the same candidate.
682
+ if (prev?.outcome === "posted" && outcome !== "posted")
683
+ return;
684
+ byId.set(candidateId, {
685
+ candidate_id: candidateId,
686
+ outcome,
687
+ ...(reason ? { reason } : {}),
688
+ ...(ourUrl ? { our_url: ourUrl } : {}),
689
+ });
690
+ };
691
+ for (const line of stdout.split("\n")) {
692
+ let m = /\[post\] candidate (\d+) posted as (\S+) \(post_id=/.exec(line);
693
+ if (m) {
694
+ upsert(m[1], "posted", undefined, m[2]);
695
+ continue;
696
+ }
697
+ m = /\[post\] candidate (\d+): pre-post dedup hit\b/.exec(line);
698
+ if (m) {
699
+ upsert(m[1], "skipped", "duplicate_thread_pre_post");
700
+ continue;
701
+ }
702
+ m = /\[post\] candidate (\d+) reply failed: ([A-Za-z0-9_:-]+)/.exec(line);
703
+ if (m) {
704
+ upsert(m[1], "skipped", m[2]);
705
+ continue;
706
+ }
707
+ m = /\[post\] candidate (\d+) reply succeeded but reply_url invalid:/.exec(line);
708
+ if (m) {
709
+ upsert(m[1], "skipped", "no_reply_url_captured");
710
+ continue;
711
+ }
712
+ m = /\[post\] candidate (\d+): empty reply_text; skipping/.exec(line);
713
+ if (m) {
714
+ upsert(m[1], "skipped", "empty_reply_text");
715
+ continue;
716
+ }
717
+ m = /\[post\] candidate (\d+) crashed:/.exec(line);
718
+ if (m)
719
+ upsert(m[1], "failed", "exception");
720
+ }
721
+ return [...byId.values()];
722
+ }
723
+ // Resolve the configured posting handle the SAME way account_resolver.py does:
724
+ // AUTOPOSTER_TWITTER_HANDLE env first, then config.json accounts.twitter.handle.
725
+ // Returns the bare handle (no @) or null. The post preflight uses it so a missing
726
+ // handle fails ONCE, loudly, instead of as N silent per-reply no_account_configured
727
+ // skips (twitter_browser.py refuses to post with no handle — no impersonation).
728
+ function readConfiguredTwitterHandle() {
729
+ const env = (process.env.AUTOPOSTER_TWITTER_HANDLE || "").trim().replace(/^@/, "");
730
+ if (env)
731
+ return env;
732
+ try {
733
+ const cfg = JSON.parse(fs.readFileSync(path.join(repoDir(), "config.json"), "utf-8"));
734
+ const h = cfg?.accounts?.twitter?.handle;
735
+ const s = (typeof h === "string" ? h : "").trim().replace(/^@/, "");
736
+ return s || null;
737
+ }
738
+ catch {
739
+ return null;
740
+ }
741
+ }
742
+ // Self-heal a missing handle: read the live logged-in @handle from the managed
743
+ // Chrome and persist it to config.json accounts.twitter.handle. This is ground
744
+ // truth (the poster posts through that exact session), NOT a guess — so it's safe
745
+ // where a hardcoded fallback would not be. Closes the onboarding gap where
746
+ // connect_x's best-effort handle capture silently no-op'd and left posting dead.
747
+ // Best-effort; never throws — the caller re-checks and refuses loudly if still unset.
748
+ async function ensurePostingHandle() {
749
+ try {
750
+ await runPython("scripts/setup_twitter_auth.py", ["resolve-handle"], {
751
+ timeoutMs: 60_000,
752
+ env: withSapsEnvCompat({ S4L_REPO_DIR: repoDir(), PATH: pipelinePath() }),
753
+ });
754
+ }
755
+ catch {
756
+ /* best effort */
757
+ }
758
+ }
759
+ async function ensureTwitterBrowserForPost() {
760
+ const chrome = resolveChrome();
761
+ const env = {
762
+ S4L_REPO_DIR: repoDir(),
763
+ S4L_PYTHON: resolvePython(),
764
+ PATH: pipelinePath(),
765
+ TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
766
+ };
767
+ if (chrome)
768
+ env.BH_CHROME_BIN = chrome;
769
+ return run("bash", ["-lc", ". skill/lib/twitter-backend.sh && ensure_twitter_browser_for_backend"], {
770
+ timeoutMs: 90_000,
771
+ env: withSapsEnvCompat(env),
772
+ onLine: (line) => {
773
+ const t = line.replace(/\s+$/, "");
774
+ if (t.trim())
775
+ console.error(`[post-browser] ${t}`);
776
+ },
777
+ });
778
+ }
779
+ async function postApproved(batchId, plan) {
780
+ // Post every card the user APPROVED that hasn't already landed or been ruled out.
781
+ // `approved` is now a DURABLE decision (sticky, never cleared by a later call), so
782
+ // filtering out posted/terminal here makes this idempotent: re-running it only
783
+ // drains the not-yet-posted approved backlog (e.g. a card a restart interrupted),
784
+ // never re-posts a done one. This is what lets the startup backlog-drain and the
785
+ // per-card menu-bar calls share one code path safely.
786
+ const approved = (plan.candidates || []).filter((c) => c.approved === true && c.posted !== true && c.terminal !== true);
787
+ if (approved.length === 0)
788
+ return { attempted: 0, exit_code: 0, summary: "nothing approved" };
789
+ // PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
790
+ // EVERY reply with no_account_configured and the whole batch skips — invisibly.
791
+ // If onboarding never persisted it, self-heal from the live session; if even that
792
+ // can't determine it, refuse here with a clear reason rather than launching a
793
+ // poster that silently burns the whole batch.
794
+ if (!readConfiguredTwitterHandle())
795
+ await ensurePostingHandle();
796
+ if (!readConfiguredTwitterHandle()) {
797
+ return {
798
+ attempted: 0,
799
+ exit_code: 0,
800
+ posted: 0,
801
+ summary: "no_account_configured",
802
+ error: "X is connected but no posting @handle is configured, so every reply would be refused " +
803
+ "(no_account_configured). Re-run project_config action:'connect_x' to capture the handle, " +
804
+ "or set accounts.twitter.handle in config.json.",
805
+ };
806
+ }
807
+ // Mark posting active so the draft-cycle scan DEFERS launching any scan for the
808
+ // duration of this batch (+ grace). This is the source-level mutual exclusion
809
+ // that actually fixes the hijack: the autopilot never launches a scan to race
810
+ // the post for the browser. Reset is guaranteed by scheduleShellLockRelease()
811
+ // in the finally below, so an early/failed post can't wedge scanning.
812
+ postingActive = true;
813
+ startPostingFlagHeartbeat(); // cross-instance: a sibling MCP's scan defers too
814
+ // Posting is a priority over scanning: abort any in-flight pipeline scan so the
815
+ // approved post takes the browser immediately instead of waiting on the lock.
816
+ preemptScanForPost();
817
+ // Hold the /tmp shell browser lock (the one the scanner respects) for the WHOLE
818
+ // batch so the every-minute autopilot scan queues behind the post instead of
819
+ // seizing Chrome mid-batch — the root cause of approved batches landing 0/N.
820
+ const heldShellLock = await acquireShellBrowserLock();
821
+ const approvedBatch = `${batchId}_approved`;
822
+ writePlan(approvedBatch, { ...plan, candidates: approved });
823
+ // S4L_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
824
+ // never get the active-campaign suffix (e.g. " written with ai") appended.
825
+ // twitter_browser.py's reply handler reads this env (inherited through
826
+ // twitter_post_plan.py's subprocess). The cron pipeline doesn't set it, so the
827
+ // A/B disclosure experiment keeps running on autopilot/cron and on Reddit.
828
+ const res = await (async () => {
829
+ try {
830
+ const browser = await ensureTwitterBrowserForPost();
831
+ if (browser.code !== 0) {
832
+ const failure = {
833
+ posted: 0,
834
+ skipped: 0,
835
+ failed: approved.length,
836
+ failure_reasons: "browser_bootstrap_failed",
837
+ skip_reasons: "",
838
+ };
839
+ return {
840
+ code: browser.code,
841
+ stdout: `${JSON.stringify(failure)}\n`,
842
+ stderr: [browser.stderr, browser.stdout].filter(Boolean).join("\n"),
843
+ };
844
+ }
845
+ return await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
846
+ timeoutMs: 900_000,
847
+ env: withSapsEnvCompat({
848
+ S4L_SKIP_CAMPAIGN_SUFFIX: "1",
849
+ // Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
850
+ // runs TWITTER_TAIL_LINK_RATE=0.9 (from .env) so ~10% of autopilot posts
851
+ // ship link-less as an experiment arm. But when the user hand-reviews a
852
+ // draft, sees the link target in the table, and approves it, dropping the
853
+ // link is surprising and unwanted. Force 1.0 here so every approved draft
854
+ // carries its link. This wins over .env / process.env because run() spreads
855
+ // opts.env AFTER process.env, and twitter_post_plan.py never load_dotenv's
856
+ // with override, so nothing clobbers it. Cron is untouched (it never goes
857
+ // through this MCP path), so the 0.9 experiment keeps running there.
858
+ TWITTER_TAIL_LINK_RATE: "1.0",
859
+ // Plugin flow only: skip the link_tail Claude call. It just rewords
860
+ // prose around the URL (the minted short link comes from the
861
+ // deterministic wrap step), and on .mcpb boxes there's no `claude`
862
+ // binary so it wastes ~35s/post of run_claude.sh retry backoff before
863
+ // falling back to the mechanical concat anyway. link_tail.py honors
864
+ // this and short-circuits to that concat instantly. The local
865
+ // cron/plist autopilot never sets this, so it keeps generating the
866
+ // bridge sentence.
867
+ S4L_SKIP_LINK_TAIL: "1",
868
+ // The poster attaches to the twitter-harness Chrome over CDP. The cron
869
+ // pipeline exports this from skill/lib/twitter-backend.sh; the MCP path
870
+ // must set it explicitly or twitter_browser.py fails with "No twitter-
871
+ // harness Chrome reachable". Honor an inherited value (AppMaker / VM
872
+ // BYO-Chrome), else default to the local harness on port 9555.
873
+ TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
874
+ }),
875
+ // Stream the poster's output live so HANDLED
876
+ // failures — e.g. every reply refused with no_account_configured, which
877
+ // returns a reason instead of throwing — surface in main.log + telemetry
878
+ // in real time. Without this the poster's stdout was buffered in-process
879
+ // and only flushed to post-*.log at the END, so a 0/N batch was invisible
880
+ // while the menu bar showed "posting N/89" climbing.
881
+ onLine: (line) => {
882
+ const t = line.replace(/\s+$/, "");
883
+ if (t.trim())
884
+ console.error(`[post] ${t}`);
885
+ },
886
+ });
887
+ }
888
+ finally {
889
+ // Always schedule the grace release (even if the lock acquire failed): the
890
+ // timer both frees the lock AND clears postingActive, so scanning resumes
891
+ // SHELL_LOCK_GRACE_MS after the last card. Holding through the grace lets the
892
+ // NEXT approved card reuse one continuous hold (mirrors the plist holding the
893
+ // lock through the whole posting phase, then releasing at the end).
894
+ scheduleShellLockRelease();
895
+ }
896
+ })();
897
+ // Persist the poster's own stdout/stderr to a dated log. Without this the post
898
+ // run was invisible: twitter_post_plan.py's output streamed to this MCP
899
+ // instance's stderr and was never tee'd anywhere on disk, so a 0/N batch left
900
+ // no on-box trace to debug. Best-effort; never breaks posting.
901
+ try {
902
+ const postLogDir = path.join(repoDir(), "skill", "logs");
903
+ fs.mkdirSync(postLogDir, { recursive: true });
904
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
905
+ fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${approved.length} exit=${res.code} ` +
906
+ `shell_lock=${heldShellLock}\n\n=== stdout ===\n${res.stdout}\n\n=== stderr ===\n${res.stderr}\n`);
907
+ }
908
+ catch {
909
+ /* best effort */
910
+ }
911
+ let summary = res.stdout.trim();
912
+ try {
913
+ const lines = res.stdout.trim().split("\n");
914
+ summary = JSON.parse(lines[lines.length - 1]);
915
+ }
916
+ catch {
917
+ /* keep raw */
918
+ }
919
+ // Real posted count from the pipeline summary — NOT the approved count. A run
920
+ // can exit 0 yet post nothing (every reply hit reply_box_not_found, etc.), so
921
+ // trusting approved.length here reported phantom successes ("posted: 1" when 0
922
+ // landed). Fall back to approved.length only when the summary is unparseable
923
+ // AND the process exited clean.
924
+ const summObj = (summary && typeof summary === "object") ? summary : null;
925
+ const realPosted = summObj && typeof summObj.posted === "number"
926
+ ? summObj.posted
927
+ : res.code === 0 && !summObj
928
+ ? approved.length
929
+ : 0;
930
+ // Mark candidates according to the poster's per-candidate outcome. This keeps
931
+ // the review queue honest: posted drafts disappear as posted, terminal skips
932
+ // (dedup, deleted tweet, no captured URL) disappear without being counted as
933
+ // posted, and multi-approval batches no longer smear one posted count across
934
+ // every approved draft.
935
+ const resultRowsFromSummary = Array.isArray(summObj?.candidate_results)
936
+ ? summObj?.candidate_results
937
+ : [];
938
+ const resultRows = resultRowsFromSummary.length
939
+ ? resultRowsFromSummary
940
+ .map((r) => ({
941
+ candidate_id: String(r.candidate_id ?? ""),
942
+ outcome: String(r.outcome || ""),
943
+ reason: typeof r.reason === "string" ? r.reason : undefined,
944
+ our_url: typeof r.our_url === "string" ? r.our_url : undefined,
945
+ }))
946
+ .filter((r) => r.candidate_id && ["posted", "skipped", "failed"].includes(r.outcome))
947
+ : parsePostCandidateResults(res.stdout);
948
+ const approvedById = new Map();
949
+ approved.forEach((c) => {
950
+ if (c.candidate_id !== undefined && c.candidate_id !== null)
951
+ approvedById.set(String(c.candidate_id), c);
952
+ });
953
+ let touchedPlan = false;
954
+ if (resultRows.length) {
955
+ resultRows.forEach((r, idx) => {
956
+ const c = approvedById.get(r.candidate_id) || approved[idx];
957
+ if (!c)
958
+ return;
959
+ if (r.outcome === "posted") {
960
+ c.posted = true;
961
+ c.terminal = false;
962
+ if (r.our_url)
963
+ c.our_url = r.our_url;
964
+ touchedPlan = true;
965
+ }
966
+ else if (r.outcome === "skipped" || r.outcome === "failed") {
967
+ c.terminal = true;
968
+ c.terminal_reason = r.reason || r.outcome;
969
+ touchedPlan = true;
970
+ }
971
+ });
972
+ }
973
+ else if (realPosted > 0 || (res.code === 0 && !summObj)) {
974
+ // Legacy fallback for older poster output without parseable per-candidate
975
+ // lines. Mark only when we have no finer-grained signal.
976
+ for (const c of approved)
977
+ c.posted = true;
978
+ touchedPlan = true;
979
+ }
980
+ if (touchedPlan) {
981
+ try {
982
+ writePlan(batchId, plan);
983
+ }
984
+ catch {
985
+ /* best effort */
986
+ }
987
+ }
988
+ // Post failures are HANDLED in the pipeline (it returns a count, never throws),
989
+ // so they never reach Sentry on their own. Capture an explicit event whenever
990
+ // the run exited non-zero OR fewer drafts posted than were approved. This is
991
+ // the only telemetry channel that reaches a customer .mcpb install (their cycle
992
+ // log lives on their machine). install_id/hostname are auto-tagged.
993
+ if (res.code !== 0 || realPosted < approved.length) {
994
+ captureError(new Error(`post_drafts: ${realPosted}/${approved.length} posted (exit=${res.code})`), {
995
+ component: "post",
996
+ exit_code: String(res.code),
997
+ attempted: String(approved.length),
998
+ posted: String(realPosted),
999
+ failure_reasons: String(summObj?.failure_reasons || ""),
1000
+ skip_reasons: String(summObj?.skip_reasons || ""),
1001
+ stderr_tail: res.stderr.split("\n").slice(-5).join(" | ").slice(0, 500),
1002
+ });
1003
+ void flushSentry(2000);
1004
+ }
1005
+ void flushLogs();
1006
+ return {
1007
+ attempted: approved.length,
1008
+ posted: realPosted,
1009
+ exit_code: res.code,
1010
+ summary,
1011
+ stderr_tail: res.stderr.split("\n").slice(-8).join("\n"),
1012
+ };
1013
+ }
1014
+ // ---- getting-started: discoverable front door (USER-invoked, no side effects)
1015
+ // This is NOT a tool — the model never auto-calls it. It surfaces in clients
1016
+ // that render prompts as slash-commands / starters (e.g. Claude Desktop's "/"
1017
+ // menu). When the user picks it, it injects the message below into the chat,
1018
+ // which nudges the agent to start the real onboarding via the `project_config` tool.
1019
+ // Deliberately a DUMB POINTER: it names no fields and no steps, so it can never
1020
+ // drift from REQUIRED_FIELDS / the project_config tool's flow. All real logic stays
1021
+ // in `project_config`; this is just a convenience handle to begin.
1022
+ server.registerPrompt("getting-started", {
1023
+ title: "Set up S4L",
1024
+ description: "Start here. Walks you through configuring a product and connecting your X/Twitter " +
1025
+ "account so the autoposter can draft and post for you.",
1026
+ }, async () => ({
1027
+ messages: [
1028
+ {
1029
+ role: "user",
1030
+ content: {
1031
+ type: "text",
1032
+ text: "Set up social-autoposter plugin end to end now. Treat this as a terminal goal: inspect status, " +
1033
+ "install or repair the owned runtime, auto-detect and connect my X session, scan my " +
1034
+ "profile, discover and research the product I most clearly represent, infer and save a " +
1035
+ "conservative complete project with search topics, seed them, and run a draft-only " +
1036
+ "verification. Keep going without asking me to approve each safe setup step. A brief " +
1037
+ "heads-up before macOS keychain prompts is enough; proceed immediately. Ask only if an " +
1038
+ "interactive login is unavoidable or no product can be identified from config, context, " +
1039
+ "my X profile, or public research. Do not post or enable autopilot unless I explicitly ask. " +
1040
+ "Keep every reply to me extremely concise: a few short sentences at most, no step-by-step " +
1041
+ "narration or long status walls. If you must ask me something (e.g. the product URL), make " +
1042
+ "it one short question.",
1043
+ },
1044
+ },
1045
+ ],
1046
+ }));
1047
+ // Instruction (NOT a script) the agent follows to research the product website
1048
+ // after the profile scan. The agent uses ITS OWN browser/fetch tools — the MCP
1049
+ // ships no scraper. The goal is to fill the PRODUCT half of the config (what it
1050
+ // does, how it's different, who it's for, the CTA link, claims to avoid) from the
1051
+ // site itself, written in the user's voice captured by the profile scan.
1052
+ const WEBSITE_RESEARCH_INSTRUCTIONS = "PRODUCT RESEARCH (do this before saving the product fields):\n" +
1053
+ "1. Discover the product URL from existing config, the conversation, the connected X profile " +
1054
+ "(bio, links, and recent posts), or public research. Use the clearest supported product without " +
1055
+ "asking. Ask one blocking question only if no defensible product can be identified.\n" +
1056
+ "2. Visit it with your OWN browser/fetch tools (no scraper is provided) and read " +
1057
+ "AT LEAST 5 pages if the site has them — follow the internal nav/footer links. " +
1058
+ "Prioritize: homepage, pricing, features/product, about, docs or changelog or blog, " +
1059
+ "FAQ, customers/testimonials/case-studies. Read as many as you can find (5+ is the " +
1060
+ "floor, not the cap) to learn the product deeply.\n" +
1061
+ "3. From what you actually read, extract the PRODUCT fields: `description` (what it " +
1062
+ "does, concretely), `differentiator` (how it's genuinely different from alternatives), " +
1063
+ "`icp` (who it's for — cross-check against who the user engages with on X), " +
1064
+ "`get_started_link` (the primary signup/CTA URL), and `content_guardrails` (claims, " +
1065
+ "competitors, or wording the site avoids — never overclaim beyond the site).\n" +
1066
+ "4. WRITE these fields in the USER'S voice from the profile scan (their phrasing, " +
1067
+ "register, vibe) while keeping every product CLAIM factual to the site. Don't invent " +
1068
+ "features, metrics, or guarantees the site doesn't state.\n" +
1069
+ "5. Save the best conservative factual draft without adding a confirmation round-trip. Call " +
1070
+ "project_config with name + the product fields (plus voice/search_topics from the profile scan), AND " +
1071
+ "expand those topics into a `search_queries` array of ~30 concrete X advanced-search strings in the " +
1072
+ "SAME call — YOU are the model, so do the expansion in-session; it seeds directly with no `claude -p`. " +
1073
+ "If the site is thin or unreachable, use only supported facts and leave optional detail conservative; " +
1074
+ "ask the user only if a required field is genuinely unknowable.";
1075
+ async function seedSearchQueriesForProject(project, rawQueries) {
1076
+ const agentQueries = normalizeStringList(rawQueries) ?? [];
1077
+ let queries = [];
1078
+ if (!agentQueries.length) {
1079
+ return {
1080
+ note: " (No search_queries supplied, so the cycle will run off the seeded topics one at a time. " +
1081
+ "To fan out, re-run with a search_queries array of ~30 X search strings you expand from these " +
1082
+ "topics — it seeds them directly, no claude CLI.)",
1083
+ queries,
1084
+ };
1085
+ }
1086
+ try {
1087
+ const qfile = path.join(os.tmpdir(), `saps-queries-${project}-${Date.now()}.json`);
1088
+ fs.writeFileSync(qfile, JSON.stringify({ queries: agentQueries.map((q) => ({ query: q, topic: "" })) }));
1089
+ const qseed = await runPython("scripts/seed_search_queries.py", ["--project", project, "--queries-json", qfile, "--supply-test", "auto", "--emit-json"], { timeoutMs: 600_000 });
1090
+ try {
1091
+ fs.unlinkSync(qfile);
1092
+ }
1093
+ catch {
1094
+ /* best-effort cleanup */
1095
+ }
1096
+ const qm = /seeded=(\d+)\s+inserted=(\d+)\s+updated=(\d+)/.exec(qseed.stdout);
1097
+ const qjson = qseed.stdout.split("===QUERIES_JSON===")[1];
1098
+ if (qjson) {
1099
+ try {
1100
+ queries = (JSON.parse(qjson.trim()).queries ?? []);
1101
+ }
1102
+ catch {
1103
+ /* leave empty; count note still informs the user */
1104
+ }
1105
+ }
1106
+ if (qseed.code === 0 && qm) {
1107
+ const n = queries.length || Number(qm[1]);
1108
+ return {
1109
+ note: ` Seeded ${n} search quer${n === 1 ? "y" : "ies"} so the cycle can fan out instead of running a single query.`,
1110
+ queries,
1111
+ };
1112
+ }
1113
+ if (qseed.code !== 0) {
1114
+ const qtail = (qseed.stderr || qseed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
1115
+ return {
1116
+ note: ` (Search queries not seeded yet — ${qtail}. The cycle still runs off the seeded topics.)`,
1117
+ queries,
1118
+ };
1119
+ }
1120
+ return { note: "", queries };
1121
+ }
1122
+ catch (e) {
1123
+ return { note: ` (Search-query seeding skipped — ${e.message}.)`, queries };
1124
+ }
1125
+ }
1126
+ // ---- engagement_mode: choose personal-brand vs product (setup-time) --------
1127
+ // Part of onboarding: AFTER X connect + profile_scan, BEFORE product config, the
1128
+ // agent asks the user which mode they want and calls this. It persists the mode
1129
+ // (scripts/saps_mode.py, the single source of truth the cycle reads) and
1130
+ // provisions the persona project (grounded in the profile scan), then the agent
1131
+ // continues to product setup — a product is always configured regardless of mode.
1132
+ tool("engagement_mode", {
1133
+ title: "Choose engagement lanes (personal brand + optional product promotion)",
1134
+ description: "Set or read the engagement LANES the autopilot drafts in. There are TWO independent lanes that " +
1135
+ "can BOTH be on (the cycle then splits 50/50): PERSONAL BRAND (organic, link-free engagement in " +
1136
+ "the user's own voice — ON by default) and PRODUCT PROMOTION (the marketing pipeline, link " +
1137
+ "replies — OFF by default, opt-in). This is a SETUP step: AFTER X is connected, the profile " +
1138
+ "is scanned (for VOICE), and the user has answered the DICTATION interview (for TOPICS + corpus), " +
1139
+ "personal-brand is already the default, so ASK the user the ONE question: do they " +
1140
+ "ALSO want to promote a product? Then call action:'set' with personal_brand:true and " +
1141
+ "promotion:true|false. Pass the voice/description you captured from the scan, the search_topics " +
1142
+ "you extracted PRIMARILY from the dictation, and the raw dictation transcript as content_corpus, " +
1143
+ "so the persona is grounded in who they actually are, AND expand those topics into a search_queries " +
1144
+ "array of ~30 concrete X advanced-search strings in the SAME call (identical to project_config) " +
1145
+ "so the personal-brand cycle has a real query bank on day one instead of running one crude " +
1146
+ "topic-as-query. If they want promotion too, continue to configure the product project with " +
1147
+ "project_config afterward. The user flips either lane any time from the menu-bar checkmarks.",
1148
+ inputSchema: {
1149
+ action: z
1150
+ .enum(["get", "set", "toggle"])
1151
+ .optional()
1152
+ .describe("get = read current lane flags + persona status. set = record the user's chosen lanes (provisions the persona). toggle = lightweight flip of ONE lane (pass `lane`); mode.json only, no persona work — the dashboard/menu-bar quick toggle."),
1153
+ personal_brand: z
1154
+ .boolean()
1155
+ .optional()
1156
+ .describe("action:'set' — turn the personal-brand lane on/off. Defaults to true (the out-of-the-box lane)."),
1157
+ promotion: z
1158
+ .boolean()
1159
+ .optional()
1160
+ .describe("action:'set' — turn the product-promotion lane on/off. Defaults to false; set true when the user says they also want to promote a product."),
1161
+ lane: z
1162
+ .enum(["personal_brand", "promotion"])
1163
+ .optional()
1164
+ .describe("action:'toggle' — which single lane to flip."),
1165
+ mode: z
1166
+ .enum(["personal_brand", "promotion"])
1167
+ .optional()
1168
+ .describe("LEGACY (compat). Single-lane shorthand for action:'set': turns the named lane ON and the " +
1169
+ "other OFF. Prefer the explicit personal_brand/promotion booleans."),
1170
+ description: z
1171
+ .string()
1172
+ .optional()
1173
+ .describe("Persona grounding from the scan: 2-3 sentences on who this person is as a builder/voice."),
1174
+ content_angle: z
1175
+ .string()
1176
+ .optional()
1177
+ .describe("Persona grounding: a paragraph of concrete first-hand experience the persona speaks from, synthesized from the DICTATION interview (contrarian takes, earned expertise) with the scan as backup."),
1178
+ content_corpus: z
1179
+ .string()
1180
+ .optional()
1181
+ .describe("The RAW voice-memo transcript from the onboarding dictation interview, VERBATIM (do NOT " +
1182
+ "paraphrase or summarize). Persisted to the persona_corpus.txt sidecar (never config.json), " +
1183
+ "capped ~8000 chars. This is the grounding pool the drafter quotes real specifics from " +
1184
+ "(actual projects, numbers, opinions, phrasing), so keep it dense and first-hand."),
1185
+ voice: z
1186
+ .any()
1187
+ .optional()
1188
+ .describe("Persona voice object {tone, never:[...]} captured from how they actually write (the profile scan) and calibrated by the dictation (who they like/hate reading, phrases they overuse, off-limits)."),
1189
+ search_topics: z
1190
+ .union([z.array(z.string()), z.string()])
1191
+ .optional()
1192
+ .describe("~15 topics the persona has genuine experience with. Sourced PRIMARILY from the DICTATION interview (the 'subjects you could talk about for an hour' answer), with recurring themes from the profile scan as reinforcement. This is the ONLY field that changes what gets SCANNED on X, so it must reflect what the user WANTS to be in conversations about, not just what they already posted."),
1193
+ search_queries: z
1194
+ .union([z.array(z.string()), z.string()])
1195
+ .optional()
1196
+ .describe("Cold-start X search-query bank YOU expand from search_topics, in THIS same call — same " +
1197
+ "as project_config. Fan each persona topic into a few concrete X advanced-search strings " +
1198
+ "(aim ~30 total, e.g. 'mac menu bar app -filter:replies', 'screen recording lang:en') so " +
1199
+ "the personal-brand cycle fans out instead of running one crude topic-as-query. Seeded " +
1200
+ "directly with NO `claude -p`. Without it the persona bank is empty on day one."),
1201
+ },
1202
+ }, async (args) => {
1203
+ const action = args.action || "get";
1204
+ const readFlags = async () => {
1205
+ const cur = await runPython("scripts/saps_mode.py", ["flags"], { timeoutMs: 15_000 });
1206
+ try {
1207
+ const f = JSON.parse((cur.stdout || "").trim());
1208
+ return { personal_brand: !!f.personal_brand, promotion: !!f.promotion };
1209
+ }
1210
+ catch {
1211
+ return { personal_brand: true, promotion: false };
1212
+ }
1213
+ };
1214
+ if (action === "get") {
1215
+ const flags = await readFlags();
1216
+ const persona = findPersonaProject();
1217
+ const mode = flags.personal_brand ? "personal_brand" : "promotion";
1218
+ return jsonContent({ flags, mode, persona: persona ? persona.name : null });
1219
+ }
1220
+ // Lightweight flip of ONE lane (the dashboard/menu-bar quick toggle): just
1221
+ // rewrite mode.json via saps_mode.py — NO persona provisioning. Mirrors the
1222
+ // menu bar's pure-local _toggle_lane so flipping from either surface is cheap.
1223
+ if (action === "toggle") {
1224
+ const lane = args.lane === "promotion" ? "promotion" : "personal_brand";
1225
+ const res = await runPython("scripts/saps_mode.py", ["toggle", lane], { timeoutMs: 15_000 });
1226
+ if (res.code !== 0) {
1227
+ const tail = (res.stderr || res.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
1228
+ return textContent(`Could not switch lane: ${tail}`);
1229
+ }
1230
+ try {
1231
+ return jsonContent({ flags: JSON.parse((res.stdout || "").trim()) });
1232
+ }
1233
+ catch {
1234
+ return jsonContent({ flags: await readFlags() });
1235
+ }
1236
+ }
1237
+ // action === 'set'. Resolve the two lane flags. Explicit booleans win; the
1238
+ // legacy `mode` shorthand maps to single-lane; default is personal ON.
1239
+ let personalBrand;
1240
+ let promotion;
1241
+ if (args.mode === "personal_brand" || args.mode === "promotion") {
1242
+ personalBrand = args.mode === "personal_brand";
1243
+ promotion = args.mode === "promotion";
1244
+ }
1245
+ else {
1246
+ personalBrand = args.personal_brand === undefined ? true : !!args.personal_brand;
1247
+ promotion = !!args.promotion;
1248
+ }
1249
+ if (!personalBrand && !promotion) {
1250
+ return textContent("At least one lane must be on. personal_brand is the default; set promotion:true if the user " +
1251
+ "also wants product promotion (both on -> the cycle splits 50/50).");
1252
+ }
1253
+ const mode = personalBrand ? "personal_brand" : "promotion";
1254
+ recordOnboardingAttempt("mode_chosen", { personal_brand: personalBrand, promotion });
1255
+ const setRes = await runPython("scripts/saps_mode.py", ["set-flags", personalBrand ? "1" : "0", promotion ? "1" : "0"], { timeoutMs: 15_000 });
1256
+ if (setRes.code !== 0) {
1257
+ const tail = (setRes.stderr || setRes.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
1258
+ blockOnboardingMilestone("mode_chosen", "mode_set_failed", tail, { personal_brand: personalBrand, promotion });
1259
+ return textContent(`Couldn't save the engagement lanes: ${tail}`);
1260
+ }
1261
+ // Provision the persona (grounded from the scan when supplied) regardless of
1262
+ // mode, so the toggle always has a real persona to flip to.
1263
+ let personaName;
1264
+ let personaCreated = false;
1265
+ try {
1266
+ const r = ensurePersonaProject({
1267
+ description: args.description,
1268
+ content_angle: args.content_angle,
1269
+ voice: args.voice,
1270
+ search_topics: args.search_topics,
1271
+ content_corpus: args.content_corpus,
1272
+ });
1273
+ personaName = r.name;
1274
+ personaCreated = r.created;
1275
+ }
1276
+ catch (e) {
1277
+ blockOnboardingMilestone("mode_chosen", "persona_provision_failed", e?.message || String(e), { mode });
1278
+ return textContent(`Mode saved as ${mode}, but provisioning the persona project failed: ${e?.message || e}. ` +
1279
+ `Retry engagement_mode action:'set'.`);
1280
+ }
1281
+ // Seed the persona's topics into the DB universe the cycle reads (best-effort;
1282
+ // the cycle's own fail-loud path still reports if topics are missing).
1283
+ let personaTopicsSeeded = false;
1284
+ let personaTopicCount = 0;
1285
+ const seed = await runPython("scripts/seed_search_topics.py", ["--project", personaName], {
1286
+ timeoutMs: 60_000,
1287
+ });
1288
+ if (seed.code === 0) {
1289
+ const m = /planned=(\d+)\s+inserted=(\d+)\s+updated=(\d+)/.exec(seed.stdout);
1290
+ personaTopicCount = m ? Number(m[1]) : 0;
1291
+ personaTopicsSeeded = true;
1292
+ }
1293
+ // Seed the persona's search QUERIES too — identical to the product path
1294
+ // (project_config). Personal-brand-only setups used to seed topics but never
1295
+ // queries, so their Phase 1 bank was empty and the cycle ran one crude
1296
+ // topic-as-query (Karol, 2026-06-30). Only after the topic seed succeeds.
1297
+ let personaQueryCount = 0;
1298
+ let personaQueryNote = "";
1299
+ if (personaTopicsSeeded) {
1300
+ const qr = await seedSearchQueriesForProject(personaName, args.search_queries);
1301
+ personaQueryCount = qr.queries.length;
1302
+ personaQueryNote = qr.note;
1303
+ }
1304
+ completeOnboardingMilestone("mode_chosen", { personal_brand: personalBrand, promotion, persona: personaName });
1305
+ // Personal-brand-only is a first-class setup path: the persona is the draftable
1306
+ // project, so seeding its topics IS the topics_seeded milestone. Without this the
1307
+ // product path (project_config) is the only place that completes it, leaving a
1308
+ // persona-only checklist stuck at "topics pending" even though topics are live.
1309
+ if (personalBrand && personaTopicsSeeded) {
1310
+ completeOnboardingMilestone("topics_seeded", {
1311
+ project: personaName,
1312
+ topic_count: personaTopicCount,
1313
+ persona: true,
1314
+ });
1315
+ }
1316
+ // Install/refresh the launchd kicker NOW. For a personal-brand-only setup the
1317
+ // persona is the only draftable project (no managed product), so nothing else
1318
+ // would trigger the install until a later queue-worker boot — leaving the user
1319
+ // with no autopilot and no drafts. ensureQueueKickerInstalled is persona-aware
1320
+ // (see its gate); fire it best-effort so the kicker is live the moment the
1321
+ // persona is seeded. (2026-06-30) Skipped when promotion-only, since the
1322
+ // product project isn't configured yet (it stays gated until project_config).
1323
+ let kickerInstall = null;
1324
+ if (personalBrand) {
1325
+ try {
1326
+ kickerInstall = await ensureQueueKickerInstalled();
1327
+ console.error(`[engagement_mode] launchd kicker: ${kickerInstall.ok ? "ok" : "skip"} (${kickerInstall.detail})`);
1328
+ }
1329
+ catch (e) {
1330
+ kickerInstall = { ok: false, detail: e?.message || String(e) };
1331
+ console.error("[engagement_mode] kicker install failed:", e?.message || e);
1332
+ }
1333
+ }
1334
+ const bothOn = personalBrand && promotion;
1335
+ const next_step = promotion
1336
+ ? (bothOn
1337
+ ? "Personal brand + product promotion are BOTH on (the cycle splits 50/50), and the persona " +
1338
+ "is provisioned + topic-seeded. "
1339
+ : "Product promotion is on and the persona is provisioned. ") +
1340
+ "NOW CONTINUE SETUP: configure the product project with project_config (research the product " +
1341
+ "site and fill description, icp, voice, search_topics, search_queries)."
1342
+ : (personaQueryCount > 0
1343
+ ? `Personal-brand lane is on (the default); the persona is provisioned, topic-seeded, and ${personaQueryCount} search quer${personaQueryCount === 1 ? "y" : "ies"} seeded, so there `
1344
+ : "Personal-brand lane is on (the default); the persona is provisioned + topic-seeded (but NO search_queries were supplied, so it will run one topic-as-query at a time — re-call engagement_mode action:'set' with a search_queries array of ~30 X search strings expanded from the persona topics to fan out). There ") +
1345
+ "is nothing more to configure (no product project is needed). NOW SCHEDULE THE AUTOPILOT: call " +
1346
+ "queue_setup and create each returned task with create_scheduled_task (prompt verbatim; " +
1347
+ "'already exists' is fine), then call the dashboard tool to confirm the schedule is firing. " +
1348
+ "That is the final setup step — do NOT stop before the autopilot is scheduled.";
1349
+ return jsonContent({
1350
+ ok: true,
1351
+ flags: { personal_brand: personalBrand, promotion },
1352
+ mode,
1353
+ persona: personaName,
1354
+ persona_created: personaCreated,
1355
+ persona_topics_seeded: personaTopicsSeeded,
1356
+ persona_topic_count: personaTopicCount,
1357
+ persona_query_count: personaQueryCount,
1358
+ persona_query_note: personaQueryNote || null,
1359
+ kicker_installed: kickerInstall ? kickerInstall.ok : null,
1360
+ kicker_detail: kickerInstall ? kickerInstall.detail : null,
1361
+ onboarding: onboardingSnapshot(),
1362
+ next_step,
1363
+ });
1364
+ });
1365
+ tool("project_config", {
1366
+ title: "Configure or edit a project",
1367
+ description: "The ONE tool for a project's whole lifecycle: create it, EDIT it later, and connect its X " +
1368
+ "account. There is no separate raw-config editor — every project change goes through here so " +
1369
+ "it validates, merges, and re-seeds the search-topic universe the cycle reads. To CHANGE an " +
1370
+ "existing project (its website, voice, icp, differentiator, search_topics, guardrails, CTA " +
1371
+ "link), call this with that project's `name` and ONLY the fields you want to change; it merges " +
1372
+ "onto what's already saved and never clobbers untouched fields. Run it FIRST before any " +
1373
+ "drafting or autopilot. A user's request to set up social-autoposter is a request to finish " +
1374
+ "the workflow end to end, not to interview them step by step: resume from current status, " +
1375
+ "infer discoverable fields, and keep taking safe actions until runtime, project, X connection, " +
1376
+ "topic seeding, and draft-only verification are complete.\n" +
1377
+ "Two jobs:\n" +
1378
+ "1) Configure (or edit) a project this install posts for: its website, what it does " +
1379
+ "(description), who to target (icp), and brand voice. To fill the PRODUCT fields, discover the " +
1380
+ "product URL from config, conversation context, the connected X profile, or public research, " +
1381
+ "then visit it with your own browser/fetch tools — read 5+ pages (home, pricing, features, " +
1382
+ "about, docs/blog, FAQ) to learn it deeply, rather than guessing from the name. Set up MULTIPLE " +
1383
+ "products (call once per product, identified by name); fill or edit a project's fields " +
1384
+ "INCREMENTALLY across several calls — pass whatever you have, it merges and tells you what's " +
1385
+ "still missing.\n" +
1386
+ "2) Connect X/Twitter (action:'connect_x'): the autoposter posts through its OWN managed Chrome, " +
1387
+ "which needs your logged-in x.com session. This imports x.com/twitter.com cookies from your " +
1388
+ "everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into that browser — nothing else is " +
1389
+ "touched. An explicit setup/connect request is authorization: briefly warn that macOS Safe " +
1390
+ "Storage prompts may appear, then call action:'connect_x', confirm:true immediately. Use " +
1391
+ "action:'detect_x_sources' first and choose its recommendation instead of asking the user.\n" +
1392
+ "Call with status:true (or no name) to list every configured project, its remaining fields, AND " +
1393
+ "whether X is connected. Use config, conversation context, profile_scan, and website research " +
1394
+ "before asking for fields. Ask only if no product can be identified or an interactive login is " +
1395
+ "unavoidable. The get_stats tool refuses to run until a project is " +
1396
+ "fully set up.",
1397
+ inputSchema: {
1398
+ status: z.boolean().optional(),
1399
+ action: z
1400
+ .enum(["connect_x", "detect_x_sources", "profile_scan"])
1401
+ .optional()
1402
+ .describe("connect_x = import/validate your X session in the autoposter's managed browser. " +
1403
+ "With an explicit setup/connect request, warn about possible keychain prompts and call " +
1404
+ "with confirm:true without waiting for another yes/no reply. Without confirm:true it " +
1405
+ "only previews the operation for users who asked to inspect it rather than run it. " +
1406
+ "detect_x_sources = list the browsers/profiles the X session can be imported from " +
1407
+ "(read-only, no keychain prompt) so the user can pick the right one; returns " +
1408
+ "{sources:[{spec,label,x_session}], recommended}. " +
1409
+ "profile_scan = AFTER connect_x, read the connected account's bio + recent posts + recent " +
1410
+ "replies to build a 'grounding truth' corpus. Use it to draft voice/icp/search_topics in " +
1411
+ "the USER'S OWN register (their phrases, vibe, profession), then save a conservative best " +
1412
+ "draft without requiring a confirmation round-trip. Returns {profile, posts, comments, " +
1413
+ "grounding_instructions}."),
1414
+ confirm: z
1415
+ .boolean()
1416
+ .optional()
1417
+ .describe("Set true to run the import. An explicit setup/connect request counts as authorization."),
1418
+ x_source: z
1419
+ .string()
1420
+ .optional()
1421
+ .describe("Optional browser profile to import the X session from, e.g. 'arc:Default', 'chrome:Profile 1'. " +
1422
+ "Default: auto-detect chrome/arc/brave/edge."),
1423
+ x_manual_login: z
1424
+ .boolean()
1425
+ .optional()
1426
+ .describe("Set true ONLY when the user explicitly wants to sign into X by hand. It opens a focused " +
1427
+ "X login window and waits for them to log in. By default (false), connect_x does NOT pop a " +
1428
+ "browser window on an auto-import miss; it returns needs_login and you offer manual login as " +
1429
+ "an opt-in. The login window still opens automatically if the user DENIED the keychain prompt."),
1430
+ name: z
1431
+ .string()
1432
+ .optional()
1433
+ .describe("Short machine slug for the project, e.g. 'nicia' (lowercase, no spaces). The key that identifies which project to create/update."),
1434
+ website: z.string().optional().describe("The product's website URL"),
1435
+ description: z.string().optional().describe("What the product does, 1-3 sentences"),
1436
+ icp: z
1437
+ .string()
1438
+ .optional()
1439
+ .describe("Ideal customer / target audience to engage on X"),
1440
+ voice: z.string().optional().describe("Brand voice / tone for the replies"),
1441
+ differentiator: z
1442
+ .string()
1443
+ .optional()
1444
+ .describe("What makes it different from alternatives (recommended)"),
1445
+ search_topics: z
1446
+ .union([z.array(z.string()), z.string()])
1447
+ .optional()
1448
+ .describe("Topics/keywords to monitor on X (comma-separated or array)"),
1449
+ search_queries: z
1450
+ .union([z.array(z.string()), z.string()])
1451
+ .optional()
1452
+ .describe("Cold-start X search-query bank YOU expand from search_topics, in this same call. " +
1453
+ "Fan each topic into a few concrete X advanced-search strings (aim ~30 total, e.g. " +
1454
+ "'mac menu bar app -filter:replies', 'screen recording privacy lang:en') so the cycle " +
1455
+ "fans out instead of running one crude topic-as-query. Seeded directly with NO `claude " +
1456
+ "-p` — you are the model doing the expansion, so setup never needs the claude CLI."),
1457
+ get_started_link: z
1458
+ .string()
1459
+ .optional()
1460
+ .describe("Primary call-to-action link (signup / get started)"),
1461
+ content_guardrails: z
1462
+ .string()
1463
+ .optional()
1464
+ .describe("Anything the posts must avoid saying / claiming"),
1465
+ fields: z
1466
+ .record(z.string(), z.any())
1467
+ .optional()
1468
+ .describe("Escape hatch to edit ANY other project field the named props above don't cover — e.g. " +
1469
+ "weight, platform, voice_relationship, booking_link, qualification, subreddit_bans, " +
1470
+ "short_links_host, short_links_live, content_angle, messaging, landing_pages, posthog. " +
1471
+ "Pass {name:'<project>', fields:{<key>:<value>, ...}}; each key SHALLOW-merges onto the " +
1472
+ "project, REPLACING that key's whole value (read the current value via status:true first if " +
1473
+ "you only want to tweak part of a nested object, then pass the full new value). A value of " +
1474
+ "null DELETES the key. 'name' is ignored here (can't rename through this path). This is how " +
1475
+ "you edit advanced config without any raw whole-file overwrite."),
1476
+ },
1477
+ }, async (args) => {
1478
+ // ---- List import sources (for the panel dropdown) ---------------------
1479
+ // Read-only browser/profile detection. Never reads the keychain or decrypts
1480
+ // a cookie, so it shows no macOS Safe Storage prompt. Lets the user pick the
1481
+ // exact browser+profile that holds their X session.
1482
+ if (args.action === "detect_x_sources") {
1483
+ const r = await xDetectSources();
1484
+ return jsonContent({
1485
+ action: "detect_x_sources",
1486
+ ok: r.ok,
1487
+ sources: r.sources,
1488
+ recommended: r.recommended,
1489
+ error: r.error,
1490
+ });
1491
+ }
1492
+ // ---- Connect X/Twitter: import the user's session into our browser ----
1493
+ // Preview-or-run: a call without confirm describes the operation. During an
1494
+ // explicit end-to-end setup request the agent gives a short keychain heads-up
1495
+ // and calls confirm:true immediately; no extra yes/no round-trip is needed.
1496
+ if (args.action === "connect_x") {
1497
+ if (args.confirm !== true) {
1498
+ // Cheap probe so the explanation reflects current state (no Chrome launch).
1499
+ const cur = await xStatus();
1500
+ if (cur.connected) {
1501
+ return jsonContent({
1502
+ action: "connect_x",
1503
+ already_connected: true,
1504
+ state: cur.state,
1505
+ note: "X is already connected in the autoposter's browser. Nothing to import.",
1506
+ });
1507
+ }
1508
+ return jsonContent({
1509
+ action: "connect_x",
1510
+ requires_confirmation: true,
1511
+ current_state: cur.state,
1512
+ what_will_happen: "To post for you, the autoposter uses its OWN managed Google Chrome (separate from your " +
1513
+ "everyday browser). It needs your logged-in X/Twitter session. If you confirm, it will: " +
1514
+ "(1) start that managed Chrome if it isn't running, (2) copy ONLY your x.com and twitter.com " +
1515
+ "cookies from your everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into it, and " +
1516
+ "(3) verify you're logged in. No other site's cookies are read, and your passwords are never " +
1517
+ "seen. If it can't import a valid session, a Chrome window will open for you to sign in once.",
1518
+ keychain_prompt: "Reading the saved session requires macOS to unlock the browser's encrypted cookie store, so " +
1519
+ "one or more keychain prompts will appear (\u201c... wants to use your confidential information " +
1520
+ "stored in '... Safe Storage' in your keychain\u201d). This is expected. The user enters their Mac " +
1521
+ "login password and clicks Allow (or Always Allow to avoid repeats). If they use more than one " +
1522
+ "browser, the prompt can appear a few times, once per browser.",
1523
+ say_to_user: "Heads up: your Mac will pop up a keychain prompt asking to use your browser's Safe Storage. " +
1524
+ "That's just us reading your saved X login, nothing else. Type your Mac login password and click " +
1525
+ "Allow (or Always Allow). If you use more than one browser you may see it a couple of times, " +
1526
+ "once per browser.",
1527
+ how_to_proceed: "If the user explicitly requested setup or connection, relay the say_to_user line as a brief " +
1528
+ "heads-up and immediately call project_config again with action:'connect_x', confirm:true; do not wait " +
1529
+ "for another yes/no reply. Optionally pass the recommended x_source. If the user only asked " +
1530
+ "what connection would do, stop after this preview.",
1531
+ });
1532
+ }
1533
+ recordOnboardingAttempt("x_connected", {
1534
+ state: args.x_source ? "source_selected" : "auto_detect",
1535
+ });
1536
+ const r = await xConnect(args.x_source, args.x_manual_login);
1537
+ let doctorReport = null;
1538
+ if (r.connected) {
1539
+ completeOnboardingMilestone("x_connected", { state: r.state });
1540
+ // The pre-connect Doctor intentionally treats missing X/cookie artifacts
1541
+ // as expected. Once connect_x succeeds, run the full phase immediately
1542
+ // to verify persistence, CDP, and the durable cookie mirror.
1543
+ doctorReport = await runDoctorPhase("full");
1544
+ }
1545
+ else {
1546
+ blockOnboardingMilestone("x_connected", `x_${r.state || "not_connected"}`, r.error || r.note || summarizeXAuth(r), { state: r.state || "not_connected" });
1547
+ }
1548
+ return jsonContent({
1549
+ action: "connect_x",
1550
+ connected: r.connected,
1551
+ state: r.state,
1552
+ source: r.source,
1553
+ summary: summarizeXAuth(r),
1554
+ note: r.note,
1555
+ attempts: r.attempts,
1556
+ doctor: doctorReport
1557
+ ? {
1558
+ phase: doctorReport.phase,
1559
+ ok: doctorReport.ok,
1560
+ summary: doctorReport.summary,
1561
+ }
1562
+ : undefined,
1563
+ onboarding: onboardingSnapshot(),
1564
+ next_step: r.connected
1565
+ ? "X is connected. Next, run project_config action:'profile_scan' to read this account's bio + recent " +
1566
+ "posts + replies and draft the project's voice/icp/search_topics in the user's own register " +
1567
+ "before saving. Then set up the autopilot (queue_setup + create_scheduled_task) once the project is fully set up; it then drafts on its own."
1568
+ : r.state === "needs_login"
1569
+ ? "The user must finish signing in to x.com in the Chrome window that just opened. Tell " +
1570
+ "them that single required action, then call project_config action:'connect_x', confirm:true again."
1571
+ : "X is not connected yet. " + summarizeXAuth(r),
1572
+ });
1573
+ }
1574
+ // ---- Profile scan: grounding-truth corpus from the connected account ----
1575
+ // Reuses the authenticated managed-Chrome session (so it must run AFTER a
1576
+ // successful connect_x) to read the user's bio + recent posts + recent
1577
+ // replies. Returns the raw corpus plus grounding_instructions; synthesis of
1578
+ // voice/icp/topics happens IN THIS CONVERSATION (no nested model), then the
1579
+ // agent confirms with the user and calls project_config to persist. Read-only.
1580
+ if (args.action === "profile_scan") {
1581
+ // Handle is auto-detected from the live logged-in session by the scanner.
1582
+ recordOnboardingAttempt("profile_scanned");
1583
+ const scan = await xScanProfile();
1584
+ if (!scan.ok) {
1585
+ const hint = scan.state === "browser_not_running" || scan.state === "no_handle"
1586
+ ? " Run project_config action:'connect_x' (confirm:true) first so the account is connected, then retry profile_scan."
1587
+ : "";
1588
+ blockOnboardingMilestone("profile_scanned", `profile_${scan.state || "failed"}`, scan.error || "profile scan failed", { state: scan.state || "failed" });
1589
+ return jsonContent({
1590
+ action: "profile_scan",
1591
+ ok: false,
1592
+ state: scan.state,
1593
+ error: (scan.error || "profile scan failed") + hint,
1594
+ onboarding: onboardingSnapshot(),
1595
+ });
1596
+ }
1597
+ completeOnboardingMilestone("profile_scanned", {
1598
+ state: scan.state,
1599
+ });
1600
+ return jsonContent({
1601
+ action: "profile_scan",
1602
+ ok: true,
1603
+ handle: scan.handle,
1604
+ profile: scan.profile,
1605
+ counts: scan.counts,
1606
+ posts: scan.posts,
1607
+ comments: scan.comments,
1608
+ grounding_instructions: scan.grounding_instructions,
1609
+ website_research_instructions: WEBSITE_RESEARCH_INSTRUCTIONS,
1610
+ onboarding: onboardingSnapshot(),
1611
+ next_step: "FOUR steps, in order. FIRST (VOICE, from this scan): read the bio, posts, and comments " +
1612
+ "as GROUND TRUTH and, per grounding_instructions, extract their profession/identity, " +
1613
+ "voice & vibe (tone, phrasing, casing, tics), 2-4 verbatim golden-rule example replies, " +
1614
+ "a phrase bank + things they avoid, and their icp. The scan is BACKWARD-LOOKING (only what " +
1615
+ "they already posted) so it is the source for VOICE, not the primary source for topics. " +
1616
+ "SECOND (the DICTATION interview — this is where TOPICS + grounding corpus come from, do NOT " +
1617
+ "skip it and do NOT infer topics from the scan alone): tell the user to answer ALL of the " +
1618
+ "following in ONE spoken dictation (the Claude input box already supports dictation, so they " +
1619
+ "just talk once and you split the answers into fields). Ask verbatim, as a single numbered " +
1620
+ "list:\n" +
1621
+ " 1. Who are you, and what do you want to be known for? (-> description)\n" +
1622
+ " 2. What subjects could you talk about for an hour, work and non-work? (-> search_topics: " +
1623
+ "this is the LOAD-BEARING answer, it is the ONLY thing that decides what gets scanned on X, " +
1624
+ "so it must capture what they WANT to be in conversations about)\n" +
1625
+ " 3. Your most contrarian takes — what does everyone in your field get wrong, and what did " +
1626
+ "you used to believe that you have reversed on? (-> content_angle + corpus)\n" +
1627
+ " 4. What can you explain in 5 minutes that took you years, and what mistake do you watch " +
1628
+ "beginners make over and over? (-> content_angle + corpus)\n" +
1629
+ " 5. Best or worst thing that happened to you recently, and a failure you learned the most " +
1630
+ "from? (-> corpus, keeps drafts current)\n" +
1631
+ " 6. Who do you love or hate reading online, and any lines or phrases you say a lot? " +
1632
+ "(-> voice calibration)\n" +
1633
+ " 7. Anything off-limits (topics, companies, people), and how spicy can we get — safe, " +
1634
+ "opinionated, or provocative? (-> content_guardrails + voice.never)\n" +
1635
+ "Then SYNTHESIZE the fields from their dictation: search_topics comes PRIMARILY from answer 2 " +
1636
+ "(fold in recurring scan themes only as reinforcement); description/content_angle/voice from " +
1637
+ "the rest. Keep their RAW transcript VERBATIM as content_corpus (do NOT paraphrase; their " +
1638
+ "actual numbers, opinions, and phrasing are what make drafts sound like them). If the user " +
1639
+ "declines or gives nothing usable, fall back to scan-derived topics. " +
1640
+ "THIRD (engagement lanes — ASK THE USER, do not infer): the PERSONAL BRAND lane (organic, " +
1641
+ "link-free engagement in their own voice) is ON by default, so ask the ONE question — do they " +
1642
+ "ALSO want to PROMOTE a PRODUCT (the marketing lane, link replies)? Both lanes can run (the " +
1643
+ "cycle splits 50/50). Call the `engagement_mode` tool action:'set' with personal_brand:true, " +
1644
+ "promotion:true|false AND the voice/description/search_topics you synthesized PLUS the raw " +
1645
+ "dictation transcript as content_corpus (this provisions the persona and seeds topics). Only " +
1646
+ "NOW are topics seeded — postponed until the dictation is in. " +
1647
+ "FOURTH (product, ONLY if they wanted promotion): follow " +
1648
+ "website_research_instructions — discover the product URL from config, context, profile " +
1649
+ "links/posts, or public research and read 5+ of its pages to fill description, " +
1650
+ "differentiator, icp, get_started_link, and content_guardrails, written in the voice you " +
1651
+ "just captured. Save the best conservative supported fields without a confirmation " +
1652
+ "round-trip. Ask only if no product can be identified or a required field is unknowable. If " +
1653
+ "they only want personal brand, SKIP the product step.",
1654
+ });
1655
+ }
1656
+ // Status / discovery mode: no project name supplied, or explicitly asked.
1657
+ if (args.status === true || !args.name) {
1658
+ const projects = listManagedProjectStatus();
1659
+ const rtReady = runtimeReady();
1660
+ // On a bare .mcpb install the runtime step also materializes the pipeline
1661
+ // source that xStatus shells into. Status must still work before that first
1662
+ // install, otherwise the agent cannot discover that installation is the
1663
+ // next milestone. Avoid probing Python until the owned runtime is ready.
1664
+ const x = rtReady
1665
+ ? await xStatus().catch(() => ({ connected: false, state: "status_unavailable" }))
1666
+ : { connected: false, state: "runtime_not_ready" };
1667
+ await ensureDoctorPhase(x.connected ? "full" : "pre_connect");
1668
+ const ver = await versionStatus();
1669
+ const configured = projects.some((p) => p.ready);
1670
+ if (rtReady)
1671
+ completeOnboardingMilestone("runtime_ready");
1672
+ if (x.connected) {
1673
+ completeOnboardingMilestone("x_connected", { state: x.state || "connected" });
1674
+ }
1675
+ if (configured) {
1676
+ completeOnboardingMilestone("project_ready", {
1677
+ missing_count: 0,
1678
+ });
1679
+ }
1680
+ // mode_chosen completes when the user explicitly picked a mode (mode.json
1681
+ // exists) OR this is a legacy install already past setup (a ready product),
1682
+ // so adding this step never regresses an already-onboarded box.
1683
+ if (modeChosen() || configured) {
1684
+ completeOnboardingMilestone("mode_chosen", {
1685
+ source: modeChosen() ? "chosen" : "backfilled_legacy",
1686
+ });
1687
+ }
1688
+ return jsonContent({
1689
+ configured,
1690
+ projects,
1691
+ runtime_ready: rtReady,
1692
+ x_connected: x.connected,
1693
+ x_state: x.state,
1694
+ x_handle: x.handle ?? null,
1695
+ mcp_version: ver.installed,
1696
+ latest_version: ver.latest,
1697
+ update_available: ver.update_available,
1698
+ mode: currentMode(),
1699
+ flags: currentFlags(),
1700
+ update_hint: ver.update_available
1701
+ ? `A newer version (${ver.latest}) is available — you're on ${ver.installed}. ` +
1702
+ `Tell the user and offer to run the \`runtime\` tool with action:'update' ` +
1703
+ `(or \`npx social-autoposter@latest update\`).`
1704
+ : undefined,
1705
+ required_fields: REQUIRED_FIELDS,
1706
+ recommended_fields: RECOMMENDED_FIELDS,
1707
+ config_path: configPath(),
1708
+ ready_for_verification: rtReady && configured && x.connected,
1709
+ onboarding: onboardingSnapshot(),
1710
+ next_step: !rtReady
1711
+ ? "Runtime is not ready yet. It provisions automatically on boot — poll runtime action:'status' until ready (only call runtime action:'install' to retry if status shows the boot provision failed or stalled), then continue setup automatically."
1712
+ : projects.length === 0
1713
+ ? "No projects yet. Discover the product from conversation context and the connected X profile; research its website, infer a conservative complete project, and call project_config. Ask only if no product can be identified." +
1714
+ (x.connected ? "" : " X is not connected yet either — detect_x_sources, warn about keychain prompts, then run connect_x with confirm:true without a separate permission turn.")
1715
+ : projects.every((p) => p.ready)
1716
+ ? (x.connected
1717
+ ? "All configured projects are ready and X is connected. SCHEDULE THE AUTOPILOT: (1) call queue_setup and create each returned task with create_scheduled_task (prompt verbatim; 'already exists' is fine); (2) the autopilot then runs on its own (launchd kicker + queue worker). Call the `dashboard` tool to confirm the schedule is firing (schedule_state 'ok') — that is the terminal step; do NOT wait for or verify a draft card. Do NOT pause to ask the user to review drafts."
1718
+ : "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
1719
+ "x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
1720
+ : "Some projects are missing required fields (see each project's missing_required). Derive them from config, context, profile_scan, and website research, then call project_config again. Ask only if a required field is genuinely unknowable." +
1721
+ (x.connected ? "" : " X is also not connected yet; detect sources and run connect_x with confirm:true."),
1722
+ });
1723
+ }
1724
+ // Apply mode (incremental): merge whatever fields were supplied onto the
1725
+ // named project, then report whether it's now ready or still missing fields.
1726
+ try {
1727
+ recordOnboardingAttempt("project_ready", {
1728
+ missing_count: 0,
1729
+ });
1730
+ const result = applySetup(args);
1731
+ if (result.ready) {
1732
+ completeOnboardingMilestone("project_ready", { missing_count: 0 });
1733
+ }
1734
+ else {
1735
+ blockOnboardingMilestone("project_ready", "missing_required_fields", `Project '${result.project}' still needs: ${result.missing_required.join(", ")}`, { missing_count: result.missing_required.length });
1736
+ }
1737
+ // Seed this project's search_topics into the DB universe the cycle reads
1738
+ // (project_search_topics). Without this a freshly-configured project has
1739
+ // topics in config.json but ZERO rows in the DB, so draft_cycle's topic
1740
+ // picker raises and the cycle silently returns nothing. Best-effort: a
1741
+ // seed hiccup never fails setup — the cycle's fail-loud path still tells
1742
+ // the user if topics are missing. Only runs once the project is ready
1743
+ // (i.e. it actually has search_topics to seed). (2026-06-02)
1744
+ let seedNote = "";
1745
+ let topicsSeeded = false;
1746
+ let topicCount = 0;
1747
+ let searchQueries = [];
1748
+ if (result.ready) {
1749
+ recordOnboardingAttempt("topics_seeded");
1750
+ const seed = await runPython("scripts/seed_search_topics.py", ["--project", result.project], { timeoutMs: 60_000 });
1751
+ if (seed.code === 0) {
1752
+ const m = /planned=(\d+)\s+inserted=(\d+)\s+updated=(\d+)/.exec(seed.stdout);
1753
+ topicCount = m ? Number(m[1]) : 0;
1754
+ topicsSeeded = true;
1755
+ completeOnboardingMilestone("topics_seeded", {
1756
+ topic_count: topicCount,
1757
+ });
1758
+ seedNote = m
1759
+ ? ` Seeded ${m[1]} search topic(s) into the DB (new: ${m[2]}, updated: ${m[3]}), so the draft cycle has a topic universe to work with.`
1760
+ : " Seeded search topics into the DB so the draft cycle has a topic universe to work with.";
1761
+ }
1762
+ else {
1763
+ const tail = (seed.stderr || seed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
1764
+ blockOnboardingMilestone("topics_seeded", "topic_seed_failed", tail, { exit_code: seed.code });
1765
+ seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}. The autopilot will report clearly if topics are missing.)`;
1766
+ }
1767
+ // Cold-start QUERY supply (shared with the persona/engagement_mode path
1768
+ // via seedSearchQueriesForProject): fan the agent-supplied search_queries
1769
+ // into project_search_queries so the Phase 1 bank fans out on day one
1770
+ // instead of running ONE crude topic-as-query. Only after the topic seed
1771
+ // succeeds, matching the persona path's guard.
1772
+ if (seed.code === 0) {
1773
+ const qr = await seedSearchQueriesForProject(result.project, args.search_queries);
1774
+ seedNote += qr.note;
1775
+ searchQueries = qr.queries;
1776
+ }
1777
+ }
1778
+ // Install/refresh the launchd kicker NOW, the moment a product project is
1779
+ // ready — identical to the persona path (engagement_mode). Before this, a
1780
+ // promotion-only setup never installed the kicker at setup time (the persona
1781
+ // path explicitly skips promotion-only, and project_config didn't pick it
1782
+ // up), so drafting didn't start until a later Claude/queue-worker boot ran
1783
+ // the boot-time install. ensureQueueKickerInstalled is idempotent + product/
1784
+ // persona-aware, so calling it from both setup paths is safe. Best-effort:
1785
+ // a kicker hiccup never fails setup. (2026-06-30)
1786
+ let kickerInstall = null;
1787
+ if (result.ready) {
1788
+ try {
1789
+ kickerInstall = await ensureQueueKickerInstalled();
1790
+ console.error(`[project_config] launchd kicker: ${kickerInstall.ok ? "ok" : "skip"} (${kickerInstall.detail})`);
1791
+ }
1792
+ catch (e) {
1793
+ kickerInstall = { ok: false, detail: e?.message || String(e) };
1794
+ console.error("[project_config] kicker install failed:", e?.message || e);
1795
+ }
1796
+ }
1797
+ // Surface any advanced (escape-hatch) field edits in the note so the
1798
+ // agent can confirm exactly what changed to the user.
1799
+ let advancedNote = "";
1800
+ if (result.fields_set.length || result.fields_removed.length) {
1801
+ const parts = [];
1802
+ if (result.fields_set.length)
1803
+ parts.push(`set ${result.fields_set.join(", ")}`);
1804
+ if (result.fields_removed.length)
1805
+ parts.push(`removed ${result.fields_removed.join(", ")}`);
1806
+ advancedNote = ` Advanced fields updated: ${parts.join("; ")}.`;
1807
+ }
1808
+ return jsonContent({
1809
+ ok: true,
1810
+ project: result.project,
1811
+ action: result.created ? "created" : "updated",
1812
+ ready: result.ready,
1813
+ missing_required: result.missing_required,
1814
+ topics_seeded: topicsSeeded,
1815
+ topic_count: topicCount,
1816
+ search_queries: searchQueries,
1817
+ kicker_installed: kickerInstall ? kickerInstall.ok : null,
1818
+ kicker_detail: kickerInstall ? kickerInstall.detail : null,
1819
+ fields_set: result.fields_set,
1820
+ fields_removed: result.fields_removed,
1821
+ config_path: configPath(),
1822
+ onboarding: onboardingSnapshot(),
1823
+ note: (result.ready
1824
+ ? `Project '${result.project}' is fully configured.${seedNote} Next: if X is not connected, ` +
1825
+ `detect sources, warn about keychain prompts, and call project_config with ` +
1826
+ `action:'connect_x', confirm:true immediately. Once X is connected, schedule the autopilot ` +
1827
+ `(queue_setup + create_scheduled_task per task); the autopilot then drafts on its own. Call the ` +
1828
+ `dashboard to confirm the schedule is firing (schedule_state 'ok') — that is the final step, ` +
1829
+ `no need to wait for or verify a draft card.`
1830
+ : `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
1831
+ `First derive those fields from existing context, profile_scan, and website research, then ` +
1832
+ `call project_config again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`) +
1833
+ advancedNote,
1834
+ });
1835
+ }
1836
+ catch (e) {
1837
+ return textContent(`Setup failed: ${e.message}`);
1838
+ }
1839
+ });
1840
+ // ---- post_drafts: post the user's chosen drafts from a batch ---------------
1841
+ // Second half of the manual loop. The user reviewed the menu-bar cards a draft
1842
+ // cycle produced and said which numbers to post / edit; this posts exactly those.
1843
+ // Editing a draft implies posting it. Indices are 1-based, matching the table.
1844
+ tool("post_drafts", {
1845
+ title: "Post chosen drafts",
1846
+ description: "Post the drafts the user approved from a draft cycle. Pass the batch_id from the " +
1847
+ "approval cards and the user's decision by NUMBER (1-based, matching the table): `post` is " +
1848
+ "the list of draft numbers to post as drafted; `edits` rewrites a draft's text before " +
1849
+ "posting it (editing implies posting); `post_all` posts every draft. Only the chosen " +
1850
+ "drafts post; anything not listed is left unposted. Call this ONLY after the user has " +
1851
+ "told you which drafts they want. After posting, call the `dashboard` tool so the user " +
1852
+ "sees the updated state.",
1853
+ inputSchema: {
1854
+ batch_id: z.string().describe("The batch_id of the draft batch (from the approval cards)."),
1855
+ post: z
1856
+ .array(z.number().int().positive())
1857
+ .optional()
1858
+ .describe("1-based draft numbers to post as drafted, e.g. [1, 3, 5]."),
1859
+ edits: z
1860
+ .array(z.object({ n: z.number().int().positive(), text: z.string() }))
1861
+ .optional()
1862
+ .describe("Rewrites: each {n, text} replaces draft n's wording, then posts it."),
1863
+ post_all: z.boolean().optional().describe("Post every draft in the batch."),
1864
+ reject: z
1865
+ .array(z.number().int().positive())
1866
+ .optional()
1867
+ .describe("1-based draft numbers the user REJECTED. They are marked done and never " +
1868
+ "shown for review again, and are not posted."),
1869
+ clear_link: z
1870
+ .array(z.number().int().positive())
1871
+ .optional()
1872
+ .describe("1-based draft numbers whose link the user removed while editing. Their " +
1873
+ "link_url is cleared so the poster does not silently re-append it."),
1874
+ },
1875
+ }, async ({ batch_id, post, edits, post_all, reject, clear_link }) => {
1876
+ const plan = readPlan(batch_id);
1877
+ if (!plan || !(plan.candidates && plan.candidates.length)) {
1878
+ return textContent(`No drafts found for batch ${batch_id}. The autopilot produces a fresh batch on its next scheduled cycle.`);
1879
+ }
1880
+ const candidates = plan.candidates;
1881
+ const total = candidates.length;
1882
+ const warnings = [];
1883
+ const inRange = (n) => n >= 1 && n <= total;
1884
+ // ---- Rejections: durable + final --------------------------------------
1885
+ // A rejected draft is marked terminal so it NEVER re-appears for review and is
1886
+ // never posted. A reject overrides any earlier approve on the same card.
1887
+ const rejected = [];
1888
+ (reject || []).forEach((n) => {
1889
+ if (!inRange(n)) {
1890
+ warnings.push(`ignored reject #${n}: out of range (1-${total})`);
1891
+ return;
1892
+ }
1893
+ const c = candidates[n - 1];
1894
+ if (c.posted === true) {
1895
+ warnings.push(`#${n} already posted; not rejecting`);
1896
+ return;
1897
+ }
1898
+ c.terminal = true;
1899
+ c.terminal_reason = "rejected";
1900
+ c.approved = false;
1901
+ rejected.push(n);
1902
+ });
1903
+ // Apply edits first; an edited draft is always posted.
1904
+ const approve = new Set();
1905
+ let editedCount = 0;
1906
+ (edits || []).forEach((e) => {
1907
+ if (!inRange(e.n)) {
1908
+ warnings.push(`ignored edit for #${e.n}: out of range (1-${total})`);
1909
+ return;
1910
+ }
1911
+ const text = (e.text ?? "").trim();
1912
+ if (!text) {
1913
+ warnings.push(`ignored empty edit for #${e.n}`);
1914
+ return;
1915
+ }
1916
+ candidates[e.n - 1].reply_text = text;
1917
+ approve.add(e.n);
1918
+ editedCount++;
1919
+ });
1920
+ // Honor "user deleted the link while editing": clear the link fields so the
1921
+ // poster (which runs with forced TWITTER_TAIL_LINK_RATE=1.0 on this path)
1922
+ // does NOT silently re-append a link the user intentionally removed. Without
1923
+ // this, link_url survives on the candidate row and the poster revives it.
1924
+ (clear_link || []).forEach((n) => {
1925
+ if (!inRange(n)) {
1926
+ warnings.push(`ignored clear_link #${n}: out of range (1-${total})`);
1927
+ return;
1928
+ }
1929
+ const c = candidates[n - 1];
1930
+ c.link_url = undefined;
1931
+ c.link_keyword = undefined;
1932
+ c.link_slug = undefined;
1933
+ });
1934
+ if (post_all) {
1935
+ for (let i = 1; i <= total; i++)
1936
+ approve.add(i);
1937
+ }
1938
+ (post || []).forEach((n) => {
1939
+ if (inRange(n))
1940
+ approve.add(n);
1941
+ else
1942
+ warnings.push(`ignored #${n}: out of range (1-${total})`);
1943
+ });
1944
+ // Cross-surface de-dup: chat and the menu-bar pop-ups can both approve, so
1945
+ // never re-post a candidate the other surface already posted OR ruled out.
1946
+ const alreadyDone = [];
1947
+ for (const n of Array.from(approve)) {
1948
+ if (candidates[n - 1]?.posted === true || candidates[n - 1]?.terminal === true) {
1949
+ approve.delete(n);
1950
+ alreadyDone.push(n);
1951
+ }
1952
+ }
1953
+ if (alreadyDone.length) {
1954
+ warnings.push(`already posted/decided (skipped): ${alreadyDone.sort((a, b) => a - b).join(", ")}`);
1955
+ }
1956
+ // STICKY approve: record the approval DURABLY and never clear another card's
1957
+ // prior approval. The old `c.approved = approve.has(i+1)` reset every card on
1958
+ // each call, so a later post_drafts for a different card dropped a
1959
+ // restart-interrupted approved card back into "pending". postApproved filters
1960
+ // posted/terminal, so the approved set only ever drains what's genuinely left.
1961
+ approve.forEach((n) => {
1962
+ const c = candidates[n - 1];
1963
+ if (c)
1964
+ c.approved = true;
1965
+ });
1966
+ writePlan(batch_id, plan);
1967
+ if (approve.size === 0) {
1968
+ return jsonContent({
1969
+ batch_id,
1970
+ drafted: total,
1971
+ posted: 0,
1972
+ rejected: rejected.length,
1973
+ skipped: total,
1974
+ edited: editedCount,
1975
+ note: rejected.length
1976
+ ? `Rejected ${rejected.length} draft(s); they won't be shown for review again. Nothing was posted.`
1977
+ : "No drafts selected to post. Nothing was posted.",
1978
+ warnings,
1979
+ });
1980
+ }
1981
+ const result = await postApproved(batch_id, plan);
1982
+ // Report the REAL posted count from the pipeline, not the approved count.
1983
+ // A run can approve N yet land 0 (browser/session failure); reporting
1984
+ // approve.size here told the agent "posted: N" on a total failure.
1985
+ const actuallyPosted = typeof result.posted === "number" ? result.posted : approve.size;
1986
+ if (actuallyPosted < approve.size) {
1987
+ warnings.push(`only ${actuallyPosted}/${approve.size} actually posted (exit=${result.exit_code}); ` +
1988
+ `see result.summary / result.stderr_tail for the reason`);
1989
+ }
1990
+ return jsonContent({
1991
+ batch_id,
1992
+ drafted: total,
1993
+ posted: actuallyPosted,
1994
+ approved: approve.size,
1995
+ rejected: rejected.length,
1996
+ skipped: total - actuallyPosted,
1997
+ edited: editedCount,
1998
+ result,
1999
+ warnings,
2000
+ });
2001
+ });
2002
+ // ---- autopilot: MCP tool removed ------------------------------------------
2003
+ // The `autopilot` MCP tool (enable/disable/status) was intentionally removed:
2004
+ // hands-free background posting is no longer toggled from the agent/tool surface.
2005
+ // The underlying launchd cycle job + plist (com.m13v.social-twitter-cycle) and
2006
+ // the daily self-updater are NOT touched here — an already-loaded job keeps
2007
+ // running, and the plist files stay on disk. The plist helpers above
2008
+ // (ensurePlist / plistXml / loadPlist / unloadPlist) and the constants are kept
2009
+ // as the underlying source for that job; the `dashboard` snapshot still reports
2010
+ // the job's loaded state via autopilotLoaded(). To enable/disable the job now,
2011
+ // use launchctl directly or re-add a tool here.
2012
+ // ---- get_stats: read-only -------------------------------------------------
2013
+ tool("get_stats", {
2014
+ title: "Get X/Twitter stats",
2015
+ description: "Read-only post + engagement stats for the X/Twitter rail over the last N days. " +
2016
+ "Wraps project_stats_json.py. Use to show the user how their posts are performing. " +
2017
+ "After returning the numbers, call the `dashboard` tool so the user sees them rendered.",
2018
+ inputSchema: {
2019
+ days: z.number().int().min(1).max(90).default(7),
2020
+ project: z
2021
+ .string()
2022
+ .optional()
2023
+ .describe("Which configured project to report on. Optional when only one project is set up; required when several are."),
2024
+ },
2025
+ }, async ({ days, project }) => {
2026
+ const r = resolveProject(project);
2027
+ if (!r.ok)
2028
+ return textContent(r.message);
2029
+ const proj = r.project;
2030
+ const args = ["--posts-only", "--platform", "twitter", "--days", String(days)];
2031
+ if (proj)
2032
+ args.push("--project", proj);
2033
+ const res = await runPython("scripts/project_stats_json.py", args, { timeoutMs: 120_000 });
2034
+ if (res.code !== 0) {
2035
+ return textContent(`stats failed (exit ${res.code}):\n${res.stderr || res.stdout}`);
2036
+ }
2037
+ try {
2038
+ return jsonContent(JSON.parse(res.stdout));
2039
+ }
2040
+ catch {
2041
+ return textContent(res.stdout);
2042
+ }
2043
+ });
2044
+ // ---- version: report installed version + deliver updates on demand ---------
2045
+ // ---- runtime: install + version/update + diagnostics ----------------------
2046
+ // ONE plumbing tool for the whole local-runtime lifecycle, action-based like
2047
+ // project_config and autopilot. The pipeline runs Python locally; rather than
2048
+ // depend on the user's system Python (the #1 source of install failures), the
2049
+ // first run provisions a fully OWNED uv runtime: standalone CPython + owned venv
2050
+ // + deps + Chromium. It also reports/installs new releases and runs the Doctor.
2051
+ // Plain (non-UI) so EVERY host can drive it — the panel's Install card and
2052
+ // Update button are just skins that call action:'install' then poll
2053
+ // action:'status'. See runtime.ts for the provisioning + progress contract.
2054
+ //
2055
+ // Actions:
2056
+ // status (default) — is the owned runtime installed? + in-progress step detail
2057
+ // install — start provisioning in the background; poll status to follow
2058
+ // version — installed vs latest published, whether an update is available
2059
+ // update — pull + install the latest release (npx social-autoposter@latest update)
2060
+ // doctor — run structured environment diagnostics (phase: pre_connect|full)
2061
+ // doctor_status — last persisted Doctor result without re-running checks
2062
+ tool("runtime", {
2063
+ title: "Runtime: status, update & diagnostics",
2064
+ description: "The ONE plumbing tool for the autoposter's local runtime lifecycle. The runtime PROVISIONS " +
2065
+ "ITSELF automatically when the server boots, so you normally never call action:'install' — just " +
2066
+ "poll action:'status'. action:'status' (default) " +
2067
+ "reports whether the self-contained Python/Chromium runtime is installed and, mid-install, the " +
2068
+ "per-step progress (uv, Python, venv, dependencies, Chromium) — poll it to watch boot " +
2069
+ "provisioning finish. action:'install' is a TROUBLESHOOTING retry that re-provisions that runtime " +
2070
+ "(a private Python via uv, NOT your system Python, plus " +
2071
+ "deps and Chromium); it runs in the background, returns immediately, is safe to call " +
2072
+ "repeatedly, and is a no-op once installed — only reach for it if status shows the boot provision " +
2073
+ "failed or stalled. action:'version' shows installed vs latest published " +
2074
+ "and whether an update is available; action:'update' pulls and installs the latest release (runs " +
2075
+ "`npx social-autoposter@latest update`, taking effect after the client reconnects/restarts). " +
2076
+ "action:'doctor' runs structured environment diagnostics (phase:'pre_connect' is safe at " +
2077
+ "onboarding start and treats the missing X session/cookies as expected; phase:'full' verifies the " +
2078
+ "completed environment after X is connected); action:'doctor_status' returns the last persisted " +
2079
+ "Doctor result without re-running. Use action:'status' to confirm readiness during setup; reach " +
2080
+ "for action:'install'/'doctor' only when status or another tool reports the runtime isn't ready " +
2081
+ "or to diagnose a broken environment; use action:'version'/'update' for version checks.",
2082
+ inputSchema: {
2083
+ action: z
2084
+ .enum(["status", "install", "version", "update", "doctor", "doctor_status"])
2085
+ .optional(),
2086
+ phase: z
2087
+ .enum(["pre_connect", "full"])
2088
+ .optional()
2089
+ .describe("Only for action:'doctor' — which diagnostic phase to run (default pre_connect)."),
2090
+ },
2091
+ }, async ({ action, phase }) => {
2092
+ // ---- install: start provisioning the owned runtime --------------------
2093
+ if (action === "install") {
2094
+ if (runtimeReady()) {
2095
+ completeOnboardingMilestone("runtime_ready");
2096
+ return jsonContent({ already_installed: true, ...runtimeSnapshot() });
2097
+ }
2098
+ recordOnboardingAttempt("runtime_ready");
2099
+ const progress = startProvisioning();
2100
+ return jsonContent({
2101
+ started: true,
2102
+ runtime_ready: false,
2103
+ note: "Runtime install started. Poll runtime action:'status' every ~1.5s for progress.",
2104
+ progress,
2105
+ });
2106
+ }
2107
+ // ---- version: installed vs latest published ---------------------------
2108
+ if (action === "version") {
2109
+ const v = await versionStatus();
2110
+ return jsonContent({
2111
+ installed: v.installed,
2112
+ latest_published: v.latest,
2113
+ update_available: v.update_available,
2114
+ update_command: "npx social-autoposter@latest update",
2115
+ note: v.latest == null
2116
+ ? "Could not reach npm to check for a newer version (offline or registry error)."
2117
+ : v.update_available
2118
+ ? `A newer version (${v.latest}) is available. Run this tool with action:'update' ` +
2119
+ "to install it, or run `npx social-autoposter@latest update` in a terminal."
2120
+ : "You are on the latest published version.",
2121
+ });
2122
+ }
2123
+ // ---- update: pull + install the latest release ------------------------
2124
+ if (action === "update") {
2125
+ // Overwrites mcp/dist/ (including this running file — safe; the loaded
2126
+ // process keeps old code) and re-runs install.mjs to re-register the
2127
+ // client config. npx is non-interactive so it can't stall on a confirm.
2128
+ const before = VERSION;
2129
+ const res = await run("npx", ["-y", "social-autoposter@latest", "update"], {
2130
+ timeoutMs: 600_000,
2131
+ });
2132
+ const latest = await latestPublishedVersion(); // bust the cache
2133
+ return jsonContent({
2134
+ action: "update",
2135
+ ran: "npx social-autoposter@latest update",
2136
+ exit_code: res.code,
2137
+ installed_before: before,
2138
+ latest_published: latest,
2139
+ ok: res.code === 0,
2140
+ takes_effect: "after the MCP server restarts — reconnect the client / restart Claude Desktop or " +
2141
+ "Claude Code. This process keeps running the previous version until then.",
2142
+ output_tail: (res.stdout + "\n" + res.stderr).trim().split("\n").slice(-20).join("\n"),
2143
+ });
2144
+ }
2145
+ // ---- doctor: run structured diagnostics -------------------------------
2146
+ if (action === "doctor") {
2147
+ const selected = phase || "pre_connect";
2148
+ const report = await runDoctorPhase(selected);
2149
+ return jsonContent({ doctor: report, onboarding: onboardingSnapshot() });
2150
+ }
2151
+ // ---- doctor_status: last persisted Doctor result ----------------------
2152
+ if (action === "doctor_status") {
2153
+ return jsonContent({
2154
+ doctor: onboardingLedger()?.doctor?.latest ?? null,
2155
+ onboarding: onboardingSnapshot(),
2156
+ });
2157
+ }
2158
+ // ---- status (default): runtime install snapshot -----------------------
2159
+ const snapshot = runtimeSnapshot();
2160
+ if (snapshot.runtime_ready) {
2161
+ completeOnboardingMilestone("runtime_ready");
2162
+ }
2163
+ else if (snapshot.progress?.done && !snapshot.progress.ok) {
2164
+ blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
2165
+ }
2166
+ return jsonContent({
2167
+ ...snapshot,
2168
+ menubar_running: await menubarRunning(),
2169
+ onboarding: onboardingSnapshot(),
2170
+ });
2171
+ });
2172
+ // ---- restart_menubar: relaunch the always-on tray app ----------------------
2173
+ // The menu bar app is a KeepAlive LaunchAgent that a full Quit boots out. This
2174
+ // re-runs the same ensureMenubar() the boot path uses (install if missing, load
2175
+ // the LaunchAgent), so the panel can offer a one-click "restart menu bar" when
2176
+ // the snapshot reports menubar_running:false. Returns the fresh running state so
2177
+ // the panel can drop the banner without a round-trip.
2178
+ tool("restart_menubar", {
2179
+ title: "Restart the S4L menu bar app",
2180
+ description: "Relaunch the always-on S4L menu bar (tray) app after it was quit. Re-loads its " +
2181
+ "LaunchAgent (installing the menu bar first if needed). Use when the dashboard reports the menu " +
2182
+ "bar is not running, or the user asks to start S4L, restart S4L, or bring the S4L tray icon " +
2183
+ "back. Does NOT touch the draft " +
2184
+ "schedule, X connection, or any posting — it only restarts the tray UI.",
2185
+ inputSchema: {},
2186
+ }, async () => {
2187
+ // Explicit user intent to start: lift the stop sentinel a tray Quit wrote,
2188
+ // otherwise ensureMenubar() would no-op forever.
2189
+ clearMenubarStop();
2190
+ const res = await ensureMenubar();
2191
+ const running = await menubarRunning();
2192
+ return jsonContent({
2193
+ ok: res.ok,
2194
+ skipped: res.skipped ?? false,
2195
+ detail: res.detail,
2196
+ menubar_running: running,
2197
+ });
2198
+ });
2199
+ function runtimeSnapshot() {
2200
+ const rt = readRuntime();
2201
+ const progress = readProgress();
2202
+ return {
2203
+ runtime_ready: runtimeReady(),
2204
+ provisioning: isProvisioning(),
2205
+ python: rt?.python ?? null,
2206
+ python_version: rt?.python_version ?? null,
2207
+ progress: progress ?? null,
2208
+ onboarding: onboardingSnapshot(),
2209
+ };
2210
+ }
2211
+ // ---- queue_setup: hand the agent the two worker-task specs -----------------
2212
+ // The customer-box autopilot is now two single-purpose scheduled tasks that
2213
+ // drain the pipeline's claude -p job queue (see the queue-worker section below).
2214
+ // The agent can't author their prompts (baked absolute paths to python +
2215
+ // claude_job.py), so this tool returns the EXACT specs to pass straight to the
2216
+ // host tool create_scheduled_task. Calling it also eagerly pre-approves the
2217
+ // worker tools, so the tasks never stall on a permission prompt. Read-only +
2218
+ // idempotent.
2219
+ tool("queue_setup", {
2220
+ title: "Get autopilot scheduled-task specs",
2221
+ description: "Returns the scheduled task that runs the hands-free draft autopilot on this machine " +
2222
+ "(s4l-worker, the universal queue worker). For EACH returned task, call the host tool " +
2223
+ "create_scheduled_task with its taskId, cronExpression, and prompt VERBATIM (do not edit the " +
2224
+ "prompt — it contains exact local paths). The task drains the local job queue that the " +
2225
+ "real pipeline feeds (all job types); the pipeline itself is kicked by launchd jobs this server " +
2226
+ "installs. Use this as the final onboarding step instead of the old per-type worker tasks.",
2227
+ inputSchema: {},
2228
+ }, async () => {
2229
+ ensureQueueWorkerToolsAllowed();
2230
+ // Re-arming the autopilot is an explicit "start S4L" action: lift a prior
2231
+ // tray Quit so the review cards have a surface again. Best-effort and
2232
+ // async — task specs must return regardless of tray state.
2233
+ clearMenubarStop();
2234
+ void ensureMenubar();
2235
+ // Write each worker's canonical SKILL.md to disk NOW, before the agent calls
2236
+ // create_scheduled_task. The host's create_scheduled_task can report a task
2237
+ // "already exists" (e.g. a stale Routines registration left after a reset) and
2238
+ // then NOT write the prompt file — leaving a registered-but-promptless task
2239
+ // that fires and does nothing. Pre-writing the file means the prompt is always
2240
+ // present and correct regardless of what the host create does. (2026-06-24)
2241
+ for (const spec of QUEUE_WORKERS) {
2242
+ try {
2243
+ const p = scheduledTaskSkillPath(spec.taskId);
2244
+ fs.mkdirSync(path.dirname(p), { recursive: true });
2245
+ fs.writeFileSync(p, queueWorkerSkillMd(spec), "utf-8");
2246
+ }
2247
+ catch (e) {
2248
+ console.error(`[queue_setup] could not pre-write ${spec.taskId} SKILL.md: ${e?.message || e}`);
2249
+ }
2250
+ }
2251
+ // Pre-create the dedicated worker folder so the host can set it as each task's
2252
+ // working directory at creation; this keeps the per-minute worker sessions out
2253
+ // of the user's interactive `claude --resume` picker (see queueWorkerCwd()).
2254
+ const workerFolder = queueWorkerCwd();
2255
+ try {
2256
+ fs.mkdirSync(workerFolder, { recursive: true });
2257
+ // Trust it now, before the routines point at it — otherwise the first
2258
+ // unattended fire stalls at Claude's per-folder checkTrust on a headless box.
2259
+ ensureWorkerFolderTrusted();
2260
+ }
2261
+ catch (e) {
2262
+ console.error(`[queue_setup] could not create worker folder ${workerFolder}: ${e?.message || e}`);
2263
+ }
2264
+ const tasks = QUEUE_WORKERS.map((spec) => ({
2265
+ taskId: spec.taskId,
2266
+ cronExpression: "* * * * *",
2267
+ folder: workerFolder,
2268
+ prompt: queueWorkerBody(spec),
2269
+ }));
2270
+ return jsonContent({
2271
+ tasks,
2272
+ worker_folder: workerFolder,
2273
+ // Plain-language "what happens next" the agent MUST relay to the user once
2274
+ // the tasks are created, so the setup experience sets expectations about
2275
+ // cadence (a draft batch roughly every 5 min), what the jobs make (reply
2276
+ // drafts for review), and that nothing posts on its own. (2026-06-30)
2277
+ expectations: [
2278
+ "What happens next (relay these lines to the user, in their words, right after the tasks are created):",
2279
+ "- About every 5 minutes a background draft cycle scans X for posts that match your search topics and drafts replies in your voice.",
2280
+ "- Drafts show up as review cards. The first one usually lands within a few minutes when there is matching supply on X; quiet topics mean fewer or no cards until something relevant is posted.",
2281
+ "- Nothing is posted automatically. You approve each draft yourself (from the dashboard or the menu bar); posting autopilot stays OFF until you explicitly turn it on. Today it only drafts.",
2282
+ "- One helper job (s4l-worker) runs every minute to drain the background work queue. Leave it enabled; it only drafts, it never posts.",
2283
+ "- You can edit your voice, topics, or the drafts themselves at any time, and check status on the dashboard.",
2284
+ ],
2285
+ next_step: "For EACH task, call the host tool create_scheduled_task (taskId, cronExpression, prompt " +
2286
+ "verbatim) to register its schedule — the prompt file is already written, so creating it just " +
2287
+ "wires up the cron. If create_scheduled_task reports a task already exists, that is FINE — the " +
2288
+ "schedule is registered and the prompt is current; do NOT delete/recreate, do NOT investigate, " +
2289
+ "just move on. It fires every minute and only drafts — it never posts. The pipeline that feeds " +
2290
+ "it runs from launchd jobs this server installs. Do NOT create the old " +
2291
+ "'social-autoposter-autopilot' task; it is deprecated. If the older per-type tasks " +
2292
+ "(saps-phase1-query / saps-phase2b-draft) exist from a previous version, LEAVE them — they are " +
2293
+ "compatible with the universal queue and drain the same jobs. " +
2294
+ "After the tasks are registered, relay the `expectations` lines to the user so they know the " +
2295
+ "cadence (a draft batch about every 5 minutes), that the jobs only draft and never post, and " +
2296
+ "that they approve each card themselves. " +
2297
+ "Do NOT instruct the user to set each task's working folder by hand in the Routines Edit form — " +
2298
+ "that edit does not stick (Claude Desktop caches the schedule registry in memory and clobbers a " +
2299
+ `live edit on the next fire). The S4L menu-bar app relocates these tasks to ${workerFolder} ` +
2300
+ "automatically: it detects the wrong folder, asks the user once with a modal, then restarts Claude " +
2301
+ "once while it is down to apply the change (the only reliable way). So you do NOT need to set any " +
2302
+ "folder here — just create the task; the menu bar handles keeping its once-a-minute runs " +
2303
+ "out of the user's `claude --resume` history.",
2304
+ });
2305
+ });
2306
+ // NOTE: the `run_draft_cycle` tool was REMOVED (2026-06-28, per user). The
2307
+ // autopilot drafts on its own — the launchd kicker (ensureQueueKickerInstalled)
2308
+ // fires a DRAFT_ONLY cycle every ~5 min and the queue worker drains it — so a
2309
+ // manual "draft now" tool is redundant. Onboarding now verifies by polling the
2310
+ // `dashboard` pending-draft count after the scheduled tasks are created (the
2311
+ // scheduled cycle produces the first card within a few minutes). Do NOT
2312
+ // re-introduce a run_draft_cycle / draft_cycle tool.
2313
+ // ---- panel: MCP Apps control surface --------------------------------------
2314
+ // A self-contained HTML view rendered by hosts that support MCP Apps (Claude
2315
+ // desktop/web, etc.). It duplicates NO pipeline logic: each button calls one of
2316
+ // the tools above (project_config / get_stats / dashboard) through the host
2317
+ // and re-reads status. The tool itself returns the first-paint snapshot so the
2318
+ // view has data the instant it loads.
2319
+ // Is either launchd job (cycle / daily updater) currently loaded?
2320
+ // "Autopilot" is now the pair of Claude Desktop queue-worker scheduled tasks
2321
+ // (saps-phase1-query + saps-phase2b-draft, created during onboarding via
2322
+ // create_scheduled_task) that drain the draft queue, NOT the legacy launchd job.
2323
+ // We can't read the host's enabled/paused flag, but the tasks' presence on disk is the
2324
+ // single signal the dashboard AND the menu bar key off of, so they stay aligned.
2325
+ async function autopilotLoaded() {
2326
+ let autopilot_on = false;
2327
+ try {
2328
+ // Autopilot is "on" once a COMPLETE worker set that services the pipeline's
2329
+ // queued `claude -p` calls has its SKILL.md on disk: the universal
2330
+ // s4l-worker, the transitional saps-worker (staging rc.2/rc.3), or (legacy
2331
+ // installs) both per-type workers.
2332
+ autopilot_on =
2333
+ QUEUE_WORKERS.every((spec) => fs.existsSync(scheduledTaskSkillPath(spec.taskId))) ||
2334
+ fs.existsSync(scheduledTaskSkillPath(LEGACY_UNIVERSAL_TASK_ID)) ||
2335
+ [PHASE1_TASK_ID, PHASE2B_TASK_ID].every((id) => fs.existsSync(scheduledTaskSkillPath(id)));
2336
+ }
2337
+ catch {
2338
+ /* leave false */
2339
+ }
2340
+ let auto_update_on = false;
2341
+ try {
2342
+ // noTee: this status probe dumps the entire launchd job table (hundreds of
2343
+ // lines) and fires on every dashboard/status poll — teeing it flooded Cloud
2344
+ // Logging (~98% of an install's log volume). We only need the substring
2345
+ // check, so keep the output in-memory and out of the relay. (2026-06-28)
2346
+ const res = await run("launchctl", ["list"], { timeoutMs: 10_000, noTee: true });
2347
+ auto_update_on = res.stdout.split("\n").some((l) => l.includes(UPDATER_LABEL));
2348
+ }
2349
+ catch {
2350
+ /* leave false */
2351
+ }
2352
+ return { autopilot_on, auto_update_on };
2353
+ }
2354
+ // ===========================================================================
2355
+ // Queue-worker scheduled tasks + launchd kicker (2026-06-23)
2356
+ //
2357
+ // The single drafting path. The REAL pipeline runs in DRAFT_ONLY mode under
2358
+ // launchd; its `claude -p` calls go
2359
+ // through scripts/claude_job.py's file queue (run_claude.sh provider seam); two
2360
+ // scheduled tasks drain that queue. Each task is single-purpose (one job type),
2361
+ // fires every minute, claims ONE job, runs the pipeline's own prompt as its
2362
+ // Claude turn, writes the result back, and stops.
2363
+ // ===========================================================================
2364
+ const QUEUE_WORKER_PROMPT_VERSION = 7; // v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
2365
+ const QUEUE_WORKER_PROMPT_MARKER = "saps_queue_worker_prompt_version";
2366
+ // One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
2367
+ const QUEUE_WORKERS = [
2368
+ { taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
2369
+ ];
2370
+ // Earlier installs created these instead. Never created anymore; their
2371
+ // SKILL.md is refreshed to the universal body on boot (see
2372
+ // ensureQueueWorkerPromptsCurrent) so they keep draining every job type until
2373
+ // the menubar self-heal consolidates them into s4l-worker.
2374
+ const LEGACY_QUEUE_WORKER_TASK_IDS = [LEGACY_UNIVERSAL_TASK_ID, PHASE1_TASK_ID, PHASE2B_TASK_ID];
2375
+ function scheduledTaskSkillPath(taskId) {
2376
+ const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
2377
+ return path.join(cfg, "scheduled-tasks", taskId, "SKILL.md");
2378
+ }
2379
+ // The queue dir the worker reads/writes. MUST equal what the launchd kicker sets
2380
+ // (kickerEnv below) and what claude_job.py uses, so both ends meet on one path.
2381
+ function queueDir() {
2382
+ return path.join(sapsStateDir(), "claude-queue");
2383
+ }
2384
+ // A draft job left unclaimed in pending/ this long (ms) means no scheduled-task
2385
+ // routine is draining the queue — the worker would claim within a minute if it
2386
+ // were firing. This is the liveness signal that survives a Claude account switch
2387
+ // (which orphans the routines while their global SKILL.md files stay put, so the
2388
+ // SKILL.md-presence check in autopilotLoaded() reads a FALSE green). Mirrors the
2389
+ // menu bar's AUTOPILOT_STALL_SECONDS in mcp/menubar/s4l_menubar.py — keep in sync.
2390
+ const AUTOPILOT_STALL_MS = 180_000;
2391
+ // True when no scheduled-task routine is draining the draft queue. Two signals,
2392
+ // OR'd (keep in lockstep with mcp/menubar/s4l_menubar.py::_autopilot_stalled and
2393
+ // scripts/autopilot_stall_watch.py):
2394
+ // (1) LATCHED: the producer's drain-status.json shows >=1 consecutive timeout
2395
+ // with no drain since. Survives the between-cycle gap (no pending file then),
2396
+ // so the signal is CONTINUOUS, not flickery. The durable signal.
2397
+ // (2) FAST: a draft job has sat unclaimed past AUTOPILOT_STALL_MS — catches a
2398
+ // fresh stall before the first full producer timeout has latched (1).
2399
+ // False-positive free: an idle queue (no candidates) has no pending job and the
2400
+ // producer clears the latch on every successful drain.
2401
+ function autopilotStalled() {
2402
+ // (1) latched producer drain-status
2403
+ try {
2404
+ const ds = JSON.parse(fs.readFileSync(path.join(queueDir(), "drain-status.json"), "utf-8"));
2405
+ if (Number(ds?.consecutive_timeouts || 0) >= 1)
2406
+ return true;
2407
+ }
2408
+ catch {
2409
+ /* no marker yet */
2410
+ }
2411
+ // (2) fast pending-age
2412
+ try {
2413
+ const pendRoot = path.join(queueDir(), "pending");
2414
+ let oldest = Infinity;
2415
+ for (const sub of fs.readdirSync(pendRoot, { withFileTypes: true })) {
2416
+ if (!sub.isDirectory())
2417
+ continue;
2418
+ // feedback-digest jobs are latency-insensitive (hourly kicker, retried
2419
+ // forever) and may legitimately queue behind a multi-minute draft job;
2420
+ // aging past the draft threshold there is NOT an autopilot stall.
2421
+ if (sub.name === "feedback-digest")
2422
+ continue;
2423
+ const subPath = path.join(pendRoot, sub.name);
2424
+ for (const f of fs.readdirSync(subPath)) {
2425
+ if (!f.endsWith(".json") || f.endsWith(".tmp"))
2426
+ continue;
2427
+ try {
2428
+ const m = fs.statSync(path.join(subPath, f)).mtimeMs;
2429
+ if (m < oldest)
2430
+ oldest = m;
2431
+ }
2432
+ catch {
2433
+ /* skip */
2434
+ }
2435
+ }
2436
+ }
2437
+ if (oldest !== Infinity && Date.now() - oldest > AUTOPILOT_STALL_MS)
2438
+ return true;
2439
+ }
2440
+ catch {
2441
+ /* no pending dir */
2442
+ }
2443
+ return false;
2444
+ }
2445
+ // Dedicated working directory the queue-worker scheduled tasks should RUN in.
2446
+ //
2447
+ // Claude Code/Desktop buckets every session under
2448
+ // ~/.claude/projects/<encoded-run-cwd>/, and the interactive resume/history
2449
+ // picker is scoped to the CURRENT folder's bucket by default. The two workers
2450
+ // fire every minute, so if they run in the user's project folder they flood that
2451
+ // folder's `claude --resume` picker with `<scheduled-task ...>` sessions
2452
+ // (~2,880/day, mostly empty no-ops). Pointing them at a dedicated folder the user
2453
+ // never opens interactively keeps those sessions in a SEPARATE bucket
2454
+ // (-Users-<user>--s4l-worker), leaving the project's picker clean. Safe because
2455
+ // the worker body uses absolute paths and the MCP + settings.json allow-rules are
2456
+ // global, not folder-scoped, so the run cwd is functionally irrelevant — only the
2457
+ // session bucketing changes. The "autopilot on" signal keys off the SKILL.md under
2458
+ // the config dir (scheduledTaskSkillPath), not the run folder, so it is unaffected.
2459
+ //
2460
+ // NOTE: the host tool create_scheduled_task exposes no `folder` param, so the run
2461
+ // folder is set host-side at creation (the onboarding session's folder) or via the
2462
+ // Routines UI -> Edit -> Folder. queue_setup surfaces this path + the instruction.
2463
+ function queueWorkerCwd() {
2464
+ return path.join(process.env.HOME || os.homedir(), ".s4l-worker");
2465
+ }
2466
+ // A single worker task's SKILL.md. Bash-only: claim -> follow the job's own
2467
+ // prompt -> write JSON -> submit. Paths are baked in at generation time because
2468
+ // the unattended Bash session can't resolve our env. TYPE-BLIND BY DESIGN: the
2469
+ // worker claims `--type any` and never knows what kinds of jobs exist. The
2470
+ // job's prompt sidecar is fully self-contained — the pipeline's real prompt
2471
+ // plus any per-type WORKER EXECUTION NOTES that claude_job.py prepends at
2472
+ // claim time (pacing, persist cadence). Adding a new job type touches ONLY
2473
+ // claude_job.py; this prompt and the scheduled task never change.
2474
+ function queueWorkerBody(spec) {
2475
+ const py = resolvePython();
2476
+ const job = path.join(repoDir(), "scripts", "claude_job.py");
2477
+ const sd = sapsStateDir();
2478
+ const outDir = queueDir();
2479
+ return [
2480
+ `You are the S4L queue worker. Run ONE iteration, then STOP.`,
2481
+ ``,
2482
+ `The deterministic pipeline runs on this Mac. When it needs a Claude turn it ` +
2483
+ `drops a job on a local file queue. Your only job: pick up the next job, do ` +
2484
+ `EXACTLY what its prompt says, hand the result back. You do this with Bash, ` +
2485
+ `Read, and Write, and NOTHING else. This run is unattended — reaching for any ` +
2486
+ `other tool, or trying to "investigate", STALLS it forever.`,
2487
+ ``,
2488
+ `PACING — CRITICAL: this unattended session is terminated ~90 seconds after ` +
2489
+ `your LAST tool call (a host inactivity timeout). Make your first tool call ` +
2490
+ `promptly, and if the job's prompt gives you per-item persist commands to run ` +
2491
+ `(its own quick Bash calls), run them as you complete each item instead of ` +
2492
+ `working silently — those calls are what keep the session alive. The prompt ` +
2493
+ `file may begin with a WORKER EXECUTION NOTES header; follow it exactly.`,
2494
+ ``,
2495
+ `Steps:`,
2496
+ `1. Claim the next job. Run this EXACT Bash command:`,
2497
+ ` ${py} ${job} next --type any --prompt-file --state-dir ${sd}`,
2498
+ ` It prints one line of JSON. If it prints "{}" (empty), there is NO work — ` +
2499
+ `report "no jobs" in one line and STOP. You are done.`,
2500
+ `2. Otherwise it prints {"job_id":"...","prompt_file":"...","schema_file":...}. ` +
2501
+ `Use the Read tool to read prompt_file; it is the complete, self-contained ` +
2502
+ `instruction the pipeline wrote for you. If the Read result says it is partial ` +
2503
+ `or truncated, keep reading the same file with offsets until EOF. If schema_file ` +
2504
+ `is not null, read it too. Follow the prompt EXACTLY and produce the SINGLE JSON ` +
2505
+ `object it asks for. If a schema is present, your JSON MUST satisfy it. Output ` +
2506
+ `ONLY that JSON object — no prose, no markdown, no code fences.`,
2507
+ `3. Submit it. Write your JSON object to ${outDir}/out-<job_id>.json using the ` +
2508
+ `Write tool (substitute the real job_id), then run this EXACT Bash command:`,
2509
+ ` ${py} ${job} result --job <job_id> --result-file ${outDir}/out-<job_id>.json --state-dir ${sd}`,
2510
+ ` If it reports the result was rejected (bad JSON / missing keys), fix your JSON ` +
2511
+ `and submit again — at most twice. If it still fails, run ` +
2512
+ `\`${py} ${job} result --job <job_id> --error --state-dir ${sd}\` (type a one-line ` +
2513
+ `reason, then Ctrl-D) and STOP.`,
2514
+ `4. Report in ONE short line what you did, then STOP. Do NOT claim another job, ` +
2515
+ `do NOT loop, do NOT read other files, do NOT call any other tool.`,
2516
+ ``,
2517
+ `HARD RULES: use ONLY the Bash tool (to run claude_job.py AND any persist ` +
2518
+ `commands the job's prompt explicitly gives you), the Read tool (the ` +
2519
+ `prompt/schema sidecar + the SKILL/config files the prompt names), and the ` +
2520
+ `Write tool (the result file). NEVER post, reply, open a browser, or run any ` +
2521
+ `command the prompt does not explicitly give you. An empty queue is the ` +
2522
+ `NORMAL, expected case most minutes — it is success, not a problem to debug.`,
2523
+ ].join("\n");
2524
+ }
2525
+ // Full canonical SKILL.md (frontmatter + body + version marker) the MCP writes
2526
+ // to keep the task current. queueWorkerBody() is what the agent passes to
2527
+ // create_scheduled_task at onboarding (already complete + correct, baked paths);
2528
+ // this wrapper just adds the frontmatter + marker the refresh-on-boot gate reads.
2529
+ function queueWorkerSkillMd(spec) {
2530
+ return (`---\n` +
2531
+ `name: ${spec.taskId}\n` +
2532
+ `description: S4L queue worker — claims the next job from the local pipeline ` +
2533
+ `queue, drafts it, writes the result back. Never posts.\n` +
2534
+ `---\n\n` +
2535
+ queueWorkerBody(spec) +
2536
+ `\n\n<!-- ${QUEUE_WORKER_PROMPT_MARKER}: ${QUEUE_WORKER_PROMPT_VERSION} -->\n`);
2537
+ }
2538
+ // Refresh each worker task's SKILL.md when this build ships a newer prompt than
2539
+ // what's on disk. Best-effort, only touches
2540
+ // an EXISTING task (onboarding creates them), only when stale. Also rewrites when
2541
+ // the baked-in paths (python/repo) would have changed, since a stale absolute
2542
+ // path would break the Bash commands; we detect that by always rewriting on a
2543
+ // version bump and trust the version gate otherwise.
2544
+ function ensureQueueWorkerPromptsCurrent() {
2545
+ for (const spec of QUEUE_WORKERS) {
2546
+ try {
2547
+ const skillPath = scheduledTaskSkillPath(spec.taskId);
2548
+ // Write the prompt on boot if it's MISSING (not just when stale). This makes
2549
+ // the worker SKILL.md ALWAYS present, so re-arm only ever needs the host
2550
+ // create_scheduled_task (which points filePath at it) — it never depends on
2551
+ // queue_setup being callable. Previously we skipped when absent, which left a
2552
+ // freshly-switched/onboarded account with no prompt file and forced the
2553
+ // queue_setup path (broken when the tool isn't exposed). create-if-missing.
2554
+ if (!fs.existsSync(skillPath)) {
2555
+ fs.mkdirSync(path.dirname(skillPath), { recursive: true });
2556
+ fs.writeFileSync(skillPath, queueWorkerSkillMd(spec), "utf-8");
2557
+ console.error(`[queue-worker] wrote missing ${spec.taskId} prompt -> v${QUEUE_WORKER_PROMPT_VERSION}`);
2558
+ continue;
2559
+ }
2560
+ const cur = fs.readFileSync(skillPath, "utf-8");
2561
+ const m = new RegExp(`${QUEUE_WORKER_PROMPT_MARKER}:\\s*(\\d+)`).exec(cur);
2562
+ const curVer = m ? parseInt(m[1], 10) : 0;
2563
+ if (curVer >= QUEUE_WORKER_PROMPT_VERSION)
2564
+ continue;
2565
+ fs.writeFileSync(skillPath, queueWorkerSkillMd(spec), "utf-8");
2566
+ console.error(`[queue-worker] refreshed ${spec.taskId} prompt -> v${QUEUE_WORKER_PROMPT_VERSION} (was v${curVer})`);
2567
+ }
2568
+ catch (e) {
2569
+ console.error(`[queue-worker] ensure ${spec.taskId} prompt error: ${e?.message || e}`);
2570
+ }
2571
+ }
2572
+ // Legacy per-type workers (pre-universal installs): refresh their SKILL.md to
2573
+ // the SAME universal body, but ONLY when the file already exists — we never
2574
+ // create them anymore. This upgrades an old box's two tasks into two
2575
+ // interchangeable universal workers with zero re-onboarding (the host task
2576
+ // registration keeps firing; only the prompt file changes).
2577
+ for (const taskId of LEGACY_QUEUE_WORKER_TASK_IDS) {
2578
+ try {
2579
+ const skillPath = scheduledTaskSkillPath(taskId);
2580
+ if (!fs.existsSync(skillPath))
2581
+ continue;
2582
+ const cur = fs.readFileSync(skillPath, "utf-8");
2583
+ const m = new RegExp(`${QUEUE_WORKER_PROMPT_MARKER}:\\s*(\\d+)`).exec(cur);
2584
+ const curVer = m ? parseInt(m[1], 10) : 0;
2585
+ if (curVer >= QUEUE_WORKER_PROMPT_VERSION)
2586
+ continue;
2587
+ fs.writeFileSync(skillPath, queueWorkerSkillMd({ taskId, queueType: "any", human: "universal queue" }), "utf-8");
2588
+ console.error(`[queue-worker] refreshed legacy ${taskId} prompt -> universal v${QUEUE_WORKER_PROMPT_VERSION} (was v${curVer})`);
2589
+ }
2590
+ catch (e) {
2591
+ console.error(`[queue-worker] ensure legacy ${taskId} prompt error: ${e?.message || e}`);
2592
+ }
2593
+ }
2594
+ }
2595
+ // ---- Pre-approve tools for the unattended scheduled tasks --------------------
2596
+ // Scheduled tasks default to "Ask" mode; an un-pre-approved tool STALLS forever
2597
+ // (no human to click allow). settings.json allow-rules DO apply to scheduled-task
2598
+ // sessions. Per the user's directive, pre-approve GENEROUSLY so a worker never
2599
+ // wedges even if it reaches for something unexpected: the exact claude_job.py
2600
+ // command, python broadly, the file tools it legitimately uses, and this server's
2601
+ // own tools. Allow-only + merge-in-place; never clobbers a user's settings.
2602
+ function queueWorkerAllowedTools() {
2603
+ const job = path.join(repoDir(), "scripts", "claude_job.py");
2604
+ return [
2605
+ // Blanket Bash. The scheduled-task runner only auto-approves a permission
2606
+ // request if every suggested rule is in the task's approvedPermissions store
2607
+ // (which we cannot populate from here); otherwise the unattended session hangs
2608
+ // on the prompt and is SIGTERM-killed at ~90s. The fix is to make the CLI
2609
+ // auto-allow EVERY Bash phrasing up front so no request is ever emitted. The
2610
+ // scoped rules below missed model phrasings like `cd … && python3 …` or odd
2611
+ // quoting on log_draft.py's --text, which caused intermittent draft timeouts.
2612
+ // This worker is single-purpose and its SKILL.md tightly scopes what it runs.
2613
+ "Bash",
2614
+ // Kept for clarity / belt-and-suspenders (tightest match first).
2615
+ `Bash(${resolvePython()} ${job}:*)`,
2616
+ `Bash(python3 ${job}:*)`,
2617
+ `Bash(${job}:*)`,
2618
+ "Bash(python3:*)",
2619
+ "Bash(python:*)",
2620
+ // File tools the worker uses (Write) + ones it might reach for without stalling.
2621
+ "Write",
2622
+ "Read",
2623
+ "Edit",
2624
+ "Glob",
2625
+ "Grep",
2626
+ // This server's tools, both namespaces (manifest name + protocol name).
2627
+ "mcp__social-autoposter__queue_setup",
2628
+ "mcp__social-autoposter__post_drafts",
2629
+ "mcp__social-autoposter__project_config",
2630
+ "mcp__social-autoposter__get_stats",
2631
+ "mcp__social-autoposter__dashboard",
2632
+ "mcp__S4L__queue_setup",
2633
+ "mcp__S4L__post_drafts",
2634
+ "mcp__S4L__project_config",
2635
+ "mcp__S4L__get_stats",
2636
+ "mcp__S4L__dashboard",
2637
+ // Legacy "SAPS" protocol-name namespace (pre-2026-07 brand rename): old
2638
+ // registrations still resolve tool ids under it, keep the allow-rules.
2639
+ "mcp__SAPS__queue_setup",
2640
+ "mcp__SAPS__post_drafts",
2641
+ "mcp__SAPS__project_config",
2642
+ "mcp__SAPS__get_stats",
2643
+ "mcp__SAPS__dashboard",
2644
+ ];
2645
+ }
2646
+ // Merge a list of allow-rules into ~/.claude/settings.json. Returns count added.
2647
+ // Shared by the autopilot + queue-worker pre-approvers. Never throws.
2648
+ function mergeSettingsAllow(tools) {
2649
+ try {
2650
+ const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
2651
+ const settingsPath = path.join(cfg, "settings.json");
2652
+ let settings = {};
2653
+ if (fs.existsSync(settingsPath)) {
2654
+ try {
2655
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")) || {};
2656
+ }
2657
+ catch (e) {
2658
+ console.error(`[pre-approve] settings.json unparseable; skipping: ${e?.message || e}`);
2659
+ return 0;
2660
+ }
2661
+ }
2662
+ if (typeof settings !== "object" || Array.isArray(settings))
2663
+ return 0;
2664
+ const perms = (settings.permissions ??= {});
2665
+ if (typeof perms !== "object" || Array.isArray(perms))
2666
+ return 0;
2667
+ const allow = Array.isArray(perms.allow) ? perms.allow : (perms.allow = []);
2668
+ let added = 0;
2669
+ for (const t of tools) {
2670
+ if (!allow.includes(t)) {
2671
+ allow.push(t);
2672
+ added++;
2673
+ }
2674
+ }
2675
+ if (added === 0)
2676
+ return 0;
2677
+ fs.mkdirSync(cfg, { recursive: true });
2678
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2679
+ return added;
2680
+ }
2681
+ catch (e) {
2682
+ console.error(`[pre-approve] mergeSettingsAllow error: ${e?.message || e}`);
2683
+ return 0;
2684
+ }
2685
+ }
2686
+ // Pre-approve the worker tools EAGERLY — NOT gated on a task existing — so the
2687
+ // settings are already in place before onboarding even creates the tasks, and
2688
+ // the very first unattended fire can never stall. Allow-only, idempotent.
2689
+ function ensureQueueWorkerToolsAllowed() {
2690
+ const added = mergeSettingsAllow(queueWorkerAllowedTools());
2691
+ if (added > 0) {
2692
+ console.error(`[queue-worker] pre-approved ${added} tool rule(s) in settings.json (allow-only)`);
2693
+ }
2694
+ }
2695
+ // Mark the dedicated worker folder as trusted in ~/.claude.json so the unattended
2696
+ // scheduled-task sessions can actually START there. Claude Code/Desktop gates every
2697
+ // session behind a per-folder trust check (hasTrustDialogAccepted). A brand-new
2698
+ // folder like ~/.s4l-worker has no project entry, so on a headless box the worker
2699
+ // session stalls at checkTrust forever — there is no human to click "trust the files
2700
+ // in this folder" — and the queue never drains. We create the folder ourselves
2701
+ // (boot + queue_setup), so we own trusting it too. Without this, repointing the two
2702
+ // routines at the dedicated folder silently wedges the WHOLE pipeline: seen 2026-06-26
2703
+ // when a box's worker cwd switched to ~/.s4l-worker and every worker session died at
2704
+ // checkTrust (Starting/Mapping never logged), producing 0 drafts for hours. Idempotent
2705
+ // and atomic; never throws. (The already-trusted onboarding project folder works
2706
+ // because the setup session triggered the trust dialog there once.)
2707
+ function ensureWorkerFolderTrusted() {
2708
+ try {
2709
+ const home = process.env.HOME || os.homedir();
2710
+ const cfgPath = path.join(home, ".claude.json");
2711
+ if (!fs.existsSync(cfgPath))
2712
+ return; // Claude Code not initialised yet; nothing to merge into
2713
+ let cfg;
2714
+ try {
2715
+ cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
2716
+ }
2717
+ catch (e) {
2718
+ console.error(`[queue-worker] ~/.claude.json unparseable; skip trust: ${e?.message || e}`);
2719
+ return;
2720
+ }
2721
+ if (typeof cfg !== "object" || Array.isArray(cfg) || cfg === null)
2722
+ return;
2723
+ const projects = (cfg.projects ??= {});
2724
+ if (typeof projects !== "object" || Array.isArray(projects))
2725
+ return;
2726
+ const folder = queueWorkerCwd();
2727
+ const existing = projects[folder];
2728
+ if (existing && existing.hasTrustDialogAccepted === true)
2729
+ return; // already trusted; no write
2730
+ // Preserve any fields a prior interactive open wrote; only force the trust flag.
2731
+ const entry = existing && typeof existing === "object" && !Array.isArray(existing)
2732
+ ? { ...existing }
2733
+ : {
2734
+ allowedTools: [],
2735
+ disabledMcpjsonServers: [],
2736
+ enabledMcpjsonServers: [],
2737
+ hasClaudeMdExternalIncludesApproved: false,
2738
+ hasClaudeMdExternalIncludesWarningShown: false,
2739
+ mcpContextUris: [],
2740
+ projectOnboardingSeenCount: 0,
2741
+ };
2742
+ entry.hasTrustDialogAccepted = true;
2743
+ projects[folder] = entry;
2744
+ // Atomic write: ~/.claude.json is large and read by every CLI session; a torn
2745
+ // write would brick Claude Code. Stage a temp sibling, then rename over it.
2746
+ const tmp = `${cfgPath}.s4l-trust.${process.pid}.tmp`;
2747
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
2748
+ fs.renameSync(tmp, cfgPath);
2749
+ console.error(`[queue-worker] trusted worker folder in ~/.claude.json: ${folder}`);
2750
+ }
2751
+ catch (e) {
2752
+ console.error(`[queue-worker] ensureWorkerFolderTrusted error: ${e?.message || e}`);
2753
+ }
2754
+ }
2755
+ // Register this .mcpb server into ~/.claude.json `mcpServers` so the embedded
2756
+ // Cowork/Code agent discovers S4L too. The Chat tab loads S4L via Desktop's
2757
+ // LocalMcpServerManager (.mcpb extensions); the Cowork/Code tab is a SEPARATE,
2758
+ // real `claude-code` binary launched with `--setting-sources=user,project,local`
2759
+ // that only reads MCP servers from its setting sources + plugin dirs and NEVER
2760
+ // sees .mcpb extensions. So S4L shows up in Chat but is absent in Cowork no matter
2761
+ // how many restarts — the two surfaces don't share MCP state (confirmed
2762
+ // 2026-06-30 from the embedded process args on the box: empty user `mcpServers`,
2763
+ // S4L only present as a .mcpb). Writing a user-scoped `mcpServers` entry is the
2764
+ // path `--setting-sources=user` honors, so Cowork picks it up on its next session.
2765
+ // We point the entry at THIS running server's own dist/index.js (absolute,
2766
+ // install-location-agnostic) so both npm and .mcpb installs self-register the
2767
+ // correct path. Idempotent (writes only when missing/drifted), atomic (every CLI
2768
+ // session reads this file; a torn write would brick Claude Code), never throws.
2769
+ // Runs on every boot, so a box whose ~/.claude.json didn't exist yet self-heals on
2770
+ // the next restart once a Code/Cowork session has created it. Kill switch:
2771
+ // S4L_COWORK_MCP=0.
2772
+ function ensureCoworkMcpRegistered() {
2773
+ try {
2774
+ if ((process.env.S4L_COWORK_MCP ?? process.env.SAPS_COWORK_MCP) === "0")
2775
+ return;
2776
+ const home = process.env.HOME || os.homedir();
2777
+ const cfgPath = path.join(home, ".claude.json");
2778
+ if (!fs.existsSync(cfgPath))
2779
+ return; // Claude Code not initialised yet; retry next boot
2780
+ let cfg;
2781
+ try {
2782
+ cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
2783
+ }
2784
+ catch (e) {
2785
+ console.error(`[cowork-mcp] ~/.claude.json unparseable; skip register: ${e?.message || e}`);
2786
+ return;
2787
+ }
2788
+ if (typeof cfg !== "object" || Array.isArray(cfg) || cfg === null)
2789
+ return;
2790
+ const servers = (cfg.mcpServers ??= {});
2791
+ if (typeof servers !== "object" || Array.isArray(servers))
2792
+ return;
2793
+ const serverEntry = path.join(DIST_DIR, "index.js");
2794
+ const desired = {
2795
+ command: "node",
2796
+ args: [serverEntry],
2797
+ env: { PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" },
2798
+ };
2799
+ const current = servers["social-autoposter"];
2800
+ // Skip the write when the entry already matches, to avoid churning a file every
2801
+ // CLI session reads. Re-write when missing or drifted (install moved, older
2802
+ // version registered a different path/env).
2803
+ if (current && JSON.stringify(current) === JSON.stringify(desired))
2804
+ return;
2805
+ servers["social-autoposter"] = desired;
2806
+ const tmp = `${cfgPath}.s4l-cowork.${process.pid}.tmp`;
2807
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
2808
+ fs.renameSync(tmp, cfgPath);
2809
+ console.error(`[cowork-mcp] registered S4L in ~/.claude.json mcpServers -> ${serverEntry}`);
2810
+ }
2811
+ catch (e) {
2812
+ console.error(`[cowork-mcp] ensureCoworkMcpRegistered error: ${e?.message || e}`);
2813
+ }
2814
+ }
2815
+ // ---- launchd kicker: run the REAL pipeline in DRAFT_ONLY + queue mode --------
2816
+ // Reinstates com.m13v.social-twitter-cycle as the customer-box kicker. It runs
2817
+ // run-twitter-cycle.sh straight through (scan -> score -> draft -> link-gen) but
2818
+ // STOPS before posting (DRAFT_ONLY=1), writing the plan to the review-queue the
2819
+ // approval cards read. Its `claude -p` steps route through the job queue
2820
+ // (S4L_CLAUDE_PROVIDER=queue) for the scheduled-task workers to service.
2821
+ // link_tail is skipped for now (TWITTER_TAIL_LINK_RATE=0); the short link is
2822
+ // still baked by twitter_gen_links.py (pure Python).
2823
+ const QUEUE_KICKER_INTERVAL_SECS = 300; // a fresh draft cycle every 5 min
2824
+ function kickerEnv() {
2825
+ return {
2826
+ DRAFT_ONLY: "1",
2827
+ S4L_CLAUDE_PROVIDER: "queue",
2828
+ S4L_STATE_DIR: sapsStateDir(),
2829
+ TWITTER_TAIL_LINK_RATE: "0",
2830
+ TWITTER_PAGE_GEN_RATE: "0",
2831
+ };
2832
+ }
2833
+ async function ensureQueueKickerInstalled() {
2834
+ try {
2835
+ if (process.platform !== "darwin")
2836
+ return { ok: false, detail: "not macOS" };
2837
+ if (!runtimeReady())
2838
+ return { ok: false, detail: "runtime not ready" };
2839
+ // Gate: install the kicker when SOMETHING is draftable. Two paths qualify:
2840
+ // (a) a managed product project is ready (promotion lane), OR
2841
+ // (b) personal_brand mode is on AND the persona is ready (self-promo lane).
2842
+ // Path (b) was the 2026-06-30 gap: a personal-brand-only setup has no managed
2843
+ // project, so the old `anyReady` check was always false and the kicker never
2844
+ // installed (no drafts, no first-run kick). The persona is excluded from
2845
+ // managed scope by design, so check it explicitly.
2846
+ const productReady = listManagedProjectStatus().some((p) => p.ready);
2847
+ const personaActive = currentFlags().personal_brand && personaReady();
2848
+ if (!productReady && !personaActive) {
2849
+ return {
2850
+ ok: false,
2851
+ detail: "no ready project or active persona yet",
2852
+ };
2853
+ }
2854
+ const logDir = path.join(repoDir(), "skill", "logs");
2855
+ try {
2856
+ fs.mkdirSync(logDir, { recursive: true });
2857
+ }
2858
+ catch {
2859
+ /* best-effort */
2860
+ }
2861
+ const xml = plistXml({
2862
+ label: TWITTER_AUTOPILOT_LABEL,
2863
+ // Run the DRAFT-AND-PUBLISH wrapper, NOT run-twitter-cycle.sh directly:
2864
+ // it runs the cycle (DRAFT_ONLY + queue) then MERGES the plan into the
2865
+ // review-queue cards. The cycle alone leaves drafts in an orphan /tmp plan
2866
+ // nobody reads (the 2026-06-24 merge gap). This is the ONLY card producer.
2867
+ programArgs: ["bash", path.join(repoDir(), "skill", "run-draft-and-publish.sh")],
2868
+ intervalSecs: QUEUE_KICKER_INTERVAL_SECS,
2869
+ runAtLoad: false, // don't fire a heavy cycle the instant Claude launches
2870
+ stdoutLog: path.join(logDir, "launchd-twitter-cycle-stdout.log"),
2871
+ stderrLog: path.join(logDir, "launchd-twitter-cycle-stderr.log"),
2872
+ extraEnv: kickerEnv(),
2873
+ });
2874
+ // Content-aware install: an existing box has the OLD kicker plist pointing at
2875
+ // run-twitter-cycle.sh (no merge step). ensurePlist won't overwrite, so detect
2876
+ // a drifted plist and rewrite + reload it. Otherwise the merge fix never
2877
+ // reaches an already-installed kicker.
2878
+ const uid = process.getuid ? process.getuid() : 0;
2879
+ let cur = null;
2880
+ try {
2881
+ cur = fs.readFileSync(TWITTER_AUTOPILOT_PLIST, "utf-8");
2882
+ }
2883
+ catch {
2884
+ cur = null;
2885
+ }
2886
+ let detail;
2887
+ if (cur === xml) {
2888
+ const res = await loadPlist(TWITTER_AUTOPILOT_LABEL, TWITTER_AUTOPILOT_PLIST, uid);
2889
+ detail = `current (load rc=${res.code})`;
2890
+ }
2891
+ else {
2892
+ if (cur !== null) {
2893
+ await unloadPlist(TWITTER_AUTOPILOT_LABEL, TWITTER_AUTOPILOT_PLIST, uid);
2894
+ }
2895
+ fs.mkdirSync(path.dirname(TWITTER_AUTOPILOT_PLIST), { recursive: true });
2896
+ fs.writeFileSync(TWITTER_AUTOPILOT_PLIST, xml, "utf-8");
2897
+ const res = await loadPlist(TWITTER_AUTOPILOT_LABEL, TWITTER_AUTOPILOT_PLIST, uid);
2898
+ detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
2899
+ // First-EVER install (cur === null): fire ONE immediate cycle so a brand-new
2900
+ // user gets their first drafts at setup completion instead of waiting up to a
2901
+ // full QUEUE_KICKER_INTERVAL_SECS tick. `launchctl kickstart` runs the job
2902
+ // THROUGH launchd (full baked env + the run-*-singleton.sh lock), so it is
2903
+ // NOT a bare manual kick — it cannot produce the empty-plan artifact that a
2904
+ // hand-run of run-twitter-cycle.sh would. We keep RunAtLoad=false so this
2905
+ // does NOT re-fire on every later Claude launch or on a drift-rewrite; the
2906
+ // cur === null gate restricts the kick to true first-time onboarding only.
2907
+ if (cur === null) {
2908
+ // First-run boost marker: run-draft-and-publish.sh reads this and widens
2909
+ // the first draft cycle(s) to a 48h discovery window with the top-1 card
2910
+ // cap lifted (top 5), so a brand-new user's first review batch shows
2911
+ // SEVERAL real drafts instead of one (or none). The wrapper deletes the
2912
+ // marker as soon as a merge delivers cards, or after 24h without any, so
2913
+ // every later cycle runs the standard 24h + top-1 logic. Best-effort: a
2914
+ // failed write just means a standard first cycle.
2915
+ try {
2916
+ const stateDir = sapsStateDir();
2917
+ fs.mkdirSync(stateDir, { recursive: true });
2918
+ fs.writeFileSync(path.join(stateDir, "first-run-boost.json"), JSON.stringify({ created_at: new Date().toISOString() }) + "\n", "utf-8");
2919
+ }
2920
+ catch (e) {
2921
+ console.error("[social-autoposter-mcp] first-run boost marker write failed:", e?.message || e);
2922
+ }
2923
+ const kick = await run("launchctl", ["kickstart", `gui/${uid}/${TWITTER_AUTOPILOT_LABEL}`], { timeoutMs: 15_000 });
2924
+ detail += ` + first-run kick (rc=${kick.code})`;
2925
+ }
2926
+ }
2927
+ return { ok: true, detail };
2928
+ }
2929
+ catch (e) {
2930
+ return { ok: false, detail: e?.message || String(e) };
2931
+ }
2932
+ }
2933
+ // ---- launchd reaper: kill leaked agent-mode claude worker sessions ----------
2934
+ // Independent guardrail (NOT gated on a project being ready): the leak happens
2935
+ // whenever the scheduled-task workers fire, and a no-leak run is a cheap no-op.
2936
+ // Runs the stdlib-only reaper under SYSTEM python (always present, zero deps) so
2937
+ // it works even before the owned runtime provisions. Content-aware install so an
2938
+ // already-installed box picks up a changed interval/path on the next Claude boot.
2939
+ const REAPER_INTERVAL_SECS = 60; // match the ~1/min worker spawn cadence
2940
+ async function ensureClaudeReaperInstalled() {
2941
+ try {
2942
+ if (process.platform !== "darwin")
2943
+ return { ok: false, detail: "not macOS" };
2944
+ const logDir = path.join(repoDir(), "skill", "logs");
2945
+ try {
2946
+ fs.mkdirSync(logDir, { recursive: true });
2947
+ }
2948
+ catch {
2949
+ /* best-effort */
2950
+ }
2951
+ const xml = plistXml({
2952
+ label: REAPER_LABEL,
2953
+ programArgs: ["/usr/bin/python3", path.join(repoDir(), "scripts", "reap_stale_claude_sessions.py")],
2954
+ intervalSecs: REAPER_INTERVAL_SECS,
2955
+ runAtLoad: true, // clean up an existing backlog the instant Claude launches
2956
+ stdoutLog: path.join(logDir, "launchd-claude-reaper-stdout.log"),
2957
+ stderrLog: path.join(logDir, "launchd-claude-reaper-stderr.log"),
2958
+ });
2959
+ const uid = process.getuid ? process.getuid() : 0;
2960
+ let cur = null;
2961
+ try {
2962
+ cur = fs.readFileSync(REAPER_PLIST, "utf-8");
2963
+ }
2964
+ catch {
2965
+ cur = null;
2966
+ }
2967
+ let detail;
2968
+ if (cur === xml) {
2969
+ const res = await loadPlist(REAPER_LABEL, REAPER_PLIST, uid);
2970
+ detail = `current (load rc=${res.code})`;
2971
+ }
2972
+ else {
2973
+ if (cur !== null) {
2974
+ await unloadPlist(REAPER_LABEL, REAPER_PLIST, uid);
2975
+ }
2976
+ fs.mkdirSync(path.dirname(REAPER_PLIST), { recursive: true });
2977
+ fs.writeFileSync(REAPER_PLIST, xml, "utf-8");
2978
+ const res = await loadPlist(REAPER_LABEL, REAPER_PLIST, uid);
2979
+ detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
2980
+ }
2981
+ return { ok: true, detail };
2982
+ }
2983
+ catch (e) {
2984
+ return { ok: false, detail: e?.message || String(e) };
2985
+ }
2986
+ }
2987
+ // ---- launchd feedback digest: card decisions -> learned_preferences ---------
2988
+ // Hourly, stdlib-only under SYSTEM python (http_api + learned_preferences use
2989
+ // urllib/json only; run_claude.sh resolves the claude CLI itself). A run with
2990
+ // no unprocessed review_events for this install is a cheap no-op, so the job
2991
+ // is installed unconditionally like the reaper. Content-aware install so an
2992
+ // already-installed box picks up changed args on the next Claude boot.
2993
+ const FEEDBACK_DIGEST_INTERVAL_SECS = 3600;
2994
+ async function ensureFeedbackDigestInstalled() {
2995
+ try {
2996
+ if (process.platform !== "darwin")
2997
+ return { ok: false, detail: "not macOS" };
2998
+ const logDir = path.join(repoDir(), "skill", "logs");
2999
+ try {
3000
+ fs.mkdirSync(logDir, { recursive: true });
3001
+ }
3002
+ catch {
3003
+ /* best-effort */
3004
+ }
3005
+ const xml = plistXml({
3006
+ label: FEEDBACK_DIGEST_LABEL,
3007
+ programArgs: ["/usr/bin/python3", path.join(repoDir(), "scripts", "feedback_digest.py")],
3008
+ intervalSecs: FEEDBACK_DIGEST_INTERVAL_SECS,
3009
+ runAtLoad: false, // no boot-time Claude runs; the hourly tick is enough
3010
+ stdoutLog: path.join(logDir, "launchd-feedback-digest-stdout.log"),
3011
+ stderrLog: path.join(logDir, "launchd-feedback-digest-stderr.log"),
3012
+ });
3013
+ const uid = process.getuid ? process.getuid() : 0;
3014
+ let cur = null;
3015
+ try {
3016
+ cur = fs.readFileSync(FEEDBACK_DIGEST_PLIST, "utf-8");
3017
+ }
3018
+ catch {
3019
+ cur = null;
3020
+ }
3021
+ let detail;
3022
+ if (cur === xml) {
3023
+ const res = await loadPlist(FEEDBACK_DIGEST_LABEL, FEEDBACK_DIGEST_PLIST, uid);
3024
+ detail = `current (load rc=${res.code})`;
3025
+ }
3026
+ else {
3027
+ if (cur !== null) {
3028
+ await unloadPlist(FEEDBACK_DIGEST_LABEL, FEEDBACK_DIGEST_PLIST, uid);
3029
+ }
3030
+ fs.mkdirSync(path.dirname(FEEDBACK_DIGEST_PLIST), { recursive: true });
3031
+ fs.writeFileSync(FEEDBACK_DIGEST_PLIST, xml, "utf-8");
3032
+ const res = await loadPlist(FEEDBACK_DIGEST_LABEL, FEEDBACK_DIGEST_PLIST, uid);
3033
+ detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
3034
+ }
3035
+ return { ok: true, detail };
3036
+ }
3037
+ catch (e) {
3038
+ return { ok: false, detail: e?.message || String(e) };
3039
+ }
3040
+ }
3041
+ // Install/refresh the autopilot stall watchdog launchd job. Runs off the owned
3042
+ // venv python so scripts/autopilot_stall_watch.py can import sentry_init +
3043
+ // sentry-sdk. RunAtLoad so a box that boots already-stalled reports promptly.
3044
+ async function ensureStallWatchInstalled() {
3045
+ try {
3046
+ if (process.platform !== "darwin")
3047
+ return { ok: false, detail: "not macOS" };
3048
+ if ((process.env.S4L_STALL_WATCH ?? process.env.SAPS_STALL_WATCH) === "0")
3049
+ return { ok: false, detail: "disabled (S4L_STALL_WATCH=0)" };
3050
+ const logDir = path.join(repoDir(), "skill", "logs");
3051
+ try {
3052
+ fs.mkdirSync(logDir, { recursive: true });
3053
+ }
3054
+ catch {
3055
+ /* best-effort */
3056
+ }
3057
+ const xml = plistXml({
3058
+ label: STALL_WATCH_LABEL,
3059
+ programArgs: [resolvePython(), path.join(repoDir(), "scripts", "autopilot_stall_watch.py")],
3060
+ intervalSecs: STALL_WATCH_INTERVAL_SECS,
3061
+ runAtLoad: true,
3062
+ stdoutLog: path.join(logDir, "launchd-stall-watch-stdout.log"),
3063
+ stderrLog: path.join(logDir, "launchd-stall-watch-stderr.log"),
3064
+ });
3065
+ const uid = process.getuid ? process.getuid() : 0;
3066
+ let cur = null;
3067
+ try {
3068
+ cur = fs.readFileSync(STALL_WATCH_PLIST, "utf-8");
3069
+ }
3070
+ catch {
3071
+ cur = null;
3072
+ }
3073
+ let detail;
3074
+ if (cur === xml) {
3075
+ const res = await loadPlist(STALL_WATCH_LABEL, STALL_WATCH_PLIST, uid);
3076
+ detail = `current (load rc=${res.code})`;
3077
+ }
3078
+ else {
3079
+ if (cur !== null) {
3080
+ await unloadPlist(STALL_WATCH_LABEL, STALL_WATCH_PLIST, uid);
3081
+ }
3082
+ fs.mkdirSync(path.dirname(STALL_WATCH_PLIST), { recursive: true });
3083
+ fs.writeFileSync(STALL_WATCH_PLIST, xml, "utf-8");
3084
+ const res = await loadPlist(STALL_WATCH_LABEL, STALL_WATCH_PLIST, uid);
3085
+ detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
3086
+ }
3087
+ return { ok: true, detail };
3088
+ }
3089
+ catch (e) {
3090
+ return { ok: false, detail: e?.message || String(e) };
3091
+ }
3092
+ }
3093
+ async function ensureMemorySnapshotInstalled() {
3094
+ try {
3095
+ if (process.platform !== "darwin")
3096
+ return { ok: false, detail: "not macOS" };
3097
+ if ((process.env.S4L_MEMORY_SNAPSHOT ?? process.env.SAPS_MEMORY_SNAPSHOT) === "0")
3098
+ return { ok: false, detail: "disabled (S4L_MEMORY_SNAPSHOT=0)" };
3099
+ const logDir = path.join(repoDir(), "skill", "logs");
3100
+ try {
3101
+ fs.mkdirSync(logDir, { recursive: true });
3102
+ }
3103
+ catch {
3104
+ /* best-effort */
3105
+ }
3106
+ const xml = plistXml({
3107
+ label: MEMORY_SNAPSHOT_LABEL,
3108
+ programArgs: ["/bin/bash", path.join(repoDir(), "skill", "memory-snapshot.sh")],
3109
+ intervalSecs: MEMORY_SNAPSHOT_INTERVAL_SECS,
3110
+ runAtLoad: true,
3111
+ stdoutLog: path.join(logDir, "launchd-memory-snapshot-stdout.log"),
3112
+ stderrLog: path.join(logDir, "launchd-memory-snapshot-stderr.log"),
3113
+ });
3114
+ const uid = process.getuid ? process.getuid() : 0;
3115
+ let cur = null;
3116
+ try {
3117
+ cur = fs.readFileSync(MEMORY_SNAPSHOT_PLIST, "utf-8");
3118
+ }
3119
+ catch {
3120
+ cur = null;
3121
+ }
3122
+ let detail;
3123
+ if (cur === xml) {
3124
+ const res = await loadPlist(MEMORY_SNAPSHOT_LABEL, MEMORY_SNAPSHOT_PLIST, uid);
3125
+ detail = `current (load rc=${res.code})`;
3126
+ }
3127
+ else {
3128
+ if (cur !== null) {
3129
+ await unloadPlist(MEMORY_SNAPSHOT_LABEL, MEMORY_SNAPSHOT_PLIST, uid);
3130
+ }
3131
+ fs.mkdirSync(path.dirname(MEMORY_SNAPSHOT_PLIST), { recursive: true });
3132
+ fs.writeFileSync(MEMORY_SNAPSHOT_PLIST, xml, "utf-8");
3133
+ const res = await loadPlist(MEMORY_SNAPSHOT_LABEL, MEMORY_SNAPSHOT_PLIST, uid);
3134
+ detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
3135
+ }
3136
+ return { ok: true, detail };
3137
+ }
3138
+ catch (e) {
3139
+ return { ok: false, detail: e?.message || String(e) };
3140
+ }
3141
+ }
3142
+ // Install/refresh the on-screen overlay watcher launchd job. Promotes the
3143
+ // harness status overlay from a best-effort, fired-from-other-tools nicety to a
3144
+ // first-class self-healing job. We run `harness_overlay.py watch` directly in
3145
+ // the FOREGROUND under KeepAlive (RunAtLoad starts it at boot; launchd restarts
3146
+ // it if it ever exits) rather than a StartInterval that re-fires a spawn-and-exit
3147
+ // supervisor: on macOS that supervisor races launchd, which SIGKILLs the job's
3148
+ // process group the instant the kicker shell exits and reaps the just-spawned
3149
+ // watcher before it can detach (verified on the box: the watcher caught the
3150
+ // group SIGTERM and cleared the overlay every cycle). harness_overlay.py holds a
3151
+ // singleton flock so the MCP's best-effort run-overlay-watch.sh lane can never
3152
+ // double-paint. S4L_PYTHON is baked by plistXml; we add S4L_LOG_DIR (so the
3153
+ // watcher reads the same cycle logs to decide busy/idle) and the harness CDP
3154
+ // URL. Disable with S4L_OVERLAY_WATCH=0.
3155
+ async function ensureOverlayWatchInstalled() {
3156
+ try {
3157
+ if (process.platform !== "darwin")
3158
+ return { ok: false, detail: "not macOS" };
3159
+ if ((process.env.S4L_OVERLAY_WATCH ?? process.env.SAPS_OVERLAY_WATCH) === "0")
3160
+ return { ok: false, detail: "disabled (S4L_OVERLAY_WATCH=0)" };
3161
+ const logDir = path.join(repoDir(), "skill", "logs");
3162
+ try {
3163
+ fs.mkdirSync(logDir, { recursive: true });
3164
+ }
3165
+ catch {
3166
+ /* best-effort */
3167
+ }
3168
+ const xml = plistXml({
3169
+ label: OVERLAY_WATCH_LABEL,
3170
+ programArgs: [resolvePython(), path.join(repoDir(), "scripts", "harness_overlay.py"), "watch"],
3171
+ intervalSecs: 0,
3172
+ keepAlive: true,
3173
+ runAtLoad: true,
3174
+ stdoutLog: path.join(logDir, "launchd-overlay-watch-stdout.log"),
3175
+ stderrLog: path.join(logDir, "launchd-overlay-watch-stderr.log"),
3176
+ extraEnv: {
3177
+ S4L_LOG_DIR: logDir,
3178
+ TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
3179
+ },
3180
+ });
3181
+ const uid = process.getuid ? process.getuid() : 0;
3182
+ let cur = null;
3183
+ try {
3184
+ cur = fs.readFileSync(OVERLAY_WATCH_PLIST, "utf-8");
3185
+ }
3186
+ catch {
3187
+ cur = null;
3188
+ }
3189
+ let detail;
3190
+ if (cur === xml) {
3191
+ const res = await loadPlist(OVERLAY_WATCH_LABEL, OVERLAY_WATCH_PLIST, uid);
3192
+ detail = `current (load rc=${res.code})`;
3193
+ }
3194
+ else {
3195
+ if (cur !== null) {
3196
+ await unloadPlist(OVERLAY_WATCH_LABEL, OVERLAY_WATCH_PLIST, uid);
3197
+ }
3198
+ fs.mkdirSync(path.dirname(OVERLAY_WATCH_PLIST), { recursive: true });
3199
+ fs.writeFileSync(OVERLAY_WATCH_PLIST, xml, "utf-8");
3200
+ const res = await loadPlist(OVERLAY_WATCH_LABEL, OVERLAY_WATCH_PLIST, uid);
3201
+ detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
3202
+ }
3203
+ return { ok: true, detail };
3204
+ }
3205
+ catch (e) {
3206
+ return { ok: false, detail: e?.message || String(e) };
3207
+ }
3208
+ }
3209
+ // Is the draft schedule registered AND running for the LIVE account?
3210
+ // 'ok' — worker tasks present+enabled and FIRING (host actively running).
3211
+ // 'disabled' — present but a worker task is disabled.
3212
+ // 'missing' — not firing anywhere (orphaned / not registered for the live
3213
+ // account) -> dashboard offers "Set up draft schedule".
3214
+ // The algorithm (live-account detection by freshest lastRunAt, firing window,
3215
+ // etc.) lives in ONE place: scripts/schedule_state.py. The Python menu bar imports
3216
+ // that module in-process; we shell out to it here. Keeping a single implementation
3217
+ // is the whole point — the two surfaces can no longer drift. The script is
3218
+ // stdlib-only and resolvePython() falls back to system python3, so this works even
3219
+ // before the owned runtime is provisioned. Any failure -> "missing" (safe: a
3220
+ // schedule we can't read is treated as not-firing, which only ever surfaces the
3221
+ // re-arm affordance, never a false "ok").
3222
+ async function scheduleState() {
3223
+ try {
3224
+ const res = await runPython("scripts/schedule_state.py", [], { timeoutMs: 15_000 });
3225
+ const state = JSON.parse(res.stdout.trim()).state;
3226
+ if (state === "ok" || state === "disabled")
3227
+ return state;
3228
+ return "missing";
3229
+ }
3230
+ catch {
3231
+ return "missing";
3232
+ }
3233
+ }
3234
+ // Assemble everything the panel needs in one shot (projects + X + autopilot +
3235
+ // version). Resilient: any probe that throws degrades to a safe default rather
3236
+ // than failing the whole snapshot.
3237
+ async function buildSnapshot() {
3238
+ // Single source of truth: scripts/snapshot.py computes the snapshot PURELY from
3239
+ // the stateful files (the SAME module the always-on menu bar imports directly,
3240
+ // so the two surfaces can't diverge — and the menu bar no longer depends on this
3241
+ // Node process being up). We shell out for the data, then layer on the MCP-only
3242
+ // side effects snapshot.py deliberately omits (it is a pure reader): the doctor
3243
+ // phase, onboarding-milestone telemetry, and persistence.
3244
+ let snap;
3245
+ try {
3246
+ const res = await runPython("scripts/snapshot.py", [], { timeoutMs: 95_000 });
3247
+ snap = JSON.parse(res.stdout.trim().split("\n").slice(-50).join("\n"));
3248
+ if (snap && snap._error)
3249
+ throw new Error(String(snap._error));
3250
+ }
3251
+ catch {
3252
+ // Never fail the whole panel: fall back to a minimal locally-derived snapshot.
3253
+ snap = {
3254
+ projects: [], projects_total: 0, projects_ready: 0,
3255
+ x_connected: false, x_state: "", x_handle: null,
3256
+ autopilot_on: false, autopilot_stalled: false, schedule_state: "missing",
3257
+ auto_update_on: false, version: VERSION, latest_version: null,
3258
+ update_available: false, runtime_ready: runtimeReady(),
3259
+ runtime_provisioning: isProvisioning(), setup_complete: false,
3260
+ mode: currentMode(), flags: currentFlags(), onboarding: onboardingSnapshot(),
3261
+ };
3262
+ }
3263
+ // MCP-only side effects (snapshot.py is a pure reader and does none of these):
3264
+ // the onboarding LEDGER writes here are telemetry/history; the live DISPLAY
3265
+ // statuses already come from snapshot.py's overlay.
3266
+ // Is the always-on menu bar app actually loaded? snapshot.py can't answer this
3267
+ // (it's a launchctl check the Node side owns), so layer it on here. The panel
3268
+ // uses it to offer a one-click "restart menu bar" when the tray was quit.
3269
+ snap.menubar_running = await menubarRunning();
3270
+ await ensureDoctorPhase(snap.x_connected ? "full" : "pre_connect");
3271
+ if (snap.runtime_ready)
3272
+ completeOnboardingMilestone("runtime_ready");
3273
+ if (snap.x_connected)
3274
+ completeOnboardingMilestone("x_connected", { state: snap.x_state || "connected" });
3275
+ if ((snap.projects_ready || 0) > 0)
3276
+ completeOnboardingMilestone("project_ready", { missing_count: 0 });
3277
+ if (snap.schedule_state === "ok")
3278
+ completeOnboardingMilestone("tasks_scheduled");
3279
+ // Persist this snapshot so the menu bar can answer "set up?" the SAME way when
3280
+ // the loopback server is unreachable (Claude Desktop closed or mid-restart)
3281
+ // instead of falling back to a divergent local rule. Refreshed on every
3282
+ // dashboard call (≈1s while the menu bar polls online), so the on-disk copy is
3283
+ // never more than a poll stale. Best-effort; never fails the snapshot.
3284
+ persistStatusSummary(snap);
3285
+ return snap;
3286
+ }
3287
+ // ---- dashboard localhost fallback -----------------------------------------
3288
+ // When the connected host doesn't support MCP Apps UI (Claude Code / Cowork
3289
+ // today), serve the SAME dist/panel.html from a loopback HTTP server. The page
3290
+ // detects it's running over HTTP (window.__SAPS_BRIDGE__) and routes every
3291
+ // app.callServerTool through POST /tool/<name>, which replays the exact captured
3292
+ // handler in TOOL_HANDLERS. No pipeline or front-end logic is duplicated.
3293
+ // True if the host advertised it can render our ui:// HTML resource inline.
3294
+ function hostRendersAppUi() {
3295
+ try {
3296
+ const caps = (server.server.getClientCapabilities?.() ?? null);
3297
+ const uiCap = getUiCapability(caps);
3298
+ return !!uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE);
3299
+ }
3300
+ catch {
3301
+ return false;
3302
+ }
3303
+ }
3304
+ let localPanel = null;
3305
+ // Read the built panel.html and flip it into HTTP-bridge mode by injecting a
3306
+ // flag the front-end reads at boot. Same bytes as the inline ui:// resource,
3307
+ // minus the postMessage host (there's none over loopback).
3308
+ function widgetHtmlForHttp(file) {
3309
+ const html = fs.readFileSync(path.join(DIST_DIR, file), "utf-8");
3310
+ const inject = `<script>window.__SAPS_BRIDGE__=${JSON.stringify("http")};</script>`;
3311
+ if (html.includes("</head>"))
3312
+ return html.replace("</head>", inject + "</head>");
3313
+ return inject + html;
3314
+ }
3315
+ function panelHtmlForHttp() {
3316
+ return widgetHtmlForHttp("panel.html");
3317
+ }
3318
+ function readBody(req) {
3319
+ return new Promise((resolve, reject) => {
3320
+ const chunks = [];
3321
+ req.on("data", (c) => chunks.push(Buffer.from(c)));
3322
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
3323
+ req.on("error", reject);
3324
+ });
3325
+ }
3326
+ // Start (or reuse) the loopback HTTP server that serves the dashboard plus a
3327
+ // /tool/<name> dispatch endpoint backed by TOOL_HANDLERS. Bound to 127.0.0.1 on
3328
+ // an OS-assigned ephemeral port so nothing is exposed off-box.
3329
+ function startLocalPanel() {
3330
+ if (localPanel)
3331
+ return Promise.resolve(localPanel.url);
3332
+ return new Promise((resolve, reject) => {
3333
+ const srv = http.createServer(async (req, res) => {
3334
+ try {
3335
+ const url = new URL(req.url || "/", "http://127.0.0.1");
3336
+ if (req.method === "GET" &&
3337
+ (url.pathname === "/" || url.pathname === "/panel" || url.pathname === "/index.html")) {
3338
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
3339
+ res.end(panelHtmlForHttp());
3340
+ return;
3341
+ }
3342
+ if (req.method === "GET" &&
3343
+ (url.pathname === "/product-link" || url.pathname === "/product-link.html")) {
3344
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
3345
+ res.end(widgetHtmlForHttp("product-link.html"));
3346
+ return;
3347
+ }
3348
+ if (req.method === "GET" && url.pathname === "/health") {
3349
+ res.writeHead(200, { "Content-Type": "application/json" });
3350
+ res.end(JSON.stringify({ ok: true }));
3351
+ return;
3352
+ }
3353
+ if (req.method === "POST" && url.pathname.startsWith("/tool/")) {
3354
+ const name = decodeURIComponent(url.pathname.slice("/tool/".length));
3355
+ const handler = TOOL_HANDLERS[name];
3356
+ if (!handler) {
3357
+ res.writeHead(404, { "Content-Type": "application/json" });
3358
+ res.end(JSON.stringify({ isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] }));
3359
+ return;
3360
+ }
3361
+ const raw = await readBody(req);
3362
+ let args = {};
3363
+ if (raw.trim()) {
3364
+ try {
3365
+ args = JSON.parse(raw);
3366
+ }
3367
+ catch {
3368
+ args = {};
3369
+ }
3370
+ }
3371
+ let result;
3372
+ try {
3373
+ result = await handler(args ?? {}, {});
3374
+ }
3375
+ catch (e) {
3376
+ result = { isError: true, content: [{ type: "text", text: String(e?.message || e) }] };
3377
+ }
3378
+ res.writeHead(200, { "Content-Type": "application/json" });
3379
+ res.end(JSON.stringify(result ?? {}));
3380
+ return;
3381
+ }
3382
+ res.writeHead(404, { "Content-Type": "text/plain" });
3383
+ res.end("not found");
3384
+ }
3385
+ catch (e) {
3386
+ try {
3387
+ res.writeHead(500, { "Content-Type": "text/plain" });
3388
+ res.end(String(e?.message || e));
3389
+ }
3390
+ catch { /* response already sent */ }
3391
+ }
3392
+ });
3393
+ srv.on("error", reject);
3394
+ // Optional fixed port (S4L_PANEL_PORT) for deterministic addressing; default
3395
+ // is an OS-assigned ephemeral port.
3396
+ const wantPort = Number(process.env.S4L_PANEL_PORT ?? process.env.SAPS_PANEL_PORT) || 0;
3397
+ srv.listen(wantPort, "127.0.0.1", () => {
3398
+ const addr = srv.address();
3399
+ const port = typeof addr === "object" && addr ? addr.port : 0;
3400
+ localPanel = { url: `http://127.0.0.1:${port}/`, server: srv };
3401
+ writePanelUrl(localPanel.url);
3402
+ resolve(localPanel.url);
3403
+ });
3404
+ });
3405
+ }
3406
+ // Publish the loopback URL to stable files so out-of-process readers can find
3407
+ // the ephemeral port without scraping `lsof`:
3408
+ // - panel-url plain text, for the Claude Code side-panel reverse proxy.
3409
+ // - panel-endpoint.json richer (url + version + pid), for the menu bar app,
3410
+ // which POSTs /tool/<name> here for live data.
3411
+ // Best-effort: a write failure never blocks the panel (readers re-check /health).
3412
+ function writePanelUrl(url) {
3413
+ try {
3414
+ const dir = path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp");
3415
+ fs.mkdirSync(dir, { recursive: true });
3416
+ fs.writeFileSync(path.join(dir, "panel-url"), url, "utf-8");
3417
+ fs.writeFileSync(path.join(dir, "panel-endpoint.json"), JSON.stringify({ url, pid: process.pid, version: VERSION, started_at: new Date().toISOString() }, null, 2) + "\n", "utf-8");
3418
+ }
3419
+ catch (e) {
3420
+ console.error("[social-autoposter-mcp] writePanelUrl failed:", e?.message || e);
3421
+ }
3422
+ }
3423
+ // The owned state dir, honoring S4L_STATE_DIR (matches menubar/s4l_state.py).
3424
+ function sapsStateDir() {
3425
+ return (process.env.S4L_STATE_DIR ||
3426
+ process.env.SAPS_STATE_DIR ||
3427
+ path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp"));
3428
+ }
3429
+ // Has the user explicitly chosen an engagement mode? mode.json is written by the
3430
+ // engagement_mode tool (setup) and the menu-bar toggle. Used to complete the
3431
+ // mode_chosen onboarding milestone. (Source of truth: scripts/saps_mode.py.)
3432
+ function modeChosen() {
3433
+ try {
3434
+ return fs.existsSync(path.join(sapsStateDir(), "mode.json"));
3435
+ }
3436
+ catch {
3437
+ return false;
3438
+ }
3439
+ }
3440
+ // The current engagement lane flags, surfaced in the snapshot so the dashboard
3441
+ // AND menu bar read them from ONE place (mode.json, the same file saps_mode.py
3442
+ // writes). Mirrors saps_mode.py get_flags(): explicit flag keys win; else map a
3443
+ // legacy {"mode": ...} string; else default personal ON / promotion OFF.
3444
+ function currentFlags() {
3445
+ try {
3446
+ const d = JSON.parse(fs.readFileSync(path.join(sapsStateDir(), "mode.json"), "utf-8"));
3447
+ if ("personal_brand" in d || "promotion" in d) {
3448
+ return { personal_brand: !!d.personal_brand, promotion: !!d.promotion };
3449
+ }
3450
+ const m = (d.mode || "").trim();
3451
+ if (m === "personal_brand")
3452
+ return { personal_brand: true, promotion: false };
3453
+ if (m === "promotion")
3454
+ return { personal_brand: false, promotion: true };
3455
+ }
3456
+ catch {
3457
+ /* fall through to default */
3458
+ }
3459
+ return { personal_brand: true, promotion: false };
3460
+ }
3461
+ // Derived legacy single-mode string (personal wins when on). Defaults to
3462
+ // personal_brand when unset (2026-06-29 default flip).
3463
+ function currentMode() {
3464
+ return currentFlags().personal_brand ? "personal_brand" : "promotion";
3465
+ }
3466
+ // ---- Cross-instance "posting active" flag ----------------------------------
3467
+ // posting-active.json in the shared state dir is the CROSS-MCP-INSTANCE version
3468
+ // of the in-process `postingActive` flag. The autopilot scan and the post
3469
+ // sometimes run in the SAME MCP (the in-process flag covers that) and sometimes
3470
+ // in TWO SEPARATE MCP instances (different agent sessions each spawn their own).
3471
+ // A file every instance's draft-cycle scan reads makes the mutual exclusion hold
3472
+ // regardless of which topology Claude Desktop happens to use. Heartbeat'd with a
3473
+ // short TTL so a crashed poster's flag self-clears and never wedges scanning.
3474
+ const POSTING_FLAG_TTL_MS = 45_000;
3475
+ let postingFlagHeartbeat = null;
3476
+ function postingFlagPath() {
3477
+ return path.join(sapsStateDir(), "posting-active.json");
3478
+ }
3479
+ function writePostingFlag() {
3480
+ try {
3481
+ fs.mkdirSync(sapsStateDir(), { recursive: true });
3482
+ fs.writeFileSync(postingFlagPath(), JSON.stringify({ pid: process.pid, expires_at: Date.now() + POSTING_FLAG_TTL_MS }) + "\n", "utf-8");
3483
+ }
3484
+ catch {
3485
+ /* best effort */
3486
+ }
3487
+ }
3488
+ function startPostingFlagHeartbeat() {
3489
+ writePostingFlag();
3490
+ if (postingFlagHeartbeat)
3491
+ return;
3492
+ // Refresh well within the TTL so a long batch stays flagged, but a dead poster
3493
+ // expires within POSTING_FLAG_TTL_MS.
3494
+ postingFlagHeartbeat = setInterval(() => {
3495
+ if (postingActive)
3496
+ writePostingFlag();
3497
+ }, Math.floor(POSTING_FLAG_TTL_MS / 2));
3498
+ if (typeof postingFlagHeartbeat.unref === "function")
3499
+ postingFlagHeartbeat.unref();
3500
+ }
3501
+ function stopPostingFlagHeartbeat() {
3502
+ if (postingFlagHeartbeat) {
3503
+ clearInterval(postingFlagHeartbeat);
3504
+ postingFlagHeartbeat = null;
3505
+ }
3506
+ try {
3507
+ fs.rmSync(postingFlagPath(), { force: true });
3508
+ }
3509
+ catch {
3510
+ /* best effort */
3511
+ }
3512
+ }
3513
+ // True when ANY MCP instance has a FRESH posting flag on disk. Absent or expired
3514
+ // == not posting. This is what makes a sibling instance's draft-cycle scan defer.
3515
+ function isPostingFlagFresh() {
3516
+ try {
3517
+ const j = JSON.parse(fs.readFileSync(postingFlagPath(), "utf-8"));
3518
+ return typeof j?.expires_at === "number" && j.expires_at > Date.now();
3519
+ }
3520
+ catch {
3521
+ return false;
3522
+ }
3523
+ }
3524
+ // activity.json: a tiny "what's running right now" signal the menu bar reads to
3525
+ // show a loading spinner + label (scanning / drafting / posting / …). Written by
3526
+ // long-running tools, cleared when they finish. Best-effort; absence == idle.
3527
+ let _activityLast = null;
3528
+ let _activityHb = null;
3529
+ function _writeActivityFile(state, label) {
3530
+ try {
3531
+ const dir = sapsStateDir();
3532
+ fs.mkdirSync(dir, { recursive: true });
3533
+ fs.writeFileSync(path.join(dir, "activity.json"), JSON.stringify({ state, label, since: new Date().toISOString() }) + "\n", "utf-8");
3534
+ }
3535
+ catch {
3536
+ /* best effort: a status write must never break the work it's narrating */
3537
+ }
3538
+ }
3539
+ function writeActivity(state, label) {
3540
+ _activityLast = { state, label };
3541
+ _writeActivityFile(state, label);
3542
+ // Heartbeat: re-stamp `since` so the menu bar's staleness TTL (s4l_state.py
3543
+ // ACTIVITY_TTL_SECONDS) never ages out a genuinely-running tool whose current
3544
+ // phase emits no further updates — e.g. a silent multi-minute `claude -p` draft
3545
+ // turn between "Phase 2b-prep" and the next marker. Without this, the spinner
3546
+ // would wrongly blink to idle mid-work; with it, the label is fresh exactly
3547
+ // while the tool runs and the TTL only expires it once clearActivity stops the
3548
+ // heartbeat (or the writer dies). Single shared interval; tracks the latest label.
3549
+ if (!_activityHb) {
3550
+ _activityHb = setInterval(() => {
3551
+ if (_activityLast)
3552
+ _writeActivityFile(_activityLast.state, _activityLast.label);
3553
+ }, 30_000);
3554
+ if (typeof _activityHb.unref === "function")
3555
+ _activityHb.unref();
3556
+ }
3557
+ }
3558
+ function clearActivity() {
3559
+ _activityLast = null;
3560
+ if (_activityHb) {
3561
+ clearInterval(_activityHb);
3562
+ _activityHb = null;
3563
+ }
3564
+ try {
3565
+ fs.rmSync(path.join(sapsStateDir(), "activity.json"), { force: true });
3566
+ }
3567
+ catch {
3568
+ /* best effort */
3569
+ }
3570
+ }
3571
+ // status-summary.json: the server's last-known dashboard snapshot, persisted so
3572
+ // the menu bar's OFFLINE path (loopback unreachable) reads a precomputed answer
3573
+ // instead of re-deriving setup_complete with its own copy of the rules. One
3574
+ // producer (buildSnapshot), one consumer (menubar/s4l_state.py snapshot()).
3575
+ // Written atomically so a 1s poll never sees a half-written file.
3576
+ function persistStatusSummary(snap) {
3577
+ try {
3578
+ const dir = sapsStateDir();
3579
+ fs.mkdirSync(dir, { recursive: true });
3580
+ const tmp = path.join(dir, `status-summary.json.${process.pid}.tmp`);
3581
+ fs.writeFileSync(tmp, JSON.stringify({ ...snap, written_at: new Date().toISOString() }) + "\n", "utf-8");
3582
+ fs.renameSync(tmp, path.join(dir, "status-summary.json"));
3583
+ }
3584
+ catch {
3585
+ /* best effort: a status cache write must never break the dashboard */
3586
+ }
3587
+ }
3588
+ // Signal the menu bar that a fresh draft batch is ready for pop-up review. The
3589
+ // chat-table review path is unchanged and still works; this just ALSO lets the
3590
+ // corner cards drive review (both surfaces de-dup via the plan's `posted` flag).
3591
+ // The menu bar reads review-request.json, presents the cards, posts via the
3592
+ // loopback post_drafts tool, then clears the file. Best-effort: a write failure
3593
+ // just means no pop-ups this batch (chat review still works).
3594
+ function writeReviewRequest(req) {
3595
+ try {
3596
+ const dir = sapsStateDir();
3597
+ fs.mkdirSync(dir, { recursive: true });
3598
+ fs.writeFileSync(path.join(dir, "review-request.json"), JSON.stringify(req, null, 2) + "\n", "utf-8");
3599
+ }
3600
+ catch (e) {
3601
+ console.error("[social-autoposter-mcp] writeReviewRequest failed:", e?.message || e);
3602
+ }
3603
+ }
3604
+ // Open a URL in the user's default browser, cross-platform. Opening is OPT-IN:
3605
+ // by default we do NOT pop a browser tab. The dashboard already surfaces in-host
3606
+ // (MCP Apps inline) or via the Claude Code side panel / returned loopback URL, so
3607
+ // auto-opening the OS browser on every dashboard call is unwanted noise. Set
3608
+ // S4L_PANEL_OPEN_BROWSER=1 to restore the old auto-open behavior. (The URL is
3609
+ // always returned to the caller regardless, so nothing is lost when we don't open.)
3610
+ async function openInBrowser(url) {
3611
+ if (!(process.env.S4L_PANEL_OPEN_BROWSER ?? process.env.SAPS_PANEL_OPEN_BROWSER))
3612
+ return;
3613
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
3614
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
3615
+ try {
3616
+ await run(cmd, args, { timeoutMs: 10_000 });
3617
+ }
3618
+ catch (e) {
3619
+ console.error("[social-autoposter-mcp] openInBrowser failed:", e?.message || e);
3620
+ }
3621
+ }
3622
+ // ---- Cross-process browser-lock bridge (the REAL posting-priority fix) ------
3623
+ // The SCANNER (run-twitter-cycle.sh) serializes browser access on a mkdir-based
3624
+ // DIRECTORY lock at /tmp/social-autoposter-twitter-browser.lock (skill/lock.sh).
3625
+ // The POSTER (twitter_post_plan.py / twitter_browser.py) serializes on a totally
3626
+ // SEPARATE json file lock (~/.claude/twitter-browser-lock.json) with role:"post"
3627
+ // preemption. The two locks never reference each other, so a post launched from
3628
+ // THIS MCP (or a sibling MCP instance — every autopilot agent session spawns its
3629
+ // own) never actually excluded a live scan: both held "their" lock and drove the
3630
+ // one shared harness Chrome at once, so an approved batch landed 0/N while a scan
3631
+ // churned 118 queries for ~10min (proven live on the remote box 2026-06-23:
3632
+ // /tmp lock pid=scanner AND json lock python:poster role=post, simultaneously).
3633
+ //
3634
+ // The scan that actually holds the browser is a run-twitter-cycle.sh process —
3635
+ // usually a SIBLING (the every-minute launchd cycle), which we have no
3636
+ // ChildProcess for. So we bridge to the lock the scanner truly respects: read
3637
+ // its /tmp pid file, and if a
3638
+ // live run-twitter-cycle.sh holds it, signal it cross-process. Then the post
3639
+ // HOLDS that same /tmp lock for the whole batch so the every-minute autopilot
3640
+ // scan queues behind us (its acquire_lock waits on our live pid) instead of
3641
+ // seizing Chrome mid-post. skill/lock.sh's ownership guard + kill-0 liveness +
3642
+ // 3h stale-reclaim recover the dir if we ever leak it. Never touches a locked
3643
+ // pipeline script or the python json lock.
3644
+ const TW_BROWSER_LOCK_DIR = "/tmp/social-autoposter-twitter-browser.lock";
3645
+ function shellLockHolderPid() {
3646
+ try {
3647
+ const pid = parseInt(fs.readFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), "utf-8").trim(), 10);
3648
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
3649
+ }
3650
+ catch {
3651
+ return null; // no dir / no pid file == lock is free
3652
+ }
3653
+ }
3654
+ function pidAlive(pid) {
3655
+ try {
3656
+ process.kill(pid, 0);
3657
+ return true;
3658
+ }
3659
+ catch {
3660
+ return false;
3661
+ }
3662
+ }
3663
+ // True ONLY when pid is a run-twitter-cycle.sh scan — the one holder a post is
3664
+ // allowed to preempt. Never preempt another poster or an unknown holder.
3665
+ function pidIsScan(pid) {
3666
+ try {
3667
+ const cmd = execFileSync("ps", ["-o", "command=", "-p", String(pid)], {
3668
+ encoding: "utf-8",
3669
+ timeout: 4000,
3670
+ });
3671
+ return /run-twitter-cycle\.sh/.test(cmd);
3672
+ }
3673
+ catch {
3674
+ return false;
3675
+ }
3676
+ }
3677
+ function rmShellLockDir() {
3678
+ try {
3679
+ fs.rmSync(TW_BROWSER_LOCK_DIR, { recursive: true, force: true });
3680
+ }
3681
+ catch {
3682
+ /* best effort */
3683
+ }
3684
+ }
3685
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
3686
+ // SIGKILL a scan's WHOLE process tree (the bash + its browser-harness/tee
3687
+ // children). run-twitter-cycle.sh traps SIGTERM/INT/HUP (skill/lock.sh installs
3688
+ // `trap _sa_release_locks ... TERM`), so a SIGTERM runs the cleanup handler and
3689
+ // the script KEEPS GOING — the scan never dies, still drives Chrome, and the next
3690
+ // autopilot tick stacks another on top (the zombie pileup that stale-reclaimed the
3691
+ // lock mid-post). SIGKILL can't be trapped. Kill children first so the harness CDP
3692
+ // driver lets go of Chrome immediately.
3693
+ function sigkillScanTree(pid) {
3694
+ try {
3695
+ const out = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf-8", timeout: 4000 });
3696
+ for (const cstr of out.split(/\s+/)) {
3697
+ const c = parseInt(cstr, 10);
3698
+ if (Number.isFinite(c) && c > 0) {
3699
+ try {
3700
+ process.kill(c, "SIGKILL");
3701
+ }
3702
+ catch {
3703
+ /* gone */
3704
+ }
3705
+ }
3706
+ }
3707
+ }
3708
+ catch {
3709
+ /* no children / pgrep unavailable */
3710
+ }
3711
+ try {
3712
+ process.kill(pid, "SIGKILL");
3713
+ }
3714
+ catch {
3715
+ /* gone */
3716
+ }
3717
+ }
3718
+ // Single-flight: SIGKILL every run-twitter-cycle.sh on the box before launching a
3719
+ // fresh scan, so a zombie that survived a prior SIGTERM (or a stale waiter parked
3720
+ // behind a post) can never accumulate. Mirrors the plist's run-twitter-cycle-
3721
+ // singleton.sh "one cycle at a time" guarantee, which the MCP's direct launch
3722
+ // bypassed. Best-effort; never throws.
3723
+ function sigkillAllScans() {
3724
+ try {
3725
+ const out = execFileSync("pgrep", ["-f", "skill/run-twitter-cycle.sh"], {
3726
+ encoding: "utf-8",
3727
+ timeout: 4000,
3728
+ });
3729
+ for (const pstr of out.split(/\s+/)) {
3730
+ const p = parseInt(pstr, 10);
3731
+ if (Number.isFinite(p) && p > 0)
3732
+ sigkillScanTree(p);
3733
+ }
3734
+ }
3735
+ catch {
3736
+ /* none running */
3737
+ }
3738
+ }
3739
+ // ---- Lock grace-hold: hold the /tmp lock CONTINUOUSLY across per-card posts ----
3740
+ // The plist pipeline acquires the browser lock ONCE and holds it through the whole
3741
+ // posting phase. The MCP posts per approved card (separate post_drafts calls), and
3742
+ // the old code acquired+released the lock PER CARD — leaving a release window
3743
+ // BETWEEN every card that a parked scan stale-reclaimed (the hijack). Instead we
3744
+ // keep the lock and only release it after SHELL_LOCK_GRACE_MS of no posting, so the
3745
+ // hold EXPANDS as more cards get approved and there is never a gap between cards.
3746
+ const SHELL_LOCK_GRACE_MS = Number(process.env.S4L_POST_LOCK_GRACE_MS ?? process.env.SAPS_POST_LOCK_GRACE_MS) || 60_000;
3747
+ let shellLockReleaseTimer = null;
3748
+ // True from the start of a post batch until SHELL_LOCK_GRACE_MS after the last
3749
+ // card. The draft-cycle scan checks this and DEFERS launching a scan while it's set —
3750
+ // the real fix: posting and scanning are mutually exclusive at the SOURCE (both
3751
+ // are children of THIS one MCP), so we never even launch a scan that would race
3752
+ // the post for the browser lock. Having the post fight scans for the lock (the
3753
+ // prior approach) lost the race because the autopilot relaunches scans faster
3754
+ // than the post can hold the dir. Reset is guaranteed by the grace timer below,
3755
+ // so it can never wedge scanning permanently.
3756
+ let postingActive = false;
3757
+ function cancelScheduledShellLockRelease() {
3758
+ if (shellLockReleaseTimer) {
3759
+ clearTimeout(shellLockReleaseTimer);
3760
+ shellLockReleaseTimer = null;
3761
+ }
3762
+ }
3763
+ function scheduleShellLockRelease() {
3764
+ cancelScheduledShellLockRelease();
3765
+ shellLockReleaseTimer = setTimeout(() => {
3766
+ shellLockReleaseTimer = null;
3767
+ postingActive = false; // posting drained -> the autopilot may scan again
3768
+ stopPostingFlagHeartbeat(); // clear the cross-instance flag too
3769
+ releaseShellBrowserLock();
3770
+ }, SHELL_LOCK_GRACE_MS);
3771
+ }
3772
+ // SIGKILL a live scan holding the shell browser lock so the post takes the browser
3773
+ // at once. Best-effort; only ever targets a run-twitter-cycle.sh.
3774
+ function preemptScanHoldingBrowser() {
3775
+ try {
3776
+ const pid = shellLockHolderPid();
3777
+ if (pid && pidAlive(pid) && pidIsScan(pid)) {
3778
+ console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid}) — SIGKILL tree`);
3779
+ sigkillScanTree(pid);
3780
+ }
3781
+ }
3782
+ catch {
3783
+ /* best effort */
3784
+ }
3785
+ }
3786
+ // Take (or extend) the shell browser lock for the batch. Preempts a scan holder
3787
+ // with SIGKILL; never steals from a live non-scan holder (a peer poster) — there
3788
+ // it returns false and posting proceeds unguarded (no worse than before).
3789
+ async function acquireShellBrowserLock() {
3790
+ // A new post cancels any pending grace-release and EXTENDS the existing hold.
3791
+ cancelScheduledShellLockRelease();
3792
+ // Already ours? Refresh the pid + expiry and keep holding — this is the "expand
3793
+ // the lock as more cards get approved" path: consecutive per-card posts reuse
3794
+ // ONE continuous hold instead of churning the lock, which is what left a window
3795
+ // a parked scan stale-reclaimed between cards.
3796
+ if (shellLockHolderPid() === process.pid) {
3797
+ try {
3798
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
3799
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
3800
+ }
3801
+ catch {
3802
+ /* best effort */
3803
+ }
3804
+ return true;
3805
+ }
3806
+ for (let attempt = 0; attempt < 8; attempt++) {
3807
+ try {
3808
+ fs.mkdirSync(TW_BROWSER_LOCK_DIR); // atomic mutex — only one winner
3809
+ // Write the pid IMMEDIATELY (sync) so the dir is never observably pid-less.
3810
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
3811
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
3812
+ console.error(`[post] holding twitter-browser shell lock pid=${process.pid} — scans queue behind the post`);
3813
+ return true;
3814
+ }
3815
+ catch {
3816
+ // Dir exists. Reclaim if the holder is dead; SIGKILL-preempt if it's a scan;
3817
+ // otherwise (a live peer poster) leave it and post unguarded.
3818
+ const pid = shellLockHolderPid();
3819
+ if (!pid || !pidAlive(pid)) {
3820
+ rmShellLockDir();
3821
+ }
3822
+ else if (pidIsScan(pid)) {
3823
+ sigkillScanTree(pid); // SIGKILL — scans trap SIGTERM and survive it
3824
+ await sleepMs(300);
3825
+ rmShellLockDir();
3826
+ }
3827
+ else {
3828
+ return false; // a real peer holds it — don't steal; proceed
3829
+ }
3830
+ await sleepMs(200);
3831
+ }
3832
+ }
3833
+ return false;
3834
+ }
3835
+ // Release only if it's still OURS (mirror skill/lock.sh's ownership guard) so we
3836
+ // never wipe a scan that legitimately re-acquired after the batch finished.
3837
+ function releaseShellBrowserLock() {
3838
+ try {
3839
+ if (shellLockHolderPid() === process.pid) {
3840
+ rmShellLockDir();
3841
+ console.error(`[post] released twitter-browser shell lock pid=${process.pid}`);
3842
+ }
3843
+ }
3844
+ catch {
3845
+ /* best effort */
3846
+ }
3847
+ }
3848
+ // Posting takes priority over scanning. When the user approves a post, abort any
3849
+ // in-flight scan so the browser frees up at once. The scan that actually holds the
3850
+ // shared Chrome is a live run-twitter-cycle.sh (the every-minute launchd cycle);
3851
+ // kill it cross-process via the /tmp shell lock it truly respects. Best-effort;
3852
+ // never throws; never touches a locked pipeline script.
3853
+ function preemptScanForPost() {
3854
+ preemptScanHoldingBrowser();
3855
+ }
3856
+ appTool("dashboard", {
3857
+ title: "S4L dashboard",
3858
+ description: "Render the S4L dashboard in chat: a visual surface showing project setup, X " +
3859
+ "connection, autopilot state, and 7-day stats, with buttons to set up the schedule, connect X, " +
3860
+ "and refresh. Use when the user asks to see the dashboard, panel, " +
3861
+ "status, or controls. ALSO call this at the end of any state-changing or results-producing " +
3862
+ "action (post_drafts, get_stats, project_config) so the user sees the " +
3863
+ "updated dashboard. Hosts without UI support get the same data as text.",
3864
+ inputSchema: {},
3865
+ // fallback_url is set only when the host can't render the ui:// resource and
3866
+ // we open the dashboard via the loopback HTTP server instead. Declared
3867
+ // optional so the SDK's strict output-schema check accepts both shapes.
3868
+ outputSchema: { snapshot: z.string(), fallback_url: z.string().optional() },
3869
+ _meta: { ui: { resourceUri: PANEL_URI } },
3870
+ }, async () => {
3871
+ const snap = await buildSnapshot();
3872
+ const human = `S4L v${snap.version}` +
3873
+ (snap.update_available && snap.latest_version ? ` (update to ${snap.latest_version})` : "") +
3874
+ ` — projects ${snap.projects_ready}/${snap.projects_total} ready, ` +
3875
+ `X ${snap.x_connected ? "connected" : "not connected"}, ` +
3876
+ `autopilot ${snap.autopilot_on ? "on" : "off"}.`;
3877
+ const base = {
3878
+ content: [{ type: "text", text: human }],
3879
+ structuredContent: { snapshot: JSON.stringify(snap) },
3880
+ };
3881
+ // If the host can render MCP Apps UI inline, the _meta.ui.resourceUri above
3882
+ // makes it paint the panel. Don't ALSO emit the human text line: the host
3883
+ // shows tool-result content next to the rendered panel, so returning `human`
3884
+ // here duplicates the dashboard as an annoying text "fallback" beside it.
3885
+ // Keep the snapshot in structuredContent (the model still reads it) and emit
3886
+ // no text content so the chat shows ONLY the panel.
3887
+ if (hostRendersAppUi()) {
3888
+ return { content: [], structuredContent: { snapshot: JSON.stringify(snap) } };
3889
+ }
3890
+ // Host CAN'T render inline (Claude Code / Cowork today): serve the identical
3891
+ // panel.html from a loopback HTTP server. We do NOT auto-open a browser tab
3892
+ // (see openInBrowser — opt-in only); the dashboard is shown in the Claude Code
3893
+ // side panel, and the loopback URL is returned for anyone who wants to open it.
3894
+ try {
3895
+ const url = await startLocalPanel();
3896
+ await openInBrowser(url);
3897
+ return {
3898
+ content: [{
3899
+ type: "text",
3900
+ text: human +
3901
+ `\n\nThis host can't render the dashboard inline. It's available in the side panel; loopback URL: ${url}`,
3902
+ }],
3903
+ structuredContent: { snapshot: JSON.stringify(snap), fallback_url: url },
3904
+ };
3905
+ }
3906
+ catch (e) {
3907
+ // Loopback server failed to start; degrade to the text-only snapshot.
3908
+ console.error("[social-autoposter-mcp] local panel fallback failed:", e?.message || e);
3909
+ return base;
3910
+ }
3911
+ });
3912
+ // ---- add your product: focused single-field onboarding widget --------------
3913
+ // A standalone ui:// widget (separate from the dashboard panel) that captures
3914
+ // the user's product URL. The widget itself reads project status and either
3915
+ // writes the website via project_config (callServerTool) or, on a cold start,
3916
+ // hands the URL to the model via sendMessage. Same inline/loopback duality as
3917
+ // `dashboard`.
3918
+ appTool("connect_product", {
3919
+ title: "Add your product",
3920
+ description: "Render the 'add your product' widget in chat: a single-field form where the user pastes " +
3921
+ "their product's website. Use at the START of onboarding when you need the product URL, " +
3922
+ "instead of asking for it in plain prose. If a project already needs a website the widget " +
3923
+ "saves it directly; on a cold start it kicks off end-to-end setup. Hosts without UI support " +
3924
+ "get a loopback URL.",
3925
+ inputSchema: {},
3926
+ outputSchema: { snapshot: z.string(), fallback_url: z.string().optional() },
3927
+ _meta: { ui: { resourceUri: PRODUCT_LINK_URI } },
3928
+ }, async () => {
3929
+ const snap = await buildSnapshot();
3930
+ // Inline-capable host: paint the resource named by _meta.ui.resourceUri.
3931
+ // Emit no text content so the chat shows only the widget (see `dashboard`).
3932
+ if (hostRendersAppUi()) {
3933
+ return { content: [], structuredContent: { snapshot: JSON.stringify(snap) } };
3934
+ }
3935
+ // No inline UI: serve the identical product-link.html from the loopback
3936
+ // server at /product-link and return its URL.
3937
+ try {
3938
+ const base = await startLocalPanel();
3939
+ const url = base.replace(/\/$/, "") + "/product-link";
3940
+ await openInBrowser(url);
3941
+ return {
3942
+ content: [{
3943
+ type: "text",
3944
+ text: "Add your product: paste your product's website to begin setup.\n\n" +
3945
+ `This host can't render the widget inline. Loopback URL: ${url}`,
3946
+ }],
3947
+ structuredContent: { snapshot: JSON.stringify(snap), fallback_url: url },
3948
+ };
3949
+ }
3950
+ catch (e) {
3951
+ console.error("[social-autoposter-mcp] product-link fallback failed:", e?.message || e);
3952
+ return {
3953
+ content: [{ type: "text", text: "Paste your product's website in the chat to begin setup." }],
3954
+ structuredContent: { snapshot: JSON.stringify(snap) },
3955
+ };
3956
+ }
3957
+ });
3958
+ // ---- show browser to user: live CDP screencast ----------------------------
3959
+ // Streams a live view of the autoposter's managed Chrome into the panel. Frames
3960
+ // travel back through the normal tool-result channel as a data: URL (which the
3961
+ // default panel CSP already permits), so this needs no CSP widening and no
3962
+ // direct network access from the iframe. The panel polls action:"frame".
3963
+ //
3964
+ // This is a PLAIN tool (not appTool): it renders nothing of its own, it only
3965
+ // feeds frames into the existing `dashboard` panel via callServerTool. Registering
3966
+ // it as an app-tool requires a `_meta.ui.resourceUri`; without one,
3967
+ // registerAppTool throws "Cannot read properties of undefined (reading 'ui')" at
3968
+ // startup and the whole server fails to connect. So keep it a regular tool.
3969
+ tool("show_browser_to_user", {
3970
+ title: "Show browser to user",
3971
+ description: "Show the user a LIVE view of the autoposter's managed Chrome (what the bot " +
3972
+ "is doing in the browser right now). Attaches a CDP screencast to the active " +
3973
+ "browser session and returns the newest frame as a data: image. Actions: " +
3974
+ "'start' begins the screencast, 'frame' returns the latest frame (poll this on " +
3975
+ "a short interval to animate), 'stop' ends it, 'front' raises the real browser " +
3976
+ "window above everything else so the user can interact with it directly. Use when " +
3977
+ "the user asks to see / watch the browser, or to bring the browser to the front.",
3978
+ inputSchema: {
3979
+ action: z.enum(["start", "frame", "stop", "front"]).optional(),
3980
+ port: z.number().int().optional().describe("CDP debugging port to attach to; auto-detected if omitted."),
3981
+ },
3982
+ }, async (args) => {
3983
+ const action = args?.action || "frame";
3984
+ if (action === "stop") {
3985
+ screencast.stop();
3986
+ return jsonContent({ ok: true, running: false });
3987
+ }
3988
+ if (action === "front") {
3989
+ const res = await bringBrowserToFront(typeof args?.port === "number" ? args.port : undefined);
3990
+ if (!res.ok) {
3991
+ const message = res.error === "no_browser"
3992
+ ? "No managed Chrome is running right now, so there's nothing to bring to the front. Start a draft cycle or autopilot first."
3993
+ : "Couldn't bring the browser to the front: " + String(res.error);
3994
+ return jsonContent({ ok: false, brought_to_front: false, message });
3995
+ }
3996
+ return jsonContent({ ok: true, brought_to_front: true, port: res.port });
3997
+ }
3998
+ // If the user is about to watch the live browser, make sure the on-screen
3999
+ // overlay watcher is up too so the harness window carries its status banner.
4000
+ if (action === "start")
4001
+ await ensureOverlayWatch();
4002
+ const ensured = await screencast.ensure(typeof args?.port === "number" ? args.port : undefined);
4003
+ if (!ensured.ok) {
4004
+ const message = ensured.error === "no_browser"
4005
+ ? "No managed Chrome is running right now. Start a draft cycle or autopilot so there's a live browser session to show."
4006
+ : ensured.error === "no_websocket"
4007
+ ? "This Node runtime has no WebSocket support (needs Node 21+), so a screencast can't be opened."
4008
+ : "Couldn't attach to the browser: " + String(ensured.error);
4009
+ return jsonContent({ ok: false, running: false, frame: null, message });
4010
+ }
4011
+ // On a fresh start the first frame takes a beat to arrive; wait briefly so the
4012
+ // caller's first poll already has something to paint.
4013
+ let frame = screencast.frame();
4014
+ for (let i = 0; i < 12 && !frame; i++) {
4015
+ await new Promise((r) => setTimeout(r, 120));
4016
+ frame = screencast.frame();
4017
+ }
4018
+ const st = screencast.status();
4019
+ return jsonContent({
4020
+ ok: true,
4021
+ running: st.running,
4022
+ port: st.port,
4023
+ title: st.title,
4024
+ url: st.url,
4025
+ age_ms: st.age_ms,
4026
+ frame: frame ? `data:image/jpeg;base64,${frame}` : null,
4027
+ });
4028
+ });
4029
+ registerAppResource(server, "S4L panel", PANEL_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
4030
+ contents: [
4031
+ {
4032
+ uri: PANEL_URI,
4033
+ mimeType: RESOURCE_MIME_TYPE,
4034
+ text: fs.readFileSync(path.join(DIST_DIR, "panel.html"), "utf-8"),
4035
+ },
4036
+ ],
4037
+ }));
4038
+ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
4039
+ contents: [
4040
+ {
4041
+ uri: PRODUCT_LINK_URI,
4042
+ mimeType: RESOURCE_MIME_TYPE,
4043
+ text: fs.readFileSync(path.join(DIST_DIR, "product-link.html"), "utf-8"),
4044
+ },
4045
+ ],
4046
+ }));
4047
+ // Post any cards the user APPROVED that never landed — e.g. a restart killed the
4048
+ // batch mid-way. "Proceed to post the already-approved items." postApproved is
4049
+ // idempotent (it filters posted/terminal), so this only drains the genuine
4050
+ // backlog and never double-posts. Best-effort; never throws.
4051
+ async function drainApprovedBacklog() {
4052
+ try {
4053
+ const plan = readPlan(REVIEW_QUEUE_ID);
4054
+ const cands = plan?.candidates || [];
4055
+ const backlog = cands.filter((c) => c.approved === true && c.posted !== true && c.terminal !== true);
4056
+ if (!backlog.length)
4057
+ return;
4058
+ console.error(`[post] draining ${backlog.length} approved-but-unposted card(s) left from before`);
4059
+ await postApproved(REVIEW_QUEUE_ID, plan);
4060
+ }
4061
+ catch (e) {
4062
+ console.error("[post] drainApprovedBacklog error:", e?.message || e);
4063
+ }
4064
+ }
4065
+ async function main() {
4066
+ initSentry();
4067
+ // Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
4068
+ // Cloud Run relay (-> Cloud Logging) so we can troubleshoot/rescue any user
4069
+ // scenario (silent stalls, partial onboarding) without asking them to ship a
4070
+ // log file. Best-effort; disabled with S4L_LOG_STREAM=0.
4071
+ startLogStreaming();
4072
+ // A plugin UPDATE refreshes this server (dist/) but not the materialized
4073
+ // pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
4074
+ // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
4075
+ // the version first materialized at install). Synchronous + best-effort.
4076
+ ensurePipelineCurrent();
4077
+ // Deterministically provision the owned runtime on boot: whenever it isn't
4078
+ // ready (a fresh install, or one interrupted mid-way because a step failed or
4079
+ // Claude/the host died mid-install) kick the full install in the background
4080
+ // instead of waiting for the agent to call `runtime action:'install'`. The
4081
+ // host spawns this server when the plugin loads, so the env starts installing
4082
+ // the moment the plugin is active. Idempotent: it re-checks done steps and
4083
+ // attempts only the missing ones; the background provision() updates
4084
+ // install-progress.json as it goes.
4085
+ if (ensureRuntimeProvisioned()) {
4086
+ console.error("[social-autoposter-mcp] owned runtime not ready; provisioning on boot");
4087
+ }
4088
+ // Queue-backed drafting (2026-06-23): keep the two worker-task prompts current,
4089
+ // pre-approve their tools EAGERLY (before onboarding even creates the tasks, so
4090
+ // the first unattended fire can't stall), and (re)install the launchd kicker
4091
+ // that runs the real DRAFT_ONLY pipeline whose claude -p calls feed the queue.
4092
+ // All best-effort; none may block boot.
4093
+ ensureQueueWorkerPromptsCurrent();
4094
+ ensureQueueWorkerToolsAllowed();
4095
+ // Pre-create the dedicated worker folder so a box that already has the tasks can
4096
+ // be re-pointed at it (Routines -> Edit -> Folder) without the folder missing.
4097
+ // Keeps the per-minute worker sessions out of the project's interactive
4098
+ // `claude --resume` picker once the folder is set. Best-effort.
4099
+ try {
4100
+ fs.mkdirSync(queueWorkerCwd(), { recursive: true });
4101
+ // Trust the folder too — without this the per-minute worker sessions stall at
4102
+ // Claude's per-folder checkTrust on a headless box and never drain the queue.
4103
+ ensureWorkerFolderTrusted();
4104
+ }
4105
+ catch (e) {
4106
+ console.error(`[queue-worker] could not create worker folder: ${e?.message || e}`);
4107
+ }
4108
+ void ensureQueueKickerInstalled()
4109
+ .then((r) => console.error(`[queue-worker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4110
+ .catch((e) => console.error("[queue-worker] kicker install failed:", e?.message || e));
4111
+ // Self-healing reaper for the agent-mode session leak the queue autopilot
4112
+ // produces (finished `claude` worker sessions Desktop never tears down). A
4113
+ // standalone guardrail; install unconditionally so it caps memory even on a
4114
+ // box whose project isn't ready yet. Best-effort; must never block boot.
4115
+ void ensureClaudeReaperInstalled()
4116
+ .then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4117
+ .catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
4118
+ // Feedback digest: hourly distillation of the user's card approve/reject
4119
+ // decisions into learned_preferences (see scripts/feedback_digest.py).
4120
+ // Best-effort; a box with no review events runs a no-op.
4121
+ void ensureFeedbackDigestInstalled()
4122
+ .then((r) => console.error(`[feedback-digest] launchd digest: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4123
+ .catch((e) => console.error("[feedback-digest] digest install failed:", e?.message || e));
4124
+ // Autopilot stall watchdog: fleet-side Sentry alert when the draft routines stop
4125
+ // draining (most often an account switch orphaning them). The menu bar shows the
4126
+ // user the Re-arm action; this is the part we see. Best-effort; never blocks boot.
4127
+ void ensureStallWatchInstalled()
4128
+ .then((r) => console.error(`[stall-watch] launchd watchdog: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4129
+ .catch((e) => console.error("[stall-watch] watchdog install failed:", e?.message || e));
4130
+ // Periodic host-resource sampler (memory/process snapshot -> local JSONL). Gives
4131
+ // us per-box resource history to diagnose RAM blowups (e.g. the agent-mode
4132
+ // session leak). Best-effort; never blocks boot. Disable with S4L_MEMORY_SNAPSHOT=0.
4133
+ void ensureMemorySnapshotInstalled()
4134
+ .then((r) => console.error(`[memory-snapshot] launchd sampler: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4135
+ .catch((e) => console.error("[memory-snapshot] sampler install failed:", e?.message || e));
4136
+ // On-screen overlay watcher supervisor. The harness status overlay only renders
4137
+ // while the watcher process is alive, and that watcher had no supervisor — when
4138
+ // it died nothing respawned it and the overlay silently vanished. Install it as
4139
+ // a first-class self-healing launchd job (RunAtLoad + 60s idempotent re-invoke).
4140
+ // Best-effort; the overlay is a nicety and must never block boot.
4141
+ void ensureOverlayWatchInstalled()
4142
+ .then((r) => console.error(`[overlay-watch] launchd supervisor: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4143
+ .catch((e) => console.error("[overlay-watch] supervisor install failed:", e?.message || e));
4144
+ // Heal installs onboarded before short_links_live defaulted to false: such a
4145
+ // project wraps short links against the customer's own domain, which has no
4146
+ // /r/[code] resolver, so every minted link 404s. Re-point them at the s4l.ai
4147
+ // resolver. Idempotent, scoped to managed projects, best-effort.
4148
+ try {
4149
+ const r = ensureShortLinksDefault();
4150
+ if (r.healed.length) {
4151
+ console.error(`[social-autoposter-mcp] short-links heal: routed ${r.healed.join(", ")} through s4l.ai (short_links_live=false)`);
4152
+ }
4153
+ }
4154
+ catch (e) {
4155
+ console.error("[social-autoposter-mcp] short-links heal failed:", e?.message || e);
4156
+ }
4157
+ // Make S4L visible in the Cowork/Code tab, not just the Chat tab: register this
4158
+ // server into ~/.claude.json `mcpServers` so the embedded claude-code (launched
4159
+ // with --setting-sources=user) discovers it. Synchronous, idempotent, atomic,
4160
+ // best-effort; never blocks boot. See ensureCoworkMcpRegistered for the why.
4161
+ ensureCoworkMcpRegistered();
4162
+ const transport = new StdioServerTransport();
4163
+ await server.connect(transport);
4164
+ console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
4165
+ // Eagerly start the loopback panel server so the Claude Code side panel (and any
4166
+ // reverse proxy in front of it) always has a backend to hit, without waiting for
4167
+ // a first `dashboard` call. Best-effort: a bind failure must never block boot.
4168
+ void startLocalPanel()
4169
+ .then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
4170
+ .catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
4171
+ // Resume posting any approved-but-unposted cards a prior run/restart left behind.
4172
+ // Delayed so the runtime + harness Chrome have settled; never blocks boot.
4173
+ {
4174
+ const t = setTimeout(() => void drainApprovedBacklog(), 30_000);
4175
+ if (typeof t.unref === "function")
4176
+ t.unref();
4177
+ }
4178
+ // Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
4179
+ // and cheap when already present, so existing installs pick it up on the next
4180
+ // Claude restart without re-provisioning. Best-effort: never blocks boot.
4181
+ void ensureMenubar()
4182
+ .then((r) => {
4183
+ console.error(`[social-autoposter-mcp] menubar: ${r.skipped ? "skip" : r.ok ? "ok" : "fail"} (${r.detail})`);
4184
+ // A non-skipped failure here is the boot-time "menu bar didn't come up"
4185
+ // path (e.g. uv missing, rumps reinstall failed on an existing install).
4186
+ // Report it; a skip (non-macOS / runtime not ready) is expected, not an error.
4187
+ if (!r.ok && !r.skipped) {
4188
+ captureError(new Error(`menubar ensure failed: ${r.detail}`), {
4189
+ component: "menubar",
4190
+ phase: "ensure",
4191
+ });
4192
+ }
4193
+ })
4194
+ .catch((e) => {
4195
+ console.error("[social-autoposter-mcp] menubar ensure failed:", e?.message || e);
4196
+ captureError(e, { component: "menubar", phase: "ensure" });
4197
+ });
4198
+ // Phone home so this .mcpb install is visible in the install-lane digest
4199
+ // (parity with the npx launchd heartbeat). Once on startup, then every 15m
4200
+ // while the desktop app keeps the server alive. unref() so it never holds the
4201
+ // process open past a normal exit.
4202
+ void sendHeartbeat("startup");
4203
+ const hb = setInterval(() => void sendHeartbeat("interval"), 15 * 60_000);
4204
+ hb.unref();
4205
+ }
4206
+ main().catch(async (err) => {
4207
+ console.error("[social-autoposter-mcp] fatal:", err);
4208
+ captureError(err, { component: "main" });
4209
+ await flushLogs();
4210
+ await flushSentry();
4211
+ process.exit(1);
4212
+ });