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 +302 -37
- package/mcp/dist/panel.html +1 -1
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +6 -0
- package/mcp/dist/runtime.js +73 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +5 -5
- package/mcp/package.json +5 -4
- package/package.json +1 -1
- package/scripts/log_run.py +7 -1
- package/scripts/reset-test-machine.sh +46 -0
- package/scripts/seed_search_queries.py +111 -45
- package/scripts/twitter_post_plan.py +24 -0
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: "
|
|
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
|
|
186
|
-
"
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
"draft
|
|
190
|
-
"
|
|
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
|
|
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)
|
|
641
|
-
"
|
|
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.
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1008
|
-
|
|
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
|
|
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 += `
|
|
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
|
|
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
|
|
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
|
|
1451
|
-
const html = fs.readFileSync(path.join(DIST_DIR,
|
|
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
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
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: "
|
|
1813
|
-
description: "Render the
|
|
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 = `
|
|
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, "
|
|
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()}`);
|