social-autoposter 1.6.69 → 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";
@@ -177,12 +176,11 @@ const server = new McpServer({
177
176
  "surfaces `update_available` and an `update_hint`.\n\n" +
178
177
  "TYPICAL FLOW: `project_config` (configure OR edit the project, and connect X) -> `draft_cycle` " +
179
178
  "(scan + review a batch; the user approves / edits / skips every draft in a single form) -> " +
180
- "`autopilot` (enable to also turn on hands-free background posting AND daily auto-updates) -> " +
181
179
  "`get_stats` (see performance). Run `project_config` first; the other tools refuse until a " +
182
180
  "project is fully configured. To change anything about a project later, call `project_config` " +
183
181
  "again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
184
182
  "RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
185
- "(`draft_cycle`, `post_drafts`, `autopilot` enable/disable, `get_stats`), end your turn by " +
183
+ "(`draft_cycle`, `post_drafts`, `get_stats`), end your turn by " +
186
184
  "calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
187
185
  "`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.",
188
186
  });
@@ -502,6 +500,19 @@ async function postApproved(batchId, plan) {
502
500
  catch {
503
501
  /* keep raw */
504
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
+ }
505
516
  return {
506
517
  attempted: approved.length,
507
518
  exit_code: res.code,
@@ -604,7 +615,7 @@ tool("project_config", {
604
615
  "Call with status:true (or no name) to list every configured project, its remaining fields, AND " +
605
616
  "whether X is connected. Use config, conversation context, profile_scan, and website research " +
606
617
  "before asking for fields. Ask only if no product can be identified or an interactive login is " +
607
- "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 " +
608
619
  "fully set up.",
609
620
  inputSchema: {
610
621
  status: z.boolean().optional(),
@@ -1071,6 +1082,15 @@ tool("draft_cycle", {
1071
1082
  outcome: "review_batch",
1072
1083
  draft_count: count,
1073
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
+ });
1074
1094
  const table = renderDraftsTable(plan);
1075
1095
  const message = `Drafted ${count} ${count === 1 ? "reply" : "replies"} for "${proj}" ` +
1076
1096
  `(batch ${drafted.batchId}). NOTHING has been posted yet.\n\n` +
@@ -1175,6 +1195,18 @@ tool("post_drafts", {
1175
1195
  else
1176
1196
  warnings.push(`ignored #${n}: out of range (1-${total})`);
1177
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
+ }
1178
1210
  candidates.forEach((c, i) => (c.approved = approve.has(i + 1)));
1179
1211
  writePlan(batch_id, plan);
1180
1212
  if (approve.size === 0) {
@@ -1199,92 +1231,16 @@ tool("post_drafts", {
1199
1231
  warnings,
1200
1232
  });
1201
1233
  });
1202
- // ---- autopilot: one tool, three actions -----------------------------------
1203
- tool("autopilot", {
1204
- title: "X autopilot",
1205
- description: "Control background X/Twitter posting. action=enable loads the launchd job so the " +
1206
- "cycle fires automatically; action=disable unloads it (manual draft_cycle still works); " +
1207
- "action=status reports whether it is loaded. After enable/disable, call the `dashboard` " +
1208
- "tool so the user sees the updated autopilot state.",
1209
- inputSchema: {
1210
- action: z.enum(["enable", "disable", "status"]),
1211
- },
1212
- }, async ({ action }) => {
1213
- if (action !== "status" && !hasReadyProject()) {
1214
- return textContent("No project is fully set up yet, so autopilot has nothing to post. Run the `project_config` tool " +
1215
- "first. Note: autopilot runs the background cycle across all configured projects; it is " +
1216
- "not scoped to one project.");
1217
- }
1218
- const uid = process.getuid ? process.getuid() : 0;
1219
- const logDir = path.join(repoDir(), "skill", "logs");
1220
- if (action === "status") {
1221
- const res = await run("launchctl", ["list"], { timeoutMs: 10_000 });
1222
- const lines = res.stdout.split("\n");
1223
- const loaded = lines.some((l) => l.includes(TWITTER_AUTOPILOT_LABEL));
1224
- const updaterLoaded = lines.some((l) => l.includes(UPDATER_LABEL));
1225
- return jsonContent({
1226
- label: TWITTER_AUTOPILOT_LABEL,
1227
- loaded,
1228
- auto_update_label: UPDATER_LABEL,
1229
- auto_update_loaded: updaterLoaded,
1230
- });
1231
- }
1232
- if (action === "enable") {
1233
- // Bring up the on-screen overlay watcher so background autopilot cycles
1234
- // still paint the harness status/sidebar. Idempotent + detached.
1235
- await ensureOverlayWatch();
1236
- // 1) Cycle plist. Write one pointing at the self-update guard ONLY if no
1237
- // plist exists yet; never overwrite a hand-tuned/dev plist.
1238
- const createdCycle = ensurePlist(TWITTER_AUTOPILOT_PLIST, plistXml({
1239
- label: TWITTER_AUTOPILOT_LABEL,
1240
- programArgs: ["/bin/bash", path.join(repoDir(), "skill", "run-cycle-update-guard.sh")],
1241
- intervalSecs: 60,
1242
- runAtLoad: false,
1243
- stdoutLog: path.join(logDir, "launchd-twitter-cycle-stdout.log"),
1244
- stderrLog: path.join(logDir, "launchd-twitter-cycle-stderr.log"),
1245
- }));
1246
- const cycleRes = await loadPlist(TWITTER_AUTOPILOT_LABEL, TWITTER_AUTOPILOT_PLIST, uid);
1247
- // 2) Daily self-updater. Keeps a headless install current with no human
1248
- // in the loop. RunAtLoad so it also checks shortly after enable.
1249
- const createdUpdater = ensurePlist(UPDATER_PLIST, plistXml({
1250
- label: UPDATER_LABEL,
1251
- programArgs: ["/bin/bash", path.join(repoDir(), "skill", "social-autoposter-update.sh")],
1252
- intervalSecs: 86_400,
1253
- runAtLoad: true,
1254
- stdoutLog: path.join(logDir, "launchd-self-update-stdout.log"),
1255
- stderrLog: path.join(logDir, "launchd-self-update-stderr.log"),
1256
- }));
1257
- const updaterRes = await loadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
1258
- return jsonContent({
1259
- action: "enable",
1260
- autopilot: {
1261
- loaded: cycleRes.code === 0,
1262
- plist: TWITTER_AUTOPILOT_PLIST,
1263
- created: createdCycle,
1264
- error: cycleRes.code === 0 ? null : (cycleRes.stderr || cycleRes.stdout).trim(),
1265
- },
1266
- auto_update: {
1267
- loaded: updaterRes.code === 0,
1268
- plist: UPDATER_PLIST,
1269
- created: createdUpdater,
1270
- note: "Daily updater enabled. It self-updates real npm installs and is a no-op on dev/source " +
1271
- "checkouts (refuses to clobber a .git working tree).",
1272
- error: updaterRes.code === 0 ? null : (updaterRes.stderr || updaterRes.stdout).trim(),
1273
- },
1274
- });
1275
- }
1276
- // disable — unload both jobs (leave the plist files in place for re-enable)
1277
- const cycleOff = await unloadPlist(TWITTER_AUTOPILOT_LABEL, TWITTER_AUTOPILOT_PLIST, uid);
1278
- const updaterOff = await unloadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
1279
- return jsonContent({
1280
- action: "disable",
1281
- autopilot_unloaded: cycleOff.code === 0,
1282
- auto_update_unloaded: updaterOff.code === 0,
1283
- note: cycleOff.code === 0
1284
- ? "Autopilot and daily auto-update unloaded. Manual draft_cycle still works."
1285
- : `Autopilot disable reported exit ${cycleOff.code}: ${(cycleOff.stderr || cycleOff.stdout).trim()}`,
1286
- });
1287
- });
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.
1288
1244
  // ---- get_stats: read-only -------------------------------------------------
1289
1245
  tool("get_stats", {
1290
1246
  title: "Get X/Twitter stats",
@@ -1452,7 +1408,7 @@ function runtimeSnapshot() {
1452
1408
  // ---- panel: MCP Apps control surface --------------------------------------
1453
1409
  // A self-contained HTML view rendered by hosts that support MCP Apps (Claude
1454
1410
  // desktop/web, etc.). It duplicates NO pipeline logic: each button calls one of
1455
- // the tools above (draft_cycle / autopilot / project_config / get_stats) through the host
1411
+ // the tools above (draft_cycle / project_config / get_stats) through the host
1456
1412
  // and re-reads status. The tool itself returns the first-paint snapshot so the
1457
1413
  // view has data the instant it loads.
1458
1414
  // Is either launchd job (cycle / daily updater) currently loaded?
@@ -1624,19 +1580,44 @@ function startLocalPanel() {
1624
1580
  });
1625
1581
  });
1626
1582
  }
1627
- // Publish the loopback URL to a stable file so an out-of-process reader (the
1628
- // Claude Code side-panel reverse proxy) can find the ephemeral port without
1629
- // scraping `lsof`. Best-effort: a write failure never blocks the panel.
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).
1630
1589
  function writePanelUrl(url) {
1631
1590
  try {
1632
1591
  const dir = path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp");
1633
1592
  fs.mkdirSync(dir, { recursive: true });
1634
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");
1635
1595
  }
1636
1596
  catch (e) {
1637
1597
  console.error("[social-autoposter-mcp] writePanelUrl failed:", e?.message || e);
1638
1598
  }
1639
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
+ }
1640
1621
  // Open a URL in the user's default browser, cross-platform. Opening is OPT-IN:
1641
1622
  // by default we do NOT pop a browser tab. The dashboard already surfaces in-host
1642
1623
  // (MCP Apps inline) or via the Claude Code side panel / returned loopback URL, so
@@ -1658,10 +1639,10 @@ async function openInBrowser(url) {
1658
1639
  appTool("dashboard", {
1659
1640
  title: "Social Autoposter dashboard",
1660
1641
  description: "Render the Social Autoposter dashboard in chat: a visual surface showing project setup, X " +
1661
- "connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, toggle " +
1662
- "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, " +
1663
1644
  "status, or controls. ALSO call this at the end of any state-changing or results-producing " +
1664
- "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 " +
1665
1646
  "updated dashboard. Hosts without UI support get the same data as text.",
1666
1647
  inputSchema: {},
1667
1648
  // fallback_url is set only when the host can't render the ui:// resource and
@@ -1802,6 +1783,12 @@ async function main() {
1802
1783
  void startLocalPanel()
1803
1784
  .then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
1804
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));
1805
1792
  // Phone home so this .mcpb install is visible in the install-lane digest
1806
1793
  // (parity with the npx launchd heartbeat). Once on startup, then every 15m
1807
1794
  // while the desktop app keeps the server alive. unref() so it never holds the