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