social-autoposter 1.6.68 → 1.6.70

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/mcp/dist/index.js CHANGED
@@ -5,7 +5,6 @@
5
5
  // draft_cycle - scan + draft, return all drafts as a numbered table for the
6
6
  // user to review in chat (posts nothing).
7
7
  // post_drafts - post the drafts the user chose by number from a batch.
8
- // autopilot - one tool, action = enable | disable | status (launchd job).
9
8
  // get_stats - read-only post + engagement stats.
10
9
  //
11
10
  // THIN wrapper. The pipeline brain (scan, score, drafting prompts, posting)
@@ -18,9 +17,9 @@ import os from "node:os";
18
17
  import path from "node:path";
19
18
  import fs from "node:fs";
20
19
  import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
21
- import { applySetup, resolveProject, hasReadyProject, listManagedProjectStatus, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, } from "./setup.js";
20
+ import { applySetup, resolveProject, listManagedProjectStatus, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, } from "./setup.js";
22
21
  import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
23
- import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, } from "./runtime.js";
22
+ import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, } from "./runtime.js";
24
23
  import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
25
24
  import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
26
25
  import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
@@ -34,7 +33,8 @@ const PANEL_URI = "ui://social-autoposter/panel.html";
34
33
  const TWITTER_AUTOPILOT_LABEL = "com.m13v.social-twitter-cycle";
35
34
  const TWITTER_AUTOPILOT_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${TWITTER_AUTOPILOT_LABEL}.plist`);
36
35
  // Daily self-updater. Enabled alongside autopilot so a hands-free (headless)
37
- // install keeps itself current — the interactive `version` tool only helps when
36
+ // install keeps itself current — the interactive `runtime` tool (action:'update')
37
+ // only helps when
38
38
  // a human-facing agent session is open, which an autopilot box never has.
39
39
  const UPDATER_LABEL = "com.m13v.social-autoposter-update";
40
40
  const UPDATER_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${UPDATER_LABEL}.plist`);
@@ -171,15 +171,16 @@ const server = new McpServer({
171
171
  "about it. A feature nobody hears about is wasted distribution. Use judgment, suggest it for " +
172
172
  "genuinely shareable moments, not routine refactors, chores, or internal cleanup.\n\n" +
173
173
  "STAY CURRENT. At the start of a session, and again right after you help the user ship/merge/" +
174
- "release, call the `version` tool (action:'status'). If `update_available` is true, tell the " +
175
- "user and offer to run `version` with action:'update'. The `setup` tool's status also surfaces " +
176
- "`update_available` and an `update_hint`.\n\n" +
177
- "TYPICAL FLOW: `setup` (configure the project once) -> `draft_cycle` (scan + review a batch; the " +
178
- "user approves / edits / skips every draft in a single form) -> `autopilot` (enable to also turn " +
179
- "on hands-free background posting AND daily auto-updates) -> `get_stats` (see performance). Run " +
180
- "`setup` first; the other tools refuse until a project is fully configured.\n\n" +
174
+ "release, call the `runtime` tool (action:'version'). If `update_available` is true, tell the " +
175
+ "user and offer to run `runtime` with action:'update'. The `project_config` tool's status also " +
176
+ "surfaces `update_available` and an `update_hint`.\n\n" +
177
+ "TYPICAL FLOW: `project_config` (configure OR edit the project, and connect X) -> `draft_cycle` " +
178
+ "(scan + review a batch; the user approves / edits / skips every draft in a single form) -> " +
179
+ "`get_stats` (see performance). Run `project_config` first; the other tools refuse until a " +
180
+ "project is fully configured. To change anything about a project later, call `project_config` " +
181
+ "again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
181
182
  "RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
182
- "(`draft_cycle`, `post_drafts`, `autopilot` enable/disable, `get_stats`), end your turn by " +
183
+ "(`draft_cycle`, `post_drafts`, `get_stats`), end your turn by " +
183
184
  "calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
184
185
  "`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.",
185
186
  });
@@ -238,9 +239,10 @@ function blockedReasonMessage(reason) {
238
239
  "Wait for the limit to reset, then run draft_cycle again.");
239
240
  case "no_search_topics":
240
241
  return ("This project has no search topics yet, so there was nothing to scan. Topics live in the " +
241
- "DB (project_search_topics) and are seeded from your project's `search_topics` during setup. " +
242
- "Re-run the `setup` tool for this project with a `search_topics` list (comma-separated keywords/" +
243
- "phrases your buyers tweet about); setup seeds them automatically, then run draft_cycle again.");
242
+ "DB (project_search_topics) and are seeded from your project's `search_topics` when you " +
243
+ "configure it. Re-run the `project_config` tool for this project with a `search_topics` list " +
244
+ "(comma-separated keywords/phrases your buyers tweet about); it seeds them automatically, then " +
245
+ "run draft_cycle again.");
244
246
  case "topics_api_unreachable":
245
247
  return ("Couldn't reach the search-topics service to load this project's topics, so the cycle stopped " +
246
248
  "before scanning. This is usually a transient backend/network issue. Try draft_cycle again in a " +
@@ -498,6 +500,19 @@ async function postApproved(batchId, plan) {
498
500
  catch {
499
501
  /* keep raw */
500
502
  }
503
+ // On a successful run, mark the posted candidates in the ORIGINAL plan so the
504
+ // other review surface (chat vs menu-bar pop-ups) skips them — cross-surface
505
+ // de-dup. Best-effort; the pipeline's own already-posted check is the backstop.
506
+ if (res.code === 0) {
507
+ for (const c of approved)
508
+ c.posted = true;
509
+ try {
510
+ writePlan(batchId, plan);
511
+ }
512
+ catch {
513
+ /* best effort */
514
+ }
515
+ }
501
516
  return {
502
517
  attempted: approved.length,
503
518
  exit_code: res.code,
@@ -509,10 +524,10 @@ async function postApproved(batchId, plan) {
509
524
  // This is NOT a tool — the model never auto-calls it. It surfaces in clients
510
525
  // that render prompts as slash-commands / starters (e.g. Claude Desktop's "/"
511
526
  // menu). When the user picks it, it injects the message below into the chat,
512
- // which nudges the agent to start the real onboarding via the `setup` tool.
527
+ // which nudges the agent to start the real onboarding via the `project_config` tool.
513
528
  // Deliberately a DUMB POINTER: it names no fields and no steps, so it can never
514
- // drift from REQUIRED_FIELDS / the setup tool's flow. All real logic stays in
515
- // `setup`; this is just a convenience handle to begin.
529
+ // drift from REQUIRED_FIELDS / the project_config tool's flow. All real logic stays
530
+ // in `project_config`; this is just a convenience handle to begin.
516
531
  server.registerPrompt("getting-started", {
517
532
  title: "Set up social-autoposter",
518
533
  description: "Start here. Walks you through configuring a product and connecting your X/Twitter " +
@@ -561,29 +576,36 @@ const WEBSITE_RESEARCH_INSTRUCTIONS = "PRODUCT RESEARCH (do this before saving t
561
576
  "register, vibe) while keeping every product CLAIM factual to the site. Don't invent " +
562
577
  "features, metrics, or guarantees the site doesn't state.\n" +
563
578
  "5. Save the best conservative factual draft without adding a confirmation round-trip. Call " +
564
- "setup with name + the product fields (plus voice/search_topics from the profile scan). If the " +
579
+ "project_config with name + the product fields (plus voice/search_topics from the profile scan). If the " +
565
580
  "site is thin or unreachable, use only supported facts and leave optional detail conservative; " +
566
581
  "ask the user only if a required field is genuinely unknowable.";
567
- // ---- setup: per-project config (the "brain": project, website, voice) -----
582
+ // ---- project_config: per-project config (the "brain": project, website, voice) -----
568
583
  // Run this FIRST. The action tools refuse until at least one project is ready.
569
584
  // You can set up MULTIPLE products and fill each project's fields INCREMENTALLY
570
585
  // across several calls — readiness is derived from config.json, never a stored
571
586
  // flag. Call with status:true (or just no name) to list every project this
572
587
  // install manages and what each still needs.
573
- tool("setup", {
574
- title: "Set up a project",
575
- description: "Run this FIRST, before any drafting or autopilot. A user's request to set up social-autoposter " +
576
- "is a request to finish the workflow end to end, not to interview them step by step. Resume " +
577
- "from current status, infer discoverable fields, and keep taking safe actions until runtime, " +
578
- "project, X connection, topic seeding, and draft-only verification are complete.\n" +
588
+ tool("project_config", {
589
+ title: "Configure or edit a project",
590
+ description: "The ONE tool for a project's whole lifecycle: create it, EDIT it later, and connect its X " +
591
+ "account. There is no separate raw-config editor every project change goes through here so " +
592
+ "it validates, merges, and re-seeds the search-topic universe the cycle reads. To CHANGE an " +
593
+ "existing project (its website, voice, icp, differentiator, search_topics, guardrails, CTA " +
594
+ "link), call this with that project's `name` and ONLY the fields you want to change; it merges " +
595
+ "onto what's already saved and never clobbers untouched fields. Run it FIRST before any " +
596
+ "drafting or autopilot. A user's request to set up social-autoposter is a request to finish " +
597
+ "the workflow end to end, not to interview them step by step: resume from current status, " +
598
+ "infer discoverable fields, and keep taking safe actions until runtime, project, X connection, " +
599
+ "topic seeding, and draft-only verification are complete.\n" +
579
600
  "Two jobs:\n" +
580
- "1) Configure a project this install posts for: its website, what it does (description), who " +
581
- "to target (icp), and brand voice. To fill the PRODUCT fields, discover the product URL from " +
582
- "config, conversation context, the connected X profile, or public research, then visit it " +
583
- "with your own browser/fetch tools — read 5+ pages (home, pricing, features, about, docs/" +
584
- "blog, FAQ) to learn it deeply, rather than guessing from the name. Set up MULTIPLE products " +
585
- "(call once per product, identified by name); fill a project's fields INCREMENTALLY across " +
586
- "several calls — pass whatever you have, it merges and tells you what's still missing.\n" +
601
+ "1) Configure (or edit) a project this install posts for: its website, what it does " +
602
+ "(description), who to target (icp), and brand voice. To fill the PRODUCT fields, discover the " +
603
+ "product URL from config, conversation context, the connected X profile, or public research, " +
604
+ "then visit it with your own browser/fetch tools — read 5+ pages (home, pricing, features, " +
605
+ "about, docs/blog, FAQ) to learn it deeply, rather than guessing from the name. Set up MULTIPLE " +
606
+ "products (call once per product, identified by name); fill or edit a project's fields " +
607
+ "INCREMENTALLY across several calls — pass whatever you have, it merges and tells you what's " +
608
+ "still missing.\n" +
587
609
  "2) Connect X/Twitter (action:'connect_x'): the autoposter posts through its OWN managed Chrome, " +
588
610
  "which needs your logged-in x.com session. This imports x.com/twitter.com cookies from your " +
589
611
  "everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into that browser — nothing else is " +
@@ -593,7 +615,7 @@ tool("setup", {
593
615
  "Call with status:true (or no name) to list every configured project, its remaining fields, AND " +
594
616
  "whether X is connected. Use config, conversation context, profile_scan, and website research " +
595
617
  "before asking for fields. Ask only if no product can be identified or an interactive login is " +
596
- "unavoidable. The draft_cycle, autopilot, and get_stats tools refuse to run until a project is " +
618
+ "unavoidable. The draft_cycle and get_stats tools refuse to run until a project is " +
597
619
  "fully set up.",
598
620
  inputSchema: {
599
621
  status: z.boolean().optional(),
@@ -655,6 +677,17 @@ tool("setup", {
655
677
  .string()
656
678
  .optional()
657
679
  .describe("Anything the posts must avoid saying / claiming"),
680
+ fields: z
681
+ .record(z.string(), z.any())
682
+ .optional()
683
+ .describe("Escape hatch to edit ANY other project field the named props above don't cover — e.g. " +
684
+ "weight, platform, voice_relationship, booking_link, qualification, subreddit_bans, " +
685
+ "short_links_host, short_links_live, content_angle, messaging, landing_pages, posthog. " +
686
+ "Pass {name:'<project>', fields:{<key>:<value>, ...}}; each key SHALLOW-merges onto the " +
687
+ "project, REPLACING that key's whole value (read the current value via status:true first if " +
688
+ "you only want to tweak part of a nested object, then pass the full new value). A value of " +
689
+ "null DELETES the key. 'name' is ignored here (can't rename through this path). This is how " +
690
+ "you edit advanced config without any raw whole-file overwrite."),
658
691
  },
659
692
  }, async (args) => {
660
693
  // ---- List import sources (for the panel dropdown) ---------------------
@@ -707,7 +740,7 @@ tool("setup", {
707
740
  "Allow (or Always Allow). If you use more than one browser you may see it a couple of times, " +
708
741
  "once per browser.",
709
742
  how_to_proceed: "If the user explicitly requested setup or connection, relay the say_to_user line as a brief " +
710
- "heads-up and immediately call setup again with action:'connect_x', confirm:true; do not wait " +
743
+ "heads-up and immediately call project_config again with action:'connect_x', confirm:true; do not wait " +
711
744
  "for another yes/no reply. Optionally pass the recommended x_source. If the user only asked " +
712
745
  "what connection would do, stop after this preview.",
713
746
  });
@@ -744,12 +777,12 @@ tool("setup", {
744
777
  : undefined,
745
778
  onboarding: onboardingSnapshot(),
746
779
  next_step: r.connected
747
- ? "X is connected. Next, run setup action:'profile_scan' to read this account's bio + recent " +
780
+ ? "X is connected. Next, run project_config action:'profile_scan' to read this account's bio + recent " +
748
781
  "posts + replies and draft the project's voice/icp/search_topics in the user's own register " +
749
782
  "before saving. Then run draft_cycle once the project is fully set up."
750
783
  : r.state === "needs_login"
751
784
  ? "The user must finish signing in to x.com in the Chrome window that just opened. Tell " +
752
- "them that single required action, then call setup action:'connect_x', confirm:true again."
785
+ "them that single required action, then call project_config action:'connect_x', confirm:true again."
753
786
  : "X is not connected yet. " + summarizeXAuth(r),
754
787
  });
755
788
  }
@@ -758,14 +791,14 @@ tool("setup", {
758
791
  // successful connect_x) to read the user's bio + recent posts + recent
759
792
  // replies. Returns the raw corpus plus grounding_instructions; synthesis of
760
793
  // voice/icp/topics happens IN THIS CONVERSATION (no nested model), then the
761
- // agent confirms with the user and calls setup to persist. Read-only.
794
+ // agent confirms with the user and calls project_config to persist. Read-only.
762
795
  if (args.action === "profile_scan") {
763
796
  // Handle is auto-detected from the live logged-in session by the scanner.
764
797
  recordOnboardingAttempt("profile_scanned");
765
798
  const scan = await xScanProfile();
766
799
  if (!scan.ok) {
767
800
  const hint = scan.state === "browser_not_running" || scan.state === "no_handle"
768
- ? " Run setup action:'connect_x' (confirm:true) first so the account is connected, then retry profile_scan."
801
+ ? " Run project_config action:'connect_x' (confirm:true) first so the account is connected, then retry profile_scan."
769
802
  : "";
770
803
  blockOnboardingMilestone("profile_scanned", `profile_${scan.state || "failed"}`, scan.error || "profile scan failed", { state: scan.state || "failed" });
771
804
  return jsonContent({
@@ -838,7 +871,7 @@ tool("setup", {
838
871
  update_available: ver.update_available,
839
872
  update_hint: ver.update_available
840
873
  ? `A newer version (${ver.latest}) is available — you're on ${ver.installed}. ` +
841
- `Tell the user and offer to run the \`version\` tool with action:'update' ` +
874
+ `Tell the user and offer to run the \`runtime\` tool with action:'update' ` +
842
875
  `(or \`npx social-autoposter@latest update\`).`
843
876
  : undefined,
844
877
  required_fields: REQUIRED_FIELDS,
@@ -847,16 +880,16 @@ tool("setup", {
847
880
  ready_for_verification: rtReady && configured && x.connected,
848
881
  onboarding: onboardingSnapshot(),
849
882
  next_step: !rtReady
850
- ? "Runtime is not ready. Call install_runtime, poll install_status to completion, then continue setup automatically."
883
+ ? "Runtime is not ready. Call runtime action:'install', poll runtime action:'status' to completion, then continue setup automatically."
851
884
  : projects.length === 0
852
- ? "No projects yet. Discover the product from conversation context and the connected X profile; research its website, infer a conservative complete project, and call setup. Ask only if no product can be identified." +
885
+ ? "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." +
853
886
  (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.")
854
887
  : projects.every((p) => p.ready)
855
888
  ? (x.connected
856
889
  ? "All configured projects are ready and X is connected. Run draft_cycle now to verify end to end without posting. After it verifies, call the `dashboard` tool so the user sees the finished setup."
857
890
  : "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
858
- "x.com session. Detect sources and run setup action:'connect_x', confirm:true; do not ask whether to proceed.")
859
- : "Some projects are missing required fields (see each project's missing_required). Derive them from config, context, profile_scan, and website research, then call setup again. Ask only if a required field is genuinely unknowable." +
891
+ "x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
892
+ : "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." +
860
893
  (x.connected ? "" : " X is also not connected yet; detect sources and run connect_x with confirm:true."),
861
894
  });
862
895
  }
@@ -938,6 +971,17 @@ tool("setup", {
938
971
  }
939
972
  }
940
973
  }
974
+ // Surface any advanced (escape-hatch) field edits in the note so the
975
+ // agent can confirm exactly what changed to the user.
976
+ let advancedNote = "";
977
+ if (result.fields_set.length || result.fields_removed.length) {
978
+ const parts = [];
979
+ if (result.fields_set.length)
980
+ parts.push(`set ${result.fields_set.join(", ")}`);
981
+ if (result.fields_removed.length)
982
+ parts.push(`removed ${result.fields_removed.join(", ")}`);
983
+ advancedNote = ` Advanced fields updated: ${parts.join("; ")}.`;
984
+ }
941
985
  return jsonContent({
942
986
  ok: true,
943
987
  project: result.project,
@@ -947,16 +991,19 @@ tool("setup", {
947
991
  topics_seeded: topicsSeeded,
948
992
  topic_count: topicCount,
949
993
  search_queries: searchQueries,
994
+ fields_set: result.fields_set,
995
+ fields_removed: result.fields_removed,
950
996
  config_path: configPath(),
951
997
  onboarding: onboardingSnapshot(),
952
- note: result.ready
998
+ note: (result.ready
953
999
  ? `Project '${result.project}' is fully configured.${seedNote} Next: if X is not connected, ` +
954
- `detect sources, warn about keychain prompts, and call setup with ` +
1000
+ `detect sources, warn about keychain prompts, and call project_config with ` +
955
1001
  `action:'connect_x', confirm:true immediately. Once X is connected, run draft_cycle to ` +
956
1002
  `verify without posting. Do not enable autopilot unless explicitly requested.`
957
1003
  : `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
958
1004
  `First derive those fields from existing context, profile_scan, and website research, then ` +
959
- `call setup again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`,
1005
+ `call project_config again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`) +
1006
+ advancedNote,
960
1007
  });
961
1008
  }
962
1009
  catch (e) {
@@ -1035,6 +1082,15 @@ tool("draft_cycle", {
1035
1082
  outcome: "review_batch",
1036
1083
  draft_count: count,
1037
1084
  });
1085
+ // Fire the menu-bar pop-up review (default). The chat-table review below is
1086
+ // unchanged; both surfaces can approve, de-duped by the plan `posted` flag.
1087
+ writeReviewRequest({
1088
+ batch_id: drafted.batchId,
1089
+ project: proj,
1090
+ count,
1091
+ plan_path: planPath(drafted.batchId),
1092
+ created_at: new Date().toISOString(),
1093
+ });
1038
1094
  const table = renderDraftsTable(plan);
1039
1095
  const message = `Drafted ${count} ${count === 1 ? "reply" : "replies"} for "${proj}" ` +
1040
1096
  `(batch ${drafted.batchId}). NOTHING has been posted yet.\n\n` +
@@ -1139,6 +1195,18 @@ tool("post_drafts", {
1139
1195
  else
1140
1196
  warnings.push(`ignored #${n}: out of range (1-${total})`);
1141
1197
  });
1198
+ // Cross-surface de-dup: chat and the menu-bar pop-ups can both approve, so
1199
+ // never re-post a candidate the other surface already posted.
1200
+ const alreadyPosted = [];
1201
+ for (const n of Array.from(approve)) {
1202
+ if (candidates[n - 1]?.posted === true) {
1203
+ approve.delete(n);
1204
+ alreadyPosted.push(n);
1205
+ }
1206
+ }
1207
+ if (alreadyPosted.length) {
1208
+ warnings.push(`already posted (skipped): ${alreadyPosted.sort((a, b) => a - b).join(", ")}`);
1209
+ }
1142
1210
  candidates.forEach((c, i) => (c.approved = approve.has(i + 1)));
1143
1211
  writePlan(batch_id, plan);
1144
1212
  if (approve.size === 0) {
@@ -1163,92 +1231,16 @@ tool("post_drafts", {
1163
1231
  warnings,
1164
1232
  });
1165
1233
  });
1166
- // ---- autopilot: one tool, three actions -----------------------------------
1167
- tool("autopilot", {
1168
- title: "X autopilot",
1169
- description: "Control background X/Twitter posting. action=enable loads the launchd job so the " +
1170
- "cycle fires automatically; action=disable unloads it (manual draft_cycle still works); " +
1171
- "action=status reports whether it is loaded. After enable/disable, call the `dashboard` " +
1172
- "tool so the user sees the updated autopilot state.",
1173
- inputSchema: {
1174
- action: z.enum(["enable", "disable", "status"]),
1175
- },
1176
- }, async ({ action }) => {
1177
- if (action !== "status" && !hasReadyProject()) {
1178
- return textContent("No project is fully set up yet, so autopilot has nothing to post. Run the `setup` tool " +
1179
- "first. Note: autopilot runs the background cycle across all configured projects; it is " +
1180
- "not scoped to one project.");
1181
- }
1182
- const uid = process.getuid ? process.getuid() : 0;
1183
- const logDir = path.join(repoDir(), "skill", "logs");
1184
- if (action === "status") {
1185
- const res = await run("launchctl", ["list"], { timeoutMs: 10_000 });
1186
- const lines = res.stdout.split("\n");
1187
- const loaded = lines.some((l) => l.includes(TWITTER_AUTOPILOT_LABEL));
1188
- const updaterLoaded = lines.some((l) => l.includes(UPDATER_LABEL));
1189
- return jsonContent({
1190
- label: TWITTER_AUTOPILOT_LABEL,
1191
- loaded,
1192
- auto_update_label: UPDATER_LABEL,
1193
- auto_update_loaded: updaterLoaded,
1194
- });
1195
- }
1196
- if (action === "enable") {
1197
- // Bring up the on-screen overlay watcher so background autopilot cycles
1198
- // still paint the harness status/sidebar. Idempotent + detached.
1199
- await ensureOverlayWatch();
1200
- // 1) Cycle plist. Write one pointing at the self-update guard ONLY if no
1201
- // plist exists yet; never overwrite a hand-tuned/dev plist.
1202
- const createdCycle = ensurePlist(TWITTER_AUTOPILOT_PLIST, plistXml({
1203
- label: TWITTER_AUTOPILOT_LABEL,
1204
- programArgs: ["/bin/bash", path.join(repoDir(), "skill", "run-cycle-update-guard.sh")],
1205
- intervalSecs: 60,
1206
- runAtLoad: false,
1207
- stdoutLog: path.join(logDir, "launchd-twitter-cycle-stdout.log"),
1208
- stderrLog: path.join(logDir, "launchd-twitter-cycle-stderr.log"),
1209
- }));
1210
- const cycleRes = await loadPlist(TWITTER_AUTOPILOT_LABEL, TWITTER_AUTOPILOT_PLIST, uid);
1211
- // 2) Daily self-updater. Keeps a headless install current with no human
1212
- // in the loop. RunAtLoad so it also checks shortly after enable.
1213
- const createdUpdater = ensurePlist(UPDATER_PLIST, plistXml({
1214
- label: UPDATER_LABEL,
1215
- programArgs: ["/bin/bash", path.join(repoDir(), "skill", "social-autoposter-update.sh")],
1216
- intervalSecs: 86_400,
1217
- runAtLoad: true,
1218
- stdoutLog: path.join(logDir, "launchd-self-update-stdout.log"),
1219
- stderrLog: path.join(logDir, "launchd-self-update-stderr.log"),
1220
- }));
1221
- const updaterRes = await loadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
1222
- return jsonContent({
1223
- action: "enable",
1224
- autopilot: {
1225
- loaded: cycleRes.code === 0,
1226
- plist: TWITTER_AUTOPILOT_PLIST,
1227
- created: createdCycle,
1228
- error: cycleRes.code === 0 ? null : (cycleRes.stderr || cycleRes.stdout).trim(),
1229
- },
1230
- auto_update: {
1231
- loaded: updaterRes.code === 0,
1232
- plist: UPDATER_PLIST,
1233
- created: createdUpdater,
1234
- note: "Daily updater enabled. It self-updates real npm installs and is a no-op on dev/source " +
1235
- "checkouts (refuses to clobber a .git working tree).",
1236
- error: updaterRes.code === 0 ? null : (updaterRes.stderr || updaterRes.stdout).trim(),
1237
- },
1238
- });
1239
- }
1240
- // disable — unload both jobs (leave the plist files in place for re-enable)
1241
- const cycleOff = await unloadPlist(TWITTER_AUTOPILOT_LABEL, TWITTER_AUTOPILOT_PLIST, uid);
1242
- const updaterOff = await unloadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
1243
- return jsonContent({
1244
- action: "disable",
1245
- autopilot_unloaded: cycleOff.code === 0,
1246
- auto_update_unloaded: updaterOff.code === 0,
1247
- note: cycleOff.code === 0
1248
- ? "Autopilot and daily auto-update unloaded. Manual draft_cycle still works."
1249
- : `Autopilot disable reported exit ${cycleOff.code}: ${(cycleOff.stderr || cycleOff.stdout).trim()}`,
1250
- });
1251
- });
1234
+ // ---- autopilot: MCP tool removed ------------------------------------------
1235
+ // The `autopilot` MCP tool (enable/disable/status) was intentionally removed:
1236
+ // hands-free background posting is no longer toggled from the agent/tool surface.
1237
+ // The underlying launchd cycle job + plist (com.m13v.social-twitter-cycle) and
1238
+ // the daily self-updater are NOT touched here an already-loaded job keeps
1239
+ // running, and the plist files stay on disk. The plist helpers above
1240
+ // (ensurePlist / plistXml / loadPlist / unloadPlist) and the constants are kept
1241
+ // as the underlying source for that job; the `dashboard` snapshot still reports
1242
+ // the job's loaded state via autopilotLoaded(). To enable/disable the job now,
1243
+ // use launchctl directly or re-add a tool here.
1252
1244
  // ---- get_stats: read-only -------------------------------------------------
1253
1245
  tool("get_stats", {
1254
1246
  title: "Get X/Twitter stats",
@@ -1282,29 +1274,90 @@ tool("get_stats", {
1282
1274
  }
1283
1275
  });
1284
1276
  // ---- version: report installed version + deliver updates on demand ---------
1285
- tool("version", {
1286
- title: "Version & updates",
1287
- description: "Report the installed social-autoposter version and check npm for a newer release. " +
1288
- "action:'status' (default) shows installed vs latest published and whether an update is " +
1289
- "available. action:'update' pulls and installs the latest release (runs " +
1290
- "`npx social-autoposter@latest update`); the new MCP code takes effect after the client " +
1291
- "reconnects / restarts (this running process keeps the old code until then). Use this when " +
1292
- "the user asks what version they're on, or to push the latest update to their machine.",
1277
+ // ---- runtime: install + version/update + diagnostics ----------------------
1278
+ // ONE plumbing tool for the whole local-runtime lifecycle, action-based like
1279
+ // project_config and autopilot. The pipeline runs Python locally; rather than
1280
+ // depend on the user's system Python (the #1 source of install failures), the
1281
+ // first run provisions a fully OWNED uv runtime: standalone CPython + owned venv
1282
+ // + deps + Chromium. It also reports/installs new releases and runs the Doctor.
1283
+ // Plain (non-UI) so EVERY host can drive it the panel's Install card and
1284
+ // Update button are just skins that call action:'install' then poll
1285
+ // action:'status'. See runtime.ts for the provisioning + progress contract.
1286
+ //
1287
+ // Actions:
1288
+ // status (default) — is the owned runtime installed? + in-progress step detail
1289
+ // install — start provisioning in the background; poll status to follow
1290
+ // version — installed vs latest published, whether an update is available
1291
+ // update — pull + install the latest release (npx social-autoposter@latest update)
1292
+ // doctor — run structured environment diagnostics (phase: pre_connect|full)
1293
+ // doctor_status — last persisted Doctor result without re-running checks
1294
+ tool("runtime", {
1295
+ title: "Runtime: install, update & diagnostics",
1296
+ description: "The ONE plumbing tool for the autoposter's local runtime lifecycle. action:'status' (default) " +
1297
+ "reports whether the self-contained Python/Chromium runtime is installed and, mid-install, the " +
1298
+ "per-step progress (uv, Python, venv, dependencies, Chromium) — poll it after action:'install'. " +
1299
+ "action:'install' provisions that runtime (a private Python via uv, NOT your system Python, plus " +
1300
+ "deps and Chromium); it runs in the background and returns immediately, is safe to call " +
1301
+ "repeatedly, and is a no-op once installed. action:'version' shows installed vs latest published " +
1302
+ "and whether an update is available; action:'update' pulls and installs the latest release (runs " +
1303
+ "`npx social-autoposter@latest update`, taking effect after the client reconnects/restarts). " +
1304
+ "action:'doctor' runs structured environment diagnostics (phase:'pre_connect' is safe at " +
1305
+ "onboarding start and treats the missing X session/cookies as expected; phase:'full' verifies the " +
1306
+ "completed environment after X is connected); action:'doctor_status' returns the last persisted " +
1307
+ "Doctor result without re-running. Use this the first time the user sets up, when another tool " +
1308
+ "reports the runtime isn't ready, when the user asks what version they're on or to update, or to " +
1309
+ "diagnose a broken environment.",
1293
1310
  inputSchema: {
1294
- action: z.enum(["status", "update"]).optional(),
1311
+ action: z
1312
+ .enum(["status", "install", "version", "update", "doctor", "doctor_status"])
1313
+ .optional(),
1314
+ phase: z
1315
+ .enum(["pre_connect", "full"])
1316
+ .optional()
1317
+ .describe("Only for action:'doctor' — which diagnostic phase to run (default pre_connect)."),
1295
1318
  },
1296
- }, async ({ action }) => {
1319
+ }, async ({ action, phase }) => {
1320
+ // ---- install: start provisioning the owned runtime --------------------
1321
+ if (action === "install") {
1322
+ if (runtimeReady()) {
1323
+ completeOnboardingMilestone("runtime_ready");
1324
+ return jsonContent({ already_installed: true, ...runtimeSnapshot() });
1325
+ }
1326
+ recordOnboardingAttempt("runtime_ready");
1327
+ const progress = startProvisioning();
1328
+ return jsonContent({
1329
+ started: true,
1330
+ runtime_ready: false,
1331
+ note: "Runtime install started. Poll runtime action:'status' every ~1.5s for progress.",
1332
+ progress,
1333
+ });
1334
+ }
1335
+ // ---- version: installed vs latest published ---------------------------
1336
+ if (action === "version") {
1337
+ const v = await versionStatus();
1338
+ return jsonContent({
1339
+ installed: v.installed,
1340
+ latest_published: v.latest,
1341
+ update_available: v.update_available,
1342
+ update_command: "npx social-autoposter@latest update",
1343
+ note: v.latest == null
1344
+ ? "Could not reach npm to check for a newer version (offline or registry error)."
1345
+ : v.update_available
1346
+ ? `A newer version (${v.latest}) is available. Run this tool with action:'update' ` +
1347
+ "to install it, or run `npx social-autoposter@latest update` in a terminal."
1348
+ : "You are on the latest published version.",
1349
+ });
1350
+ }
1351
+ // ---- update: pull + install the latest release ------------------------
1297
1352
  if (action === "update") {
1298
- // Pull + install the latest published release. This overwrites mcp/dist/
1299
- // (including this running file safe; the loaded process keeps old code)
1300
- // and re-runs install.mjs to re-register the client config. npx is run
1301
- // non-interactively so it can't stall on a confirm prompt.
1353
+ // Overwrites mcp/dist/ (including this running file safe; the loaded
1354
+ // process keeps old code) and re-runs install.mjs to re-register the
1355
+ // client config. npx is non-interactive so it can't stall on a confirm.
1302
1356
  const before = VERSION;
1303
1357
  const res = await run("npx", ["-y", "social-autoposter@latest", "update"], {
1304
1358
  timeoutMs: 600_000,
1305
1359
  });
1306
- // Bust the latest-version cache so the post-update number is fresh.
1307
- const latest = await latestPublishedVersion();
1360
+ const latest = await latestPublishedVersion(); // bust the cache
1308
1361
  return jsonContent({
1309
1362
  action: "update",
1310
1363
  ran: "npx social-autoposter@latest update",
@@ -1317,27 +1370,29 @@ tool("version", {
1317
1370
  output_tail: (res.stdout + "\n" + res.stderr).trim().split("\n").slice(-20).join("\n"),
1318
1371
  });
1319
1372
  }
1320
- const v = await versionStatus();
1321
- return jsonContent({
1322
- installed: v.installed,
1323
- latest_published: v.latest,
1324
- update_available: v.update_available,
1325
- update_command: "npx social-autoposter@latest update",
1326
- note: v.latest == null
1327
- ? "Could not reach npm to check for a newer version (offline or registry error)."
1328
- : v.update_available
1329
- ? `A newer version (${v.latest}) is available. Run this tool with action:'update' ` +
1330
- "to install it, or run `npx social-autoposter@latest update` in a terminal."
1331
- : "You are on the latest published version.",
1332
- });
1373
+ // ---- doctor: run structured diagnostics -------------------------------
1374
+ if (action === "doctor") {
1375
+ const selected = phase || "pre_connect";
1376
+ const report = await runDoctorPhase(selected);
1377
+ return jsonContent({ doctor: report, onboarding: onboardingSnapshot() });
1378
+ }
1379
+ // ---- doctor_status: last persisted Doctor result ----------------------
1380
+ if (action === "doctor_status") {
1381
+ return jsonContent({
1382
+ doctor: onboardingLedger()?.doctor?.latest ?? null,
1383
+ onboarding: onboardingSnapshot(),
1384
+ });
1385
+ }
1386
+ // ---- status (default): runtime install snapshot -----------------------
1387
+ const snapshot = runtimeSnapshot();
1388
+ if (snapshot.runtime_ready) {
1389
+ completeOnboardingMilestone("runtime_ready");
1390
+ }
1391
+ else if (snapshot.progress?.done && !snapshot.progress.ok) {
1392
+ blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
1393
+ }
1394
+ return jsonContent({ ...snapshot, onboarding: onboardingSnapshot() });
1333
1395
  });
1334
- // ---- runtime installer ----------------------------------------------------
1335
- // The pipeline runs Python locally. Rather than depend on the user's system
1336
- // Python (the #1 source of install failures), the first run provisions a fully
1337
- // OWNED uv runtime: standalone CPython + owned venv + deps + Chromium. These two
1338
- // tools drive it. They are plain (non-UI) tools so EVERY host can install — the
1339
- // panel's Install card is just a skin that calls install_runtime then polls
1340
- // install_status. See runtime.ts for the provisioning + progress contract.
1341
1396
  function runtimeSnapshot() {
1342
1397
  const rt = readRuntime();
1343
1398
  const progress = readProgress();
@@ -1350,138 +1405,10 @@ function runtimeSnapshot() {
1350
1405
  onboarding: onboardingSnapshot(),
1351
1406
  };
1352
1407
  }
1353
- tool("install_runtime", {
1354
- title: "Install the Python runtime",
1355
- description: "One-time setup that provisions the self-contained runtime the autoposter needs: a private " +
1356
- "Python (via uv, not your system Python), its dependencies, and the Chromium browser. Runs in " +
1357
- "the background and returns immediately; poll `install_status` for progress. Safe to call " +
1358
- "repeatedly; it resumes/repairs and is a no-op once everything is installed. Use this the " +
1359
- "first time the user sets up, or if other tools report the runtime isn't ready.",
1360
- inputSchema: {},
1361
- }, async () => {
1362
- if (runtimeReady()) {
1363
- completeOnboardingMilestone("runtime_ready");
1364
- return jsonContent({ already_installed: true, ...runtimeSnapshot() });
1365
- }
1366
- recordOnboardingAttempt("runtime_ready");
1367
- const progress = startProvisioning();
1368
- return jsonContent({
1369
- started: true,
1370
- runtime_ready: false,
1371
- note: "Runtime install started. Poll install_status every ~1.5s for progress.",
1372
- progress,
1373
- });
1374
- });
1375
- tool("install_status", {
1376
- title: "Runtime install status",
1377
- description: "Report whether the self-contained Python/Chromium runtime is installed and, if an install is " +
1378
- "in progress, the per-step progress (uv, Python, venv, dependencies, Chromium). Poll this after " +
1379
- "install_runtime to follow the install to completion.",
1380
- inputSchema: {},
1381
- }, async () => {
1382
- const snapshot = runtimeSnapshot();
1383
- if (snapshot.runtime_ready) {
1384
- completeOnboardingMilestone("runtime_ready");
1385
- }
1386
- else if (snapshot.progress?.done && !snapshot.progress.ok) {
1387
- blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
1388
- }
1389
- return jsonContent({ ...snapshot, onboarding: onboardingSnapshot() });
1390
- });
1391
- // ---- doctor: structured environment diagnostics --------------------------
1392
- // Uses the same shared engine as `npx social-autoposter doctor`. Pre-connect
1393
- // avoids invasive keychain access and classifies not-yet-created X artifacts as
1394
- // expected; full verifies the completed browser/session/cookie environment.
1395
- tool("doctor", {
1396
- title: "Diagnose the onboarding environment",
1397
- description: "Run the structured social-autoposter Doctor and persist the result in the onboarding " +
1398
- "ledger. phase:'pre_connect' is safe at onboarding start and treats the missing X session/" +
1399
- "cookie artifacts as expected. phase:'full' verifies the completed environment after X is " +
1400
- "connected. action:'status' returns the most recent persisted result without re-running checks.",
1401
- inputSchema: {
1402
- action: z.enum(["run", "status"]).optional(),
1403
- phase: z.enum(["pre_connect", "full"]).optional(),
1404
- },
1405
- }, async ({ action, phase, }) => {
1406
- if (action === "status") {
1407
- return jsonContent({
1408
- doctor: onboardingLedger()?.doctor?.latest ?? null,
1409
- onboarding: onboardingSnapshot(),
1410
- });
1411
- }
1412
- const selected = phase || "pre_connect";
1413
- const report = await runDoctorPhase(selected);
1414
- return jsonContent({
1415
- doctor: report,
1416
- onboarding: onboardingSnapshot(),
1417
- });
1418
- });
1419
- // ---- config: read / edit the raw config.json ------------------------------
1420
- // The panel renders the full config and lets the user edit it. Writing is
1421
- // guarded: the new content must parse as JSON, and we always drop a timestamped
1422
- // backup next to config.json before overwriting, so a bad paste is recoverable.
1423
- tool("config", {
1424
- title: "View or edit config.json",
1425
- description: "Read or update the autoposter's config.json (the source of truth for every project, the X/" +
1426
- "Reddit/LinkedIn account handles, topics, and exclusions). action:'get' (default) returns the " +
1427
- "full raw JSON; action:'save' validates the supplied `content` as JSON, writes a timestamped " +
1428
- "backup, then overwrites config.json. Use when the user asks to see, edit, or fix their config.",
1429
- inputSchema: {
1430
- action: z.enum(["get", "save"]).optional(),
1431
- content: z.string().optional(),
1432
- },
1433
- }, async (args) => {
1434
- const action = args.action || "get";
1435
- const cfgPath = configPath();
1436
- if (action === "get") {
1437
- try {
1438
- const content = fs.readFileSync(cfgPath, "utf-8");
1439
- return jsonContent({ ok: true, path: cfgPath, bytes: content.length, content });
1440
- }
1441
- catch (e) {
1442
- return jsonContent({ ok: false, path: cfgPath, error: String(e?.message || e) });
1443
- }
1444
- }
1445
- // save
1446
- const content = args.content;
1447
- if (typeof content !== "string" || content.trim() === "") {
1448
- return jsonContent({ ok: false, error: "Nothing to save: `content` was empty." });
1449
- }
1450
- let parsed;
1451
- try {
1452
- parsed = JSON.parse(content);
1453
- }
1454
- catch (e) {
1455
- // Don't write a config that won't parse — every pipeline reads this file.
1456
- return jsonContent({ ok: false, error: "Invalid JSON, not saved: " + String(e?.message || e) });
1457
- }
1458
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1459
- return jsonContent({ ok: false, error: "Top level of config.json must be a JSON object." });
1460
- }
1461
- try {
1462
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1463
- const backup = `${cfgPath}.bak-panel-${stamp}`;
1464
- try {
1465
- fs.copyFileSync(cfgPath, backup);
1466
- }
1467
- catch {
1468
- /* first-write / missing original is non-fatal */
1469
- }
1470
- // Re-serialize the parsed object so what lands on disk is canonical,
1471
- // 2-space-indented JSON with a trailing newline (matches the Python
1472
- // writers), regardless of how the user formatted their paste.
1473
- const out = JSON.stringify(parsed, null, 2) + "\n";
1474
- fs.writeFileSync(cfgPath, out, "utf-8");
1475
- return jsonContent({ ok: true, path: cfgPath, bytes: out.length, backup });
1476
- }
1477
- catch (e) {
1478
- return jsonContent({ ok: false, error: "Write failed: " + String(e?.message || e) });
1479
- }
1480
- });
1481
1408
  // ---- panel: MCP Apps control surface --------------------------------------
1482
1409
  // A self-contained HTML view rendered by hosts that support MCP Apps (Claude
1483
1410
  // desktop/web, etc.). It duplicates NO pipeline logic: each button calls one of
1484
- // the tools above (draft_cycle / autopilot / setup / get_stats) through the host
1411
+ // the tools above (draft_cycle / project_config / get_stats) through the host
1485
1412
  // and re-reads status. The tool itself returns the first-paint snapshot so the
1486
1413
  // view has data the instant it loads.
1487
1414
  // Is either launchd job (cycle / daily updater) currently loaded?
@@ -1641,19 +1568,64 @@ function startLocalPanel() {
1641
1568
  }
1642
1569
  });
1643
1570
  srv.on("error", reject);
1644
- srv.listen(0, "127.0.0.1", () => {
1571
+ // Optional fixed port (SAPS_PANEL_PORT) for deterministic addressing; default
1572
+ // is an OS-assigned ephemeral port.
1573
+ const wantPort = Number(process.env.SAPS_PANEL_PORT) || 0;
1574
+ srv.listen(wantPort, "127.0.0.1", () => {
1645
1575
  const addr = srv.address();
1646
1576
  const port = typeof addr === "object" && addr ? addr.port : 0;
1647
1577
  localPanel = { url: `http://127.0.0.1:${port}/`, server: srv };
1578
+ writePanelUrl(localPanel.url);
1648
1579
  resolve(localPanel.url);
1649
1580
  });
1650
1581
  });
1651
1582
  }
1652
- // Open a URL in the user's default browser, cross-platform. Honors
1653
- // SAPS_PANEL_NO_OPEN (set on headless autopilot boxes or in tests) to skip the
1654
- // actual open while still returning the URL to the caller.
1583
+ // Publish the loopback URL to stable files so out-of-process readers can find
1584
+ // the ephemeral port without scraping `lsof`:
1585
+ // - panel-url plain text, for the Claude Code side-panel reverse proxy.
1586
+ // - panel-endpoint.json richer (url + version + pid), for the menu bar app,
1587
+ // which POSTs /tool/<name> here for live data.
1588
+ // Best-effort: a write failure never blocks the panel (readers re-check /health).
1589
+ function writePanelUrl(url) {
1590
+ try {
1591
+ const dir = path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp");
1592
+ fs.mkdirSync(dir, { recursive: true });
1593
+ fs.writeFileSync(path.join(dir, "panel-url"), url, "utf-8");
1594
+ 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");
1595
+ }
1596
+ catch (e) {
1597
+ console.error("[social-autoposter-mcp] writePanelUrl failed:", e?.message || e);
1598
+ }
1599
+ }
1600
+ // The owned state dir, honoring SAPS_STATE_DIR (matches menubar/s4l_state.py).
1601
+ function sapsStateDir() {
1602
+ return (process.env.SAPS_STATE_DIR ||
1603
+ path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp"));
1604
+ }
1605
+ // Signal the menu bar that a fresh draft batch is ready for pop-up review. The
1606
+ // chat-table review path is unchanged and still works; this just ALSO lets the
1607
+ // corner cards drive review (both surfaces de-dup via the plan's `posted` flag).
1608
+ // The menu bar reads review-request.json, presents the cards, posts via the
1609
+ // loopback post_drafts tool, then clears the file. Best-effort: a write failure
1610
+ // just means no pop-ups this batch (chat review still works).
1611
+ function writeReviewRequest(req) {
1612
+ try {
1613
+ const dir = sapsStateDir();
1614
+ fs.mkdirSync(dir, { recursive: true });
1615
+ fs.writeFileSync(path.join(dir, "review-request.json"), JSON.stringify(req, null, 2) + "\n", "utf-8");
1616
+ }
1617
+ catch (e) {
1618
+ console.error("[social-autoposter-mcp] writeReviewRequest failed:", e?.message || e);
1619
+ }
1620
+ }
1621
+ // Open a URL in the user's default browser, cross-platform. Opening is OPT-IN:
1622
+ // by default we do NOT pop a browser tab. The dashboard already surfaces in-host
1623
+ // (MCP Apps inline) or via the Claude Code side panel / returned loopback URL, so
1624
+ // auto-opening the OS browser on every dashboard call is unwanted noise. Set
1625
+ // SAPS_PANEL_OPEN_BROWSER=1 to restore the old auto-open behavior. (The URL is
1626
+ // always returned to the caller regardless, so nothing is lost when we don't open.)
1655
1627
  async function openInBrowser(url) {
1656
- if (process.env.SAPS_PANEL_NO_OPEN)
1628
+ if (!process.env.SAPS_PANEL_OPEN_BROWSER)
1657
1629
  return;
1658
1630
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
1659
1631
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
@@ -1667,10 +1639,10 @@ async function openInBrowser(url) {
1667
1639
  appTool("dashboard", {
1668
1640
  title: "Social Autoposter dashboard",
1669
1641
  description: "Render the Social Autoposter dashboard in chat: a visual surface showing project setup, X " +
1670
- "connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, toggle " +
1671
- "autopilot, connect X, and refresh. Use when the user asks to see the dashboard, panel, " +
1642
+ "connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, connect X, " +
1643
+ "and refresh. Use when the user asks to see the dashboard, panel, " +
1672
1644
  "status, or controls. ALSO call this at the end of any state-changing or results-producing " +
1673
- "action (draft_cycle, post_drafts, autopilot enable/disable, get_stats) so the user sees the " +
1645
+ "action (draft_cycle, post_drafts, get_stats) so the user sees the " +
1674
1646
  "updated dashboard. Hosts without UI support get the same data as text.",
1675
1647
  inputSchema: {},
1676
1648
  // fallback_url is set only when the host can't render the ui:// resource and
@@ -1699,8 +1671,9 @@ appTool("dashboard", {
1699
1671
  return { content: [], structuredContent: { snapshot: JSON.stringify(snap) } };
1700
1672
  }
1701
1673
  // Host CAN'T render inline (Claude Code / Cowork today): serve the identical
1702
- // panel.html from a loopback HTTP server and open it in the browser, so the
1703
- // user still gets the visual surface instead of a wall of text.
1674
+ // panel.html from a loopback HTTP server. We do NOT auto-open a browser tab
1675
+ // (see openInBrowser opt-in only); the dashboard is shown in the Claude Code
1676
+ // side panel, and the loopback URL is returned for anyone who wants to open it.
1704
1677
  try {
1705
1678
  const url = await startLocalPanel();
1706
1679
  await openInBrowser(url);
@@ -1708,7 +1681,7 @@ appTool("dashboard", {
1708
1681
  content: [{
1709
1682
  type: "text",
1710
1683
  text: human +
1711
- `\n\nThis host can't render the dashboard inline, so I opened it in your browser: ${url}`,
1684
+ `\n\nThis host can't render the dashboard inline. It's available in the side panel; loopback URL: ${url}`,
1712
1685
  }],
1713
1686
  structuredContent: { snapshot: JSON.stringify(snap), fallback_url: url },
1714
1687
  };
@@ -1804,6 +1777,18 @@ async function main() {
1804
1777
  const transport = new StdioServerTransport();
1805
1778
  await server.connect(transport);
1806
1779
  console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
1780
+ // Eagerly start the loopback panel server so the Claude Code side panel (and any
1781
+ // reverse proxy in front of it) always has a backend to hit, without waiting for
1782
+ // a first `dashboard` call. Best-effort: a bind failure must never block boot.
1783
+ void startLocalPanel()
1784
+ .then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
1785
+ .catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
1786
+ // Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
1787
+ // and cheap when already present, so existing installs pick it up on the next
1788
+ // Claude restart without re-provisioning. Best-effort: never blocks boot.
1789
+ void ensureMenubar()
1790
+ .then((r) => console.error(`[social-autoposter-mcp] menubar: ${r.skipped ? "skip" : r.ok ? "ok" : "fail"} (${r.detail})`))
1791
+ .catch((e) => console.error("[social-autoposter-mcp] menubar ensure failed:", e?.message || e));
1807
1792
  // Phone home so this .mcpb install is visible in the install-lane digest
1808
1793
  // (parity with the npx launchd heartbeat). Once on startup, then every 15m
1809
1794
  // while the desktop app keeps the server alive. unref() so it never holds the