social-autoposter 1.6.74 → 1.6.77

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
@@ -19,7 +19,7 @@ import fs from "node:fs";
19
19
  import { repoDir, runPython, run, readPlan, writePlan, planPath, scanResultPath, } from "./repo.js";
20
20
  import { applySetup, resolveProject, listManagedProjectStatus, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, } from "./setup.js";
21
21
  import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
22
- import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, } from "./runtime.js";
22
+ import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, } from "./runtime.js";
23
23
  import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
24
24
  import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
25
25
  import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
@@ -30,6 +30,7 @@ import http from "node:http";
30
30
  // (vite-plugin-singlefile) into dist/panel.html alongside this compiled file.
31
31
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
32
32
  const PANEL_URI = "ui://social-autoposter/panel.html";
33
+ const PRODUCT_LINK_URI = "ui://social-autoposter/product-link.html";
33
34
  // Stable id for the accumulating draft review queue. submit_drafts appends each
34
35
  // run's drafts here (dedup by tweet URL) so the menu-bar cards PILE UP across a
35
36
  // continuous autopilot instead of each run overwriting the last; post_drafts posts
@@ -151,7 +152,7 @@ async function unloadPlist(label, plistPath, uid) {
151
152
  // so serverInfo.version finally reflects what the user actually has installed
152
153
  // instead of a frozen literal.
153
154
  const server = new McpServer({
154
- name: "social-autoposter",
155
+ name: "S4L",
155
156
  version: VERSION,
156
157
  }, {
157
158
  // Host injects this into the agent's system prompt, so be proactive about
@@ -182,12 +183,18 @@ const server = new McpServer({
182
183
  "autopilot by calling the HOST tool `create_scheduled_task` with: taskId " +
183
184
  "'social-autoposter-autopilot'; cronExpression '* * * * *' (continuous, like the pipeline; the " +
184
185
  "host runs one at a time and skips overlapping fires, so cycles run back-to-back); and a " +
185
- "self-contained prompt telling the run to call scan_candidates, draft ONE on-brand reply " +
186
- "(<=250 chars, same language as the thread, adds real value) for each candidate worth engaging, " +
187
- "then call submit_drafts with those drafts, and NOT post (drafts pile up in the menu-bar " +
188
- "approval cards for the user to approve). Then run ONE cycle yourself now (scan_candidates -> " +
189
- "draft -> submit_drafts) to seed the cards and pre-approve the tools, so unattended fires do not " +
190
- "stall on a permission prompt. If the host exposes no create_scheduled_task tool, tell the user " +
186
+ "self-contained prompt telling the run to call scan_candidates (which long-polls if it " +
187
+ "returns a `Scan in progress` status, call scan_candidates again with the same args and keep " +
188
+ "re-calling until it returns candidates; never sleep or use background waits between calls), " +
189
+ "then (if it needs the project's brand voice to draft on-brand) call project_config to read it, " +
190
+ "draft ONE on-brand reply (<=250 chars, same language as the thread, adds real value) for each " +
191
+ "candidate worth engaging, then call submit_drafts with those drafts, and NOT post (drafts pile " +
192
+ "up in the menu-bar approval cards for the user to approve). The prompt MUST name EVERY tool the " +
193
+ "run can use (scan_candidates, project_config, submit_drafts) so none is a surprise mid-run. " +
194
+ "Then run ONE FULL cycle yourself now that exercises ALL of them " +
195
+ "(scan_candidates -> project_config -> draft -> submit_drafts) to seed the cards AND pre-approve " +
196
+ "every tool, so unattended fires never stall waiting on a per-tool permission prompt. If the host " +
197
+ "exposes no create_scheduled_task tool, tell the user " +
191
198
  "the autopilot could not be scheduled here and to run a draft cycle manually instead.\n\n" +
192
199
  "BE PROACTIVE ABOUT MARKETING MOMENTS. Whenever the user ships, finishes, merges, or releases " +
193
200
  "something worth talking about in this session (a new feature, a launch, a long-awaited fix, a " +
@@ -524,6 +531,10 @@ async function postApproved(batchId, plan) {
524
531
  const approved = (plan.candidates || []).filter((c) => c.approved === true);
525
532
  if (approved.length === 0)
526
533
  return { attempted: 0, exit_code: 0, summary: "nothing approved" };
534
+ // Posting is a priority over scanning: abort any in-flight plugin scan so the
535
+ // approved post takes the browser immediately instead of waiting on the lock.
536
+ // Plugin pipeline only — never affects the plist autopilot.
537
+ preemptScanForPost();
527
538
  const approvedBatch = `${batchId}_approved`;
528
539
  writePlan(approvedBatch, { ...plan, candidates: approved });
529
540
  // SAPS_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
@@ -590,7 +601,7 @@ async function postApproved(batchId, plan) {
590
601
  // drift from REQUIRED_FIELDS / the project_config tool's flow. All real logic stays
591
602
  // in `project_config`; this is just a convenience handle to begin.
592
603
  server.registerPrompt("getting-started", {
593
- title: "Set up social-autoposter",
604
+ title: "Set up S4L",
594
605
  description: "Start here. Walks you through configuring a product and connecting your X/Twitter " +
595
606
  "account so the autoposter can draft and post for you.",
596
607
  }, async () => ({
@@ -637,8 +648,10 @@ const WEBSITE_RESEARCH_INSTRUCTIONS = "PRODUCT RESEARCH (do this before saving t
637
648
  "register, vibe) while keeping every product CLAIM factual to the site. Don't invent " +
638
649
  "features, metrics, or guarantees the site doesn't state.\n" +
639
650
  "5. Save the best conservative factual draft without adding a confirmation round-trip. Call " +
640
- "project_config with name + the product fields (plus voice/search_topics from the profile scan). If the " +
641
- "site is thin or unreachable, use only supported facts and leave optional detail conservative; " +
651
+ "project_config with name + the product fields (plus voice/search_topics from the profile scan), AND " +
652
+ "expand those topics into a `search_queries` array of ~30 concrete X advanced-search strings in the " +
653
+ "SAME call — YOU are the model, so do the expansion in-session; it seeds directly with no `claude -p`. " +
654
+ "If the site is thin or unreachable, use only supported facts and leave optional detail conservative; " +
642
655
  "ask the user only if a required field is genuinely unknowable.";
643
656
  // ---- project_config: per-project config (the "brain": project, website, voice) -----
644
657
  // Run this FIRST. The action tools refuse until at least one project is ready.
@@ -730,6 +743,14 @@ tool("project_config", {
730
743
  .union([z.array(z.string()), z.string()])
731
744
  .optional()
732
745
  .describe("Topics/keywords to monitor on X (comma-separated or array)"),
746
+ search_queries: z
747
+ .array(z.string())
748
+ .optional()
749
+ .describe("Cold-start X search-query bank YOU expand from search_topics, in this same call. " +
750
+ "Fan each topic into a few concrete X advanced-search strings (aim ~30 total, e.g. " +
751
+ "'mac menu bar app -filter:replies', 'screen recording privacy lang:en') so the cycle " +
752
+ "fans out instead of running one crude topic-as-query. Seeded directly with NO `claude " +
753
+ "-p` — you are the model doing the expansion, so setup never needs the claude CLI."),
733
754
  get_started_link: z
734
755
  .string()
735
756
  .optional()
@@ -1001,13 +1022,28 @@ tool("project_config", {
1001
1022
  // search queries (project_search_queries) so the deterministic Phase 1
1002
1023
  // bank (qualified_query_bank.py) has something to run on day one.
1003
1024
  // Without this, a freshly-configured project's bank is empty and the
1004
- // cycle falls back to ONE crude topic-as-query. Best-effort: a failure
1005
- // here never fails setup; the topic-as-query fallback still works, just
1006
- // narrower. Supply-test is auto (only if the harness is up), so this
1007
- // stays fast when X isn't connected yet. (2026-06-04)
1008
- if (seed.code === 0) {
1025
+ // cycle falls back to ONE crude topic-as-query.
1026
+ //
1027
+ // CLAUDE-FREE: the in-session agent (you) expands topics -> queries and
1028
+ // passes them as `search_queries` in THIS call. We seed them directly via
1029
+ // --queries-json, so setup never shells out to `claude -p` (which isn't
1030
+ // installed in the Desktop / .mcpb lane and was the FileNotFoundError
1031
+ // users hit). If the agent didn't supply queries, we skip expansion
1032
+ // entirely — the topic-as-query fallback still runs, just narrower — and
1033
+ // nudge the agent to re-run with search_queries. (2026-06-19)
1034
+ const agentQueries = Array.isArray(args.search_queries)
1035
+ ? args.search_queries.map((q) => String(q).trim()).filter(Boolean)
1036
+ : [];
1037
+ if (seed.code === 0 && agentQueries.length) {
1009
1038
  try {
1010
- const qseed = await runPython("scripts/seed_search_queries.py", ["--project", result.project, "--supply-test", "auto", "--emit-json"], { timeoutMs: 600_000 });
1039
+ const qfile = path.join(os.tmpdir(), `saps-queries-${result.project}-${Date.now()}.json`);
1040
+ fs.writeFileSync(qfile, JSON.stringify({ queries: agentQueries.map((q) => ({ query: q, topic: "" })) }));
1041
+ const qseed = await runPython("scripts/seed_search_queries.py", ["--project", result.project, "--queries-json", qfile,
1042
+ "--supply-test", "auto", "--emit-json"], { timeoutMs: 600_000 });
1043
+ try {
1044
+ fs.unlinkSync(qfile);
1045
+ }
1046
+ catch { /* best-effort cleanup */ }
1011
1047
  const qm = /seeded=(\d+)\s+inserted=(\d+)\s+updated=(\d+)/.exec(qseed.stdout);
1012
1048
  const qjson = qseed.stdout.split("===QUERIES_JSON===")[1];
1013
1049
  if (qjson) {
@@ -1020,17 +1056,20 @@ tool("project_config", {
1020
1056
  }
1021
1057
  if (qseed.code === 0 && qm) {
1022
1058
  const n = searchQueries.length || Number(qm[1]);
1023
- seedNote += ` Expanded them into ${n} search quer${n === 1 ? "y" : "ies"} so the cycle can fan out instead of running a single query.`;
1059
+ seedNote += ` Seeded ${n} search quer${n === 1 ? "y" : "ies"} so the cycle can fan out instead of running a single query.`;
1024
1060
  }
1025
1061
  else if (qseed.code !== 0) {
1026
1062
  const qtail = (qseed.stderr || qseed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
1027
- seedNote += ` (Search queries not expanded yet — ${qtail}. The cycle still runs off the seeded topics.)`;
1063
+ seedNote += ` (Search queries not seeded yet — ${qtail}. The cycle still runs off the seeded topics.)`;
1028
1064
  }
1029
1065
  }
1030
1066
  catch (e) {
1031
- seedNote += ` (Search-query expansion skipped — ${e.message}.)`;
1067
+ seedNote += ` (Search-query seeding skipped — ${e.message}.)`;
1032
1068
  }
1033
1069
  }
1070
+ else if (seed.code === 0) {
1071
+ seedNote += ` (No search_queries supplied, so the cycle will run off the seeded topics one at a time. To fan out, re-call project_config with a search_queries array of ~30 X search strings you expand from these topics — it seeds them directly, no claude CLI.)`;
1072
+ }
1034
1073
  }
1035
1074
  // Surface any advanced (escape-hatch) field edits in the note so the
1036
1075
  // agent can confirm exactly what changed to the user.
@@ -1447,13 +1486,16 @@ let localPanel = null;
1447
1486
  // Read the built panel.html and flip it into HTTP-bridge mode by injecting a
1448
1487
  // flag the front-end reads at boot. Same bytes as the inline ui:// resource,
1449
1488
  // minus the postMessage host (there's none over loopback).
1450
- function panelHtmlForHttp() {
1451
- const html = fs.readFileSync(path.join(DIST_DIR, "panel.html"), "utf-8");
1489
+ function widgetHtmlForHttp(file) {
1490
+ const html = fs.readFileSync(path.join(DIST_DIR, file), "utf-8");
1452
1491
  const inject = `<script>window.__SAPS_BRIDGE__=${JSON.stringify("http")};</script>`;
1453
1492
  if (html.includes("</head>"))
1454
1493
  return html.replace("</head>", inject + "</head>");
1455
1494
  return inject + html;
1456
1495
  }
1496
+ function panelHtmlForHttp() {
1497
+ return widgetHtmlForHttp("panel.html");
1498
+ }
1457
1499
  function readBody(req) {
1458
1500
  return new Promise((resolve, reject) => {
1459
1501
  const chunks = [];
@@ -1478,6 +1520,12 @@ function startLocalPanel() {
1478
1520
  res.end(panelHtmlForHttp());
1479
1521
  return;
1480
1522
  }
1523
+ if (req.method === "GET" &&
1524
+ (url.pathname === "/product-link" || url.pathname === "/product-link.html")) {
1525
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1526
+ res.end(widgetHtmlForHttp("product-link.html"));
1527
+ return;
1528
+ }
1481
1529
  if (req.method === "GET" && url.pathname === "/health") {
1482
1530
  res.writeHead(200, { "Content-Type": "application/json" });
1483
1531
  res.end(JSON.stringify({ ok: true }));
@@ -1632,9 +1680,20 @@ async function runScanCandidates(project, onProgress) {
1632
1680
  env.BH_CHROME_BIN = chrome;
1633
1681
  let step = 0;
1634
1682
  let lastMsg = "";
1683
+ // Specific phase label for the menu bar for the whole multi-minute scan, so the
1684
+ // long-poll doesn't leave it on a generic "working" (or flicker to idle between
1685
+ // re-calls). Cleared by the handler when the scan resolves.
1686
+ writeActivity("scanning", project ? `scanning X for ${project}` : "scanning X");
1635
1687
  const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
1636
1688
  env,
1637
1689
  timeoutMs: 900_000,
1690
+ onSpawn: (c) => {
1691
+ // Track the PLUGIN's own scan process so an approved post can abort exactly
1692
+ // this scan (posting preempts scanning). This is the plugin pipeline only —
1693
+ // the plist autopilot's scan is a separate launchd process the MCP server
1694
+ // never spawns and has no handle to, so it can never be touched here.
1695
+ scanChild = c;
1696
+ },
1638
1697
  onLine: (line) => {
1639
1698
  const t = line.replace(/\s+$/, "");
1640
1699
  if (t.trim())
@@ -1648,6 +1707,7 @@ async function runScanCandidates(project, onProgress) {
1648
1707
  }
1649
1708
  },
1650
1709
  });
1710
+ scanChild = null; // scan finished on its own; nothing to preempt.
1651
1711
  const marker = /SCAN_ONLY_RESULT=(\/\S+\.json)/.exec(res.stdout + "\n" + res.stderr);
1652
1712
  if (marker && marker[1]) {
1653
1713
  try {
@@ -1671,6 +1731,20 @@ async function runScanCandidates(project, onProgress) {
1671
1731
  if (blockedMarker && blockedMarker[1]) {
1672
1732
  return { batchId: null, candidates: [], blocked: blockedReasonMessage(blockedMarker[1]) };
1673
1733
  }
1734
+ // The pipeline's single-flight guard prints too_many_inflight and exits 0 when
1735
+ // ANOTHER scan already holds the twitter-cycle slot (commonly the legacy
1736
+ // claude -p autopilot, which we deliberately keep running). That is CONTENTION,
1737
+ // not an empty batch — flag it so the caller retries instead of concluding
1738
+ // "no candidates" (a clean empty scan returns a batch_id with count:0).
1739
+ if (/too_many_inflight/.test(res.stdout + res.stderr)) {
1740
+ return {
1741
+ batchId: null,
1742
+ candidates: [],
1743
+ busy: true,
1744
+ blocked: "Another scan already holds the pipeline slot (likely the legacy autopilot). Contention, " +
1745
+ "not an empty result — retry scan_candidates shortly.",
1746
+ };
1747
+ }
1674
1748
  return {
1675
1749
  batchId: null,
1676
1750
  candidates: [],
@@ -1679,17 +1753,92 @@ async function runScanCandidates(project, onProgress) {
1679
1753
  res.stderr.split("\n").slice(-12).join("\n"),
1680
1754
  };
1681
1755
  }
1682
- tool("scan_candidates", {
1683
- title: "Scan X for reply candidates (no drafting, no posting)",
1684
- description: "Step 1 of a hands-free / scheduled autopilot run. Runs the scan+score half of the pipeline " +
1685
- "and returns the top scored X/Twitter threads worth replying to — WITHOUT drafting or posting, " +
1686
- "and without spending any `claude -p` budget. You (this session) then draft an on-brand reply " +
1687
- "for each good candidate YOURSELF and submit them with `submit_drafts`. Each candidate includes " +
1688
- "its candidate_id (pass it back), the thread text/author, the matched project, and engagement " +
1689
- "metrics. Optional `project` scopes the scan to one configured project.",
1690
- inputSchema: { project: z.string().optional() },
1691
- }, async (args) => {
1692
- const scan = await runScanCandidates(args?.project);
1756
+ // At most one scan runs at a time (mirrors the pipeline's own max=1 lock). Kept
1757
+ // across calls so a later poll attaches to the SAME running scan instead of
1758
+ // starting a new one.
1759
+ let currentScanJob = null;
1760
+ // The PLUGIN's currently-spawned scan subprocess (captured via run()'s onSpawn),
1761
+ // so an approved post can abort exactly this scan and take the browser
1762
+ // immediately. Only ever references a scan the MCP server itself launched never
1763
+ // the plist autopilot's launchd scan.
1764
+ let scanChild = null;
1765
+ // Posting takes priority over scanning (plugin pipeline only). When the user
1766
+ // approves a post, abort any in-flight plugin scan so the browser frees up at
1767
+ // once: killing the scan's bash leaves a dead lock-holder pid that the poster's
1768
+ // twitter_browser.py steals via its liveness check, so the post acquires the
1769
+ // browser without the 45s lock wait. The aborted scan just re-runs next cron
1770
+ // tick. Best-effort; never throws. Does NOT touch the plist autopilot (separate
1771
+ // process, no handle here) or any locked pipeline script.
1772
+ function preemptScanForPost() {
1773
+ try {
1774
+ if (scanChild && scanChild.pid && scanChild.exitCode === null) {
1775
+ console.error(`[post] preempting in-flight plugin scan (pid ${scanChild.pid}) so the approved post takes the browser`);
1776
+ try {
1777
+ scanChild.kill("SIGTERM");
1778
+ }
1779
+ catch {
1780
+ /* already gone */
1781
+ }
1782
+ }
1783
+ }
1784
+ catch {
1785
+ /* best effort */
1786
+ }
1787
+ // Drop the long-poll job so a queued poller stops waiting on the aborted scan;
1788
+ // the next scan_candidates call starts fresh.
1789
+ currentScanJob = null;
1790
+ scanChild = null;
1791
+ }
1792
+ // Per-call long-poll window. Must stay under the ~60s MCP client request timeout
1793
+ // so every scan_candidates call returns a real response before the client gives
1794
+ // up. Override with SAPS_SCAN_POLL_WAIT_MS for non-standard clients.
1795
+ const SCAN_POLL_WAIT_MS = Number(process.env.SAPS_SCAN_POLL_WAIT_MS) || 45_000;
1796
+ // After a contention hit (too_many_inflight), wait this long before launching a
1797
+ // fresh scan, so polls don't hammer the pipeline with back-to-back no-op scans
1798
+ // while the other holder finishes. Polls during the cooldown just report "busy".
1799
+ const SCAN_BUSY_COOLDOWN_MS = Number(process.env.SAPS_SCAN_BUSY_COOLDOWN_MS) || 20_000;
1800
+ let scanBusyUntil = 0;
1801
+ function scanBusy() {
1802
+ return textContent("Another scan is already running on this machine (likely the legacy autopilot holding the " +
1803
+ "twitter-cycle slot). This is CONTENTION, not an empty result. Call scan_candidates again in " +
1804
+ "~20s to retry — do NOT conclude there are no candidates, and do NOT sleep; just re-call.");
1805
+ }
1806
+ // Resolve to {done:true,value} the moment `p` settles, or {done:false} after
1807
+ // `ms` — whichever is first. The job promise keeps running either way, so a
1808
+ // later poll re-attaches to it.
1809
+ function waitUpTo(p, ms) {
1810
+ return Promise.race([
1811
+ p.then((value) => ({ done: true, value })),
1812
+ new Promise((resolve) => setTimeout(() => resolve({ done: false }), ms)),
1813
+ ]);
1814
+ }
1815
+ function startScanJob(project) {
1816
+ const job = {
1817
+ project,
1818
+ startedAt: Date.now(),
1819
+ done: false,
1820
+ promise: undefined,
1821
+ };
1822
+ job.promise = runScanCandidates(project)
1823
+ .then((r) => {
1824
+ job.result = r;
1825
+ job.done = true;
1826
+ return r;
1827
+ })
1828
+ .catch((e) => {
1829
+ const r = {
1830
+ batchId: null,
1831
+ candidates: [],
1832
+ blocked: `The scan crashed before producing a result: ${String(e?.message || e)}. ` +
1833
+ `Call scan_candidates again to retry.`,
1834
+ };
1835
+ job.result = r;
1836
+ job.done = true;
1837
+ return r;
1838
+ });
1839
+ return job;
1840
+ }
1841
+ function formatScanResult(scan) {
1693
1842
  if (!scan.batchId) {
1694
1843
  return textContent(scan.blocked || "No candidates found.");
1695
1844
  }
@@ -1702,6 +1851,62 @@ tool("scan_candidates", {
1702
1851
  `"${scan.batchId}" and one entry per drafted reply ({candidate_id, reply_text}). Nothing posts ` +
1703
1852
  `until the user approves.`,
1704
1853
  });
1854
+ }
1855
+ function scanInProgress(job) {
1856
+ const elapsed = Math.round((Date.now() - job.startedAt) / 1000);
1857
+ return textContent(`Scan in progress (${elapsed}s elapsed). The X scan drives a real browser and usually takes ` +
1858
+ `1-3 minutes. Call scan_candidates again RIGHT NOW to keep waiting — each call long-polls up ` +
1859
+ `to ~${Math.round(SCAN_POLL_WAIT_MS / 1000)}s and returns the candidates the moment the scan ` +
1860
+ `finishes. Do NOT sleep, run a background command, or do other work to bridge the gap; just ` +
1861
+ `re-call scan_candidates until it returns candidates or reports no candidates.`);
1862
+ }
1863
+ tool("scan_candidates", {
1864
+ title: "Scan X for reply candidates (no drafting, no posting)",
1865
+ description: "Step 1 of a hands-free / scheduled autopilot run. Runs the scan+score half of the pipeline " +
1866
+ "and returns the top scored X/Twitter threads worth replying to — WITHOUT drafting or posting, " +
1867
+ "and without spending any `claude -p` budget. You (this session) then draft an on-brand reply " +
1868
+ "for each good candidate YOURSELF and submit them with `submit_drafts`. Each candidate includes " +
1869
+ "its candidate_id (pass it back), the thread text/author, the matched project, and engagement " +
1870
+ "metrics. Optional `project` scopes the scan to one configured project. The scan drives a real " +
1871
+ "browser and can take 1-3 minutes: this call long-polls and may return a `Scan in progress` " +
1872
+ "status instead of candidates — if so, just call scan_candidates again (same args) and keep " +
1873
+ "re-calling until it returns candidates. Never sleep or use a background wait to bridge the gap.",
1874
+ inputSchema: { project: z.string().optional() },
1875
+ }, async (args) => {
1876
+ // Long-poll the single in-flight scan job (see the ScanJob registry above).
1877
+ // A finished-but-unconsumed job: hand back its result and clear the slot so a
1878
+ // later call starts a fresh scan.
1879
+ if (currentScanJob?.done) {
1880
+ const finished = currentScanJob;
1881
+ currentScanJob = null;
1882
+ if (finished.result?.busy) {
1883
+ scanBusyUntil = Date.now() + SCAN_BUSY_COOLDOWN_MS;
1884
+ return scanBusy();
1885
+ }
1886
+ return formatScanResult(finished.result);
1887
+ }
1888
+ // Contention cooldown: another scan holds the slot — report busy without
1889
+ // launching yet another no-op scan against the locked pipeline.
1890
+ if (!currentScanJob && Date.now() < scanBusyUntil) {
1891
+ return scanBusy();
1892
+ }
1893
+ // Nothing running -> start one scan (single-flight: never spawn a second
1894
+ // run-twitter-cycle.sh that would just hit the pipeline's too_many_inflight).
1895
+ if (!currentScanJob) {
1896
+ currentScanJob = startScanJob(args?.project);
1897
+ }
1898
+ // Wait for the in-flight scan, but no longer than the client timeout allows.
1899
+ const waited = await waitUpTo(currentScanJob.promise, SCAN_POLL_WAIT_MS);
1900
+ if (waited.done) {
1901
+ currentScanJob = null;
1902
+ if (waited.value.busy) {
1903
+ scanBusyUntil = Date.now() + SCAN_BUSY_COOLDOWN_MS;
1904
+ return scanBusy();
1905
+ }
1906
+ return formatScanResult(waited.value);
1907
+ }
1908
+ // Still scanning — tell the agent to simply call scan_candidates again.
1909
+ return scanInProgress(currentScanJob);
1705
1910
  });
1706
1911
  tool("submit_drafts", {
1707
1912
  title: "Submit drafted replies for review",
@@ -1809,8 +2014,8 @@ tool("submit_drafts", {
1809
2014
  `"${REVIEW_QUEUE_ID}" and the numbers to post.`);
1810
2015
  });
1811
2016
  appTool("dashboard", {
1812
- title: "Social Autoposter dashboard",
1813
- description: "Render the Social Autoposter dashboard in chat: a visual surface showing project setup, X " +
2017
+ title: "S4L dashboard",
2018
+ description: "Render the S4L dashboard in chat: a visual surface showing project setup, X " +
1814
2019
  "connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, connect X, " +
1815
2020
  "and refresh. Use when the user asks to see the dashboard, panel, " +
1816
2021
  "status, or controls. ALSO call this at the end of any state-changing or results-producing " +
@@ -1824,7 +2029,7 @@ appTool("dashboard", {
1824
2029
  _meta: { ui: { resourceUri: PANEL_URI } },
1825
2030
  }, async () => {
1826
2031
  const snap = await buildSnapshot();
1827
- const human = `Social Autoposter v${snap.version}` +
2032
+ const human = `S4L v${snap.version}` +
1828
2033
  (snap.update_available && snap.latest_version ? ` (update to ${snap.latest_version})` : "") +
1829
2034
  ` — projects ${snap.projects_ready}/${snap.projects_total} ready, ` +
1830
2035
  `X ${snap.x_connected ? "connected" : "not connected"}, ` +
@@ -1864,6 +2069,52 @@ appTool("dashboard", {
1864
2069
  return base;
1865
2070
  }
1866
2071
  });
2072
+ // ---- add your product: focused single-field onboarding widget --------------
2073
+ // A standalone ui:// widget (separate from the dashboard panel) that captures
2074
+ // the user's product URL. The widget itself reads project status and either
2075
+ // writes the website via project_config (callServerTool) or, on a cold start,
2076
+ // hands the URL to the model via sendMessage. Same inline/loopback duality as
2077
+ // `dashboard`.
2078
+ appTool("connect_product", {
2079
+ title: "Add your product",
2080
+ description: "Render the 'add your product' widget in chat: a single-field form where the user pastes " +
2081
+ "their product's website. Use at the START of onboarding when you need the product URL, " +
2082
+ "instead of asking for it in plain prose. If a project already needs a website the widget " +
2083
+ "saves it directly; on a cold start it kicks off end-to-end setup. Hosts without UI support " +
2084
+ "get a loopback URL.",
2085
+ inputSchema: {},
2086
+ outputSchema: { snapshot: z.string(), fallback_url: z.string().optional() },
2087
+ _meta: { ui: { resourceUri: PRODUCT_LINK_URI } },
2088
+ }, async () => {
2089
+ const snap = await buildSnapshot();
2090
+ // Inline-capable host: paint the resource named by _meta.ui.resourceUri.
2091
+ // Emit no text content so the chat shows only the widget (see `dashboard`).
2092
+ if (hostRendersAppUi()) {
2093
+ return { content: [], structuredContent: { snapshot: JSON.stringify(snap) } };
2094
+ }
2095
+ // No inline UI: serve the identical product-link.html from the loopback
2096
+ // server at /product-link and return its URL.
2097
+ try {
2098
+ const base = await startLocalPanel();
2099
+ const url = base.replace(/\/$/, "") + "/product-link";
2100
+ await openInBrowser(url);
2101
+ return {
2102
+ content: [{
2103
+ type: "text",
2104
+ text: "Add your product: paste your product's website to begin setup.\n\n" +
2105
+ `This host can't render the widget inline. Loopback URL: ${url}`,
2106
+ }],
2107
+ structuredContent: { snapshot: JSON.stringify(snap), fallback_url: url },
2108
+ };
2109
+ }
2110
+ catch (e) {
2111
+ console.error("[social-autoposter-mcp] product-link fallback failed:", e?.message || e);
2112
+ return {
2113
+ content: [{ type: "text", text: "Paste your product's website in the chat to begin setup." }],
2114
+ structuredContent: { snapshot: JSON.stringify(snap) },
2115
+ };
2116
+ }
2117
+ });
1867
2118
  // ---- show browser to user: live CDP screencast ----------------------------
1868
2119
  // Streams a live view of the autoposter's managed Chrome into the panel. Frames
1869
2120
  // travel back through the normal tool-result channel as a data: URL (which the
@@ -1935,7 +2186,7 @@ tool("show_browser_to_user", {
1935
2186
  frame: frame ? `data:image/jpeg;base64,${frame}` : null,
1936
2187
  });
1937
2188
  });
1938
- registerAppResource(server, "Social Autoposter panel", PANEL_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
2189
+ registerAppResource(server, "S4L panel", PANEL_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
1939
2190
  contents: [
1940
2191
  {
1941
2192
  uri: PANEL_URI,
@@ -1944,8 +2195,22 @@ registerAppResource(server, "Social Autoposter panel", PANEL_URI, { mimeType: RE
1944
2195
  },
1945
2196
  ],
1946
2197
  }));
2198
+ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
2199
+ contents: [
2200
+ {
2201
+ uri: PRODUCT_LINK_URI,
2202
+ mimeType: RESOURCE_MIME_TYPE,
2203
+ text: fs.readFileSync(path.join(DIST_DIR, "product-link.html"), "utf-8"),
2204
+ },
2205
+ ],
2206
+ }));
1947
2207
  async function main() {
1948
2208
  initSentry();
2209
+ // A plugin UPDATE refreshes this server (dist/) but not the materialized
2210
+ // pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
2211
+ // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
2212
+ // the version first materialized at install). Synchronous + best-effort.
2213
+ ensurePipelineCurrent();
1949
2214
  const transport = new StdioServerTransport();
1950
2215
  await server.connect(transport);
1951
2216
  console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);