social-autoposter 1.6.110 → 1.6.112
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 +126 -650
- package/mcp/dist/repo.js +0 -5
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +33 -6
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/reap_stale_claude_sessions.py +232 -0
- package/scripts/s4l_box_update.sh +17 -1
package/mcp/dist/index.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// social-autoposter MCP server (X/Twitter rail).
|
|
3
3
|
//
|
|
4
4
|
// Core tools:
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// run_draft_cycle - fire ONE real pipeline cycle (scan -> draft via the queue + worker
|
|
6
|
+
// task), merging the resulting drafts into the menu-bar approval cards
|
|
7
|
+
// (posts nothing).
|
|
7
8
|
// post_drafts - post the drafts the user chose by number from a batch.
|
|
8
9
|
// get_stats - read-only post + engagement stats.
|
|
9
10
|
//
|
|
@@ -17,7 +18,7 @@ import { screencast, bringBrowserToFront } from "./screencast.js";
|
|
|
17
18
|
import os from "node:os";
|
|
18
19
|
import path from "node:path";
|
|
19
20
|
import fs from "node:fs";
|
|
20
|
-
import { repoDir, runPython, run, readPlan, writePlan, planPath,
|
|
21
|
+
import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
|
|
21
22
|
import { applySetup, resolveProject, listManagedProjectStatus, ensureShortLinksDefault, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, } from "./setup.js";
|
|
22
23
|
import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
|
|
23
24
|
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, ensureRuntimeProvisioned, } from "./runtime.js";
|
|
@@ -32,15 +33,11 @@ import http from "node:http";
|
|
|
32
33
|
const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
33
34
|
const PANEL_URI = "ui://social-autoposter/panel.html";
|
|
34
35
|
const PRODUCT_LINK_URI = "ui://social-autoposter/product-link.html";
|
|
35
|
-
// Stable id for the accumulating draft review queue.
|
|
36
|
-
//
|
|
36
|
+
// Stable id for the accumulating draft review queue. Each draft cycle appends its
|
|
37
|
+
// drafts here (dedup by tweet URL) so the menu-bar cards PILE UP across a
|
|
37
38
|
// continuous autopilot instead of each run overwriting the last; post_drafts posts
|
|
38
39
|
// the approved subset and marks them posted (filtered out of the cards thereafter).
|
|
39
40
|
const REVIEW_QUEUE_ID = "review-queue";
|
|
40
|
-
// The Desktop scheduled task onboarding creates for the autopilot. Its presence on
|
|
41
|
-
// disk is the single "autopilot is set up" signal the dashboard + menu bar share
|
|
42
|
-
// (the legacy launchd autopilot is retired).
|
|
43
|
-
const AUTOPILOT_TASK_ID = "social-autoposter-autopilot";
|
|
44
41
|
// ---- Queue-backed drafting (2026-06-23) -----------------------------------
|
|
45
42
|
// Customer .mcpb boxes have no `claude` CLI, so the deterministic pipeline can't
|
|
46
43
|
// run its `claude -p` steps directly. Instead a launchd job kicks the REAL
|
|
@@ -49,12 +46,21 @@ const AUTOPILOT_TASK_ID = "social-autoposter-autopilot";
|
|
|
49
46
|
// and blocks. Two Claude Desktop scheduled tasks — one per job type — drain that
|
|
50
47
|
// queue, run the pipeline's own prompt as a Claude turn, and write the result
|
|
51
48
|
// back, unblocking the cycle. This reuses the entire pipeline (styles, voice,
|
|
52
|
-
// top-performers, em-dash rules)
|
|
53
|
-
//
|
|
49
|
+
// top-performers, em-dash rules). See scripts/claude_job.py + run_claude.sh's
|
|
50
|
+
// provider seam.
|
|
54
51
|
const PHASE1_TASK_ID = "saps-phase1-query"; // drains "twitter-query" jobs
|
|
55
52
|
const PHASE2B_TASK_ID = "saps-phase2b-draft"; // drains "twitter-prep" jobs
|
|
56
53
|
const TWITTER_AUTOPILOT_LABEL = "com.m13v.social-twitter-cycle";
|
|
57
54
|
const TWITTER_AUTOPILOT_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${TWITTER_AUTOPILOT_LABEL}.plist`);
|
|
55
|
+
// Self-healing reaper for leaked Claude agent-mode worker sessions. The queue
|
|
56
|
+
// autopilot fires two scheduled tasks every ~1 min; each fire spawns a ~200 MB
|
|
57
|
+
// `claude` agent-mode session that finishes its one queue turn but never exits
|
|
58
|
+
// (Desktop keeps the stream-json session warm), so they pile up — 226 procs /
|
|
59
|
+
// 22.5 GB on the test box in ~1h, load 75, near-OOM. We can't change Desktop's
|
|
60
|
+
// teardown, so a 60s launchd job kills the leaked sessions (see the script for
|
|
61
|
+
// the uuid-grouping safety that spares real interactive sessions).
|
|
62
|
+
const REAPER_LABEL = "com.m13v.social-claude-reaper";
|
|
63
|
+
const REAPER_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${REAPER_LABEL}.plist`);
|
|
58
64
|
// Daily self-updater. Enabled alongside autopilot so a hands-free (headless)
|
|
59
65
|
// install keeps itself current — the interactive `runtime` tool (action:'update')
|
|
60
66
|
// only helps when
|
|
@@ -205,8 +211,8 @@ const server = new McpServer({
|
|
|
205
211
|
"SCHEDULE + VERIFY THE AUTOPILOT (final onboarding step). There is ONE drafting pipeline: a launchd " +
|
|
206
212
|
"job this server installs runs the real cycle in draft-only mode, its Phase-2b Claude turn is " +
|
|
207
213
|
"serviced by a scheduled task that drains a local job queue, and the result is merged into the " +
|
|
208
|
-
"menu-bar approval cards.
|
|
209
|
-
"
|
|
214
|
+
"menu-bar approval cards. Drafting is the queue + worker path; `run_draft_cycle` is how you " +
|
|
215
|
+
"fire a real cycle on demand. Steps, in order: " +
|
|
210
216
|
"(1) call `queue_setup` — it pre-writes the worker task prompts and returns two task specs " +
|
|
211
217
|
"(saps-phase1-query, saps-phase2b-draft); " +
|
|
212
218
|
"(2) for EACH spec, call the HOST tool `create_scheduled_task` with its taskId, cronExpression, and " +
|
|
@@ -235,7 +241,7 @@ const server = new McpServer({
|
|
|
235
241
|
"project is fully configured. To change anything about a project later, call `project_config` " +
|
|
236
242
|
"again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
|
|
237
243
|
"RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
|
|
238
|
-
"(`
|
|
244
|
+
"(`run_draft_cycle`, `post_drafts`, `get_stats`), end your turn by " +
|
|
239
245
|
"calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
|
|
240
246
|
"`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.",
|
|
241
247
|
});
|
|
@@ -318,28 +324,28 @@ function blockedReasonMessage(reason) {
|
|
|
318
324
|
"couldn't run. (It DID find and rank threads, it just couldn't draft replies.) This " +
|
|
319
325
|
"CLI uses its own login, separate from Claude Desktop. To fix it, open a terminal and run:\n\n" +
|
|
320
326
|
" claude\n\n" +
|
|
321
|
-
"then `/login` inside it (or run `claude setup-token`). Once it's logged in, run
|
|
327
|
+
"then `/login` inside it (or run `claude setup-token`). Once it's logged in, run `run_draft_cycle` again.");
|
|
322
328
|
case "monthly_limit":
|
|
323
329
|
case "daily_limit":
|
|
324
330
|
case "rate_limit_5h":
|
|
325
331
|
return (`The drafting step hit an Anthropic usage limit (${reason}), so no replies were drafted. ` +
|
|
326
|
-
"Wait for the limit to reset, then run
|
|
332
|
+
"Wait for the limit to reset, then run `run_draft_cycle` again.");
|
|
327
333
|
case "no_search_topics":
|
|
328
334
|
return ("This project has no search topics yet, so there was nothing to scan. Topics live in the " +
|
|
329
335
|
"DB (project_search_topics) and are seeded from your project's `search_topics` when you " +
|
|
330
336
|
"configure it. Re-run the `project_config` tool for this project with a `search_topics` list " +
|
|
331
337
|
"(comma-separated keywords/phrases your buyers tweet about); it seeds them automatically, then " +
|
|
332
|
-
"run
|
|
338
|
+
"run `run_draft_cycle` again.");
|
|
333
339
|
case "topics_api_unreachable":
|
|
334
340
|
return ("Couldn't reach the search-topics service to load this project's topics, so the cycle stopped " +
|
|
335
|
-
"before scanning. This is usually a transient backend/network issue. Try
|
|
341
|
+
"before scanning. This is usually a transient backend/network issue. Try `run_draft_cycle` again in a " +
|
|
336
342
|
"moment; if it persists, check connectivity to the autoposter backend.");
|
|
337
343
|
case "credit_balance":
|
|
338
344
|
return ("The drafting step failed because the Anthropic account is out of credits. " +
|
|
339
|
-
"Add credits, then run
|
|
345
|
+
"Add credits, then run `run_draft_cycle` again.");
|
|
340
346
|
default:
|
|
341
347
|
return (`The drafting step failed (${reason}) and produced no drafts. ` +
|
|
342
|
-
"Check skill/logs/twitter-cycle-*.log on this machine for details, then run
|
|
348
|
+
"Check skill/logs/twitter-cycle-*.log on this machine for details, then run `run_draft_cycle` again.");
|
|
343
349
|
}
|
|
344
350
|
}
|
|
345
351
|
// Turn a raw run-twitter-cycle.sh stdout line into a short, user-facing
|
|
@@ -692,16 +698,15 @@ async function postApproved(batchId, plan) {
|
|
|
692
698
|
"or set accounts.twitter.handle in config.json.",
|
|
693
699
|
};
|
|
694
700
|
}
|
|
695
|
-
// Mark posting active so
|
|
701
|
+
// Mark posting active so the draft-cycle scan DEFERS launching any scan for the
|
|
696
702
|
// duration of this batch (+ grace). This is the source-level mutual exclusion
|
|
697
703
|
// that actually fixes the hijack: the autopilot never launches a scan to race
|
|
698
704
|
// the post for the browser. Reset is guaranteed by scheduleShellLockRelease()
|
|
699
705
|
// in the finally below, so an early/failed post can't wedge scanning.
|
|
700
706
|
postingActive = true;
|
|
701
707
|
startPostingFlagHeartbeat(); // cross-instance: a sibling MCP's scan defers too
|
|
702
|
-
// Posting is a priority over scanning: abort any in-flight
|
|
708
|
+
// Posting is a priority over scanning: abort any in-flight pipeline scan so the
|
|
703
709
|
// approved post takes the browser immediately instead of waiting on the lock.
|
|
704
|
-
// Plugin pipeline only — never affects the plist autopilot.
|
|
705
710
|
preemptScanForPost();
|
|
706
711
|
// Hold the /tmp shell browser lock (the one the scanner respects) for the WHOLE
|
|
707
712
|
// batch so the every-minute autopilot scan queues behind the post instead of
|
|
@@ -761,7 +766,7 @@ async function postApproved(batchId, plan) {
|
|
|
761
766
|
// BYO-Chrome), else default to the local harness on port 9555.
|
|
762
767
|
TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
|
|
763
768
|
},
|
|
764
|
-
// Stream the poster's output live
|
|
769
|
+
// Stream the poster's output live so HANDLED
|
|
765
770
|
// failures — e.g. every reply refused with no_account_configured, which
|
|
766
771
|
// returns a reason instead of throwing — surface in main.log + telemetry
|
|
767
772
|
// in real time. Without this the poster's stdout was buffered in-process
|
|
@@ -997,7 +1002,7 @@ tool("project_config", {
|
|
|
997
1002
|
"Call with status:true (or no name) to list every configured project, its remaining fields, AND " +
|
|
998
1003
|
"whether X is connected. Use config, conversation context, profile_scan, and website research " +
|
|
999
1004
|
"before asking for fields. Ask only if no product can be identified or an interactive login is " +
|
|
1000
|
-
"unavoidable. The
|
|
1005
|
+
"unavoidable. The run_draft_cycle and get_stats tools refuse to run until a project is " +
|
|
1001
1006
|
"fully set up.",
|
|
1002
1007
|
inputSchema: {
|
|
1003
1008
|
status: z.boolean().optional(),
|
|
@@ -1276,7 +1281,7 @@ tool("project_config", {
|
|
|
1276
1281
|
(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.")
|
|
1277
1282
|
: projects.every((p) => p.ready)
|
|
1278
1283
|
? (x.connected
|
|
1279
|
-
? "All configured projects are ready and X is connected. SCHEDULE + VERIFY THE AUTOPILOT: (1) call queue_setup and create each returned task with create_scheduled_task (prompt verbatim; 'already exists' is fine); (2) call run_draft_cycle to fire one real cycle; (3) poll the `dashboard` tool for ~3 min until the pending-draft count rises — that card came through the real pipeline. Do NOT
|
|
1284
|
+
? "All configured projects are ready and X is connected. SCHEDULE + VERIFY THE AUTOPILOT: (1) call queue_setup and create each returned task with create_scheduled_task (prompt verbatim; 'already exists' is fine); (2) call run_draft_cycle to fire one real cycle; (3) poll the `dashboard` tool for ~3 min until the pending-draft count rises — that card came through the real pipeline. Do NOT pause to ask the user to review drafts. Then call `dashboard` so the user sees the finished setup."
|
|
1280
1285
|
: "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
|
|
1281
1286
|
"x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
|
|
1282
1287
|
: "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." +
|
|
@@ -1318,13 +1323,13 @@ tool("project_config", {
|
|
|
1318
1323
|
topic_count: topicCount,
|
|
1319
1324
|
});
|
|
1320
1325
|
seedNote = m
|
|
1321
|
-
? ` Seeded ${m[1]} search topic(s) into the DB (new: ${m[2]}, updated: ${m[3]}), so
|
|
1322
|
-
: " Seeded search topics into the DB so
|
|
1326
|
+
? ` Seeded ${m[1]} search topic(s) into the DB (new: ${m[2]}, updated: ${m[3]}), so the draft cycle has a topic universe to work with.`
|
|
1327
|
+
: " Seeded search topics into the DB so the draft cycle has a topic universe to work with.";
|
|
1323
1328
|
}
|
|
1324
1329
|
else {
|
|
1325
1330
|
const tail = (seed.stderr || seed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
|
|
1326
1331
|
blockOnboardingMilestone("topics_seeded", "topic_seed_failed", tail, { exit_code: seed.code });
|
|
1327
|
-
seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}.
|
|
1332
|
+
seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}. run_draft_cycle will tell you clearly if topics are missing.)`;
|
|
1328
1333
|
}
|
|
1329
1334
|
// Cold-start QUERY supply: fan the seeded topics out into >=30 real X
|
|
1330
1335
|
// search queries (project_search_queries) so the deterministic Phase 1
|
|
@@ -1419,29 +1424,21 @@ tool("project_config", {
|
|
|
1419
1424
|
return textContent(`Setup failed: ${e.message}`);
|
|
1420
1425
|
}
|
|
1421
1426
|
});
|
|
1422
|
-
// ---- draft_cycle: DEPRECATED 2026-06-20 (registration removed) -------------
|
|
1423
|
-
// Replaced by the scan_candidates -> (agent drafts) -> submit_drafts flow, so the
|
|
1424
|
-
// AI drafting now runs in the calling session (on the user's plan) instead of a
|
|
1425
|
-
// spawned `claude -p`. post_drafts (below) still posts the approved subset, and
|
|
1426
|
-
// submit_drafts completes the onboarding "draft_verified" milestone that this
|
|
1427
|
-
// tool used to. The underlying pipeline (run-twitter-cycle.sh) and the
|
|
1428
|
-
// produceDrafts() helper are kept as the source those tools reuse; only the
|
|
1429
|
-
// draft_cycle tool registration was removed here.
|
|
1430
1427
|
// ---- post_drafts: post the user's chosen drafts from a batch ---------------
|
|
1431
|
-
// Second half of the manual loop. The user reviewed the
|
|
1432
|
-
// and said which numbers to post / edit; this posts exactly those.
|
|
1433
|
-
// draft implies posting it. Indices are 1-based, matching the table.
|
|
1428
|
+
// Second half of the manual loop. The user reviewed the menu-bar cards a draft
|
|
1429
|
+
// cycle produced and said which numbers to post / edit; this posts exactly those.
|
|
1430
|
+
// Editing a draft implies posting it. Indices are 1-based, matching the table.
|
|
1434
1431
|
tool("post_drafts", {
|
|
1435
1432
|
title: "Post chosen drafts",
|
|
1436
|
-
description: "Post the drafts the user approved from a
|
|
1437
|
-
"
|
|
1433
|
+
description: "Post the drafts the user approved from a draft cycle. Pass the batch_id from the " +
|
|
1434
|
+
"approval cards and the user's decision by NUMBER (1-based, matching the table): `post` is " +
|
|
1438
1435
|
"the list of draft numbers to post as drafted; `edits` rewrites a draft's text before " +
|
|
1439
1436
|
"posting it (editing implies posting); `post_all` posts every draft. Only the chosen " +
|
|
1440
1437
|
"drafts post; anything not listed is left unposted. Call this ONLY after the user has " +
|
|
1441
1438
|
"told you which drafts they want. After posting, call the `dashboard` tool so the user " +
|
|
1442
1439
|
"sees the updated state.",
|
|
1443
1440
|
inputSchema: {
|
|
1444
|
-
batch_id: z.string().describe("The batch_id
|
|
1441
|
+
batch_id: z.string().describe("The batch_id of the draft batch (from the approval cards)."),
|
|
1445
1442
|
post: z
|
|
1446
1443
|
.array(z.number().int().positive())
|
|
1447
1444
|
.optional()
|
|
@@ -1460,7 +1457,7 @@ tool("post_drafts", {
|
|
|
1460
1457
|
}, async ({ batch_id, post, edits, post_all, reject }) => {
|
|
1461
1458
|
const plan = readPlan(batch_id);
|
|
1462
1459
|
if (!plan || !(plan.candidates && plan.candidates.length)) {
|
|
1463
|
-
return textContent(`No drafts found for batch ${batch_id}. Run
|
|
1460
|
+
return textContent(`No drafts found for batch ${batch_id}. Run a draft cycle (run_draft_cycle) again to produce a fresh batch.`);
|
|
1464
1461
|
}
|
|
1465
1462
|
const candidates = plan.candidates;
|
|
1466
1463
|
const total = candidates.length;
|
|
@@ -1805,7 +1802,7 @@ tool("queue_setup", {
|
|
|
1805
1802
|
// the 5-min timer). The cycle drafts via the queue + worker task and the wrapper
|
|
1806
1803
|
// merges the result into the review-queue cards. Use this for onboarding
|
|
1807
1804
|
// verification ("does the real pipeline produce a card?") and any on-demand
|
|
1808
|
-
// "draft now"
|
|
1805
|
+
// "draft now". This is the single draft path.
|
|
1809
1806
|
tool("run_draft_cycle", {
|
|
1810
1807
|
title: "Run one draft cycle now",
|
|
1811
1808
|
description: "Fires ONE real draft cycle immediately (the same pipeline the autopilot runs): it scans, drafts " +
|
|
@@ -1838,19 +1835,21 @@ tool("run_draft_cycle", {
|
|
|
1838
1835
|
// ---- panel: MCP Apps control surface --------------------------------------
|
|
1839
1836
|
// A self-contained HTML view rendered by hosts that support MCP Apps (Claude
|
|
1840
1837
|
// desktop/web, etc.). It duplicates NO pipeline logic: each button calls one of
|
|
1841
|
-
// the tools above (
|
|
1838
|
+
// the tools above (run_draft_cycle / project_config / get_stats) through the host
|
|
1842
1839
|
// and re-reads status. The tool itself returns the first-paint snapshot so the
|
|
1843
1840
|
// view has data the instant it loads.
|
|
1844
1841
|
// Is either launchd job (cycle / daily updater) currently loaded?
|
|
1845
|
-
// "Autopilot" is now the Claude Desktop scheduled
|
|
1846
|
-
// (created during onboarding via
|
|
1847
|
-
//
|
|
1842
|
+
// "Autopilot" is now the pair of Claude Desktop queue-worker scheduled tasks
|
|
1843
|
+
// (saps-phase1-query + saps-phase2b-draft, created during onboarding via
|
|
1844
|
+
// create_scheduled_task) that drain the draft queue, NOT the legacy launchd job.
|
|
1845
|
+
// We can't read the host's enabled/paused flag, but the tasks' presence on disk is the
|
|
1848
1846
|
// single signal the dashboard AND the menu bar key off of, so they stay aligned.
|
|
1849
1847
|
async function autopilotLoaded() {
|
|
1850
1848
|
let autopilot_on = false;
|
|
1851
1849
|
try {
|
|
1852
|
-
|
|
1853
|
-
|
|
1850
|
+
// Autopilot is "on" once BOTH queue-worker tasks that service the draft
|
|
1851
|
+
// pipeline's queued `claude -p` calls have their SKILL.md on disk.
|
|
1852
|
+
autopilot_on = QUEUE_WORKERS.every((spec) => fs.existsSync(scheduledTaskSkillPath(spec.taskId)));
|
|
1854
1853
|
}
|
|
1855
1854
|
catch {
|
|
1856
1855
|
/* leave false */
|
|
@@ -1865,176 +1864,11 @@ async function autopilotLoaded() {
|
|
|
1865
1864
|
}
|
|
1866
1865
|
return { autopilot_on, auto_update_on };
|
|
1867
1866
|
}
|
|
1868
|
-
// ---- Autopilot prompt: keep the scheduled task's SKILL.md current ------------
|
|
1869
|
-
// The scheduled task's prompt (SKILL.md) is owned by the host and does NOT update
|
|
1870
|
-
// when the plugin updates. So a behavior change (the no-improvise / voice-inline /
|
|
1871
|
-
// stop-cleanly rules) would never reach an already-scheduled task. We close that
|
|
1872
|
-
// gap by REWRITING the task's SKILL.md on server boot when its embedded prompt
|
|
1873
|
-
// version is older than what this build ships. The host reads the file fresh on
|
|
1874
|
-
// each run, so the next fire picks up the new prompt — no host tool, no user
|
|
1875
|
-
// action. Bump AUTOPILOT_PROMPT_VERSION whenever the prompt below changes.
|
|
1876
|
-
const AUTOPILOT_PROMPT_VERSION = 2;
|
|
1877
|
-
const AUTOPILOT_PROMPT_MARKER = "saps_autopilot_prompt_version";
|
|
1878
|
-
function autopilotSkillPath() {
|
|
1879
|
-
const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
1880
|
-
return path.join(cfg, "scheduled-tasks", AUTOPILOT_TASK_ID, "SKILL.md");
|
|
1881
|
-
}
|
|
1882
|
-
// The canonical draft-only autopilot prompt: uses ONLY scan_candidates +
|
|
1883
|
-
// submit_drafts, drafts from the INLINE project_voices, improvises nothing, and
|
|
1884
|
-
// stops cleanly if its tools aren't available.
|
|
1885
|
-
function autopilotSkillMd(project) {
|
|
1886
|
-
const body = [
|
|
1887
|
-
`You are running the Social Autoposter draft-only autopilot for the project "${project}". ` +
|
|
1888
|
-
`Run ONE cycle, then stop. DRAFT-ONLY: you must NEVER post to X/Twitter — you only queue ` +
|
|
1889
|
-
`drafts for the user's approval. Use ONLY two tools — scan_candidates and submit_drafts — ` +
|
|
1890
|
-
`and IMPROVISE NOTHING ELSE. Specifically: NEVER run Bash, Read, Write, Edit, python, or any ` +
|
|
1891
|
-
`shell/file tool, for ANY reason — not to debug, inspect a tool result, verify an outcome, or ` +
|
|
1892
|
-
`investigate something surprising. This run is unattended: any tool that is not pre-approved ` +
|
|
1893
|
-
`STALLS the whole pipeline waiting on a human who is not there, so reaching for one is never ` +
|
|
1894
|
-
`worth it. If something looks off, report it in one line and stop.`,
|
|
1895
|
-
``,
|
|
1896
|
-
`Steps:`,
|
|
1897
|
-
`1. Call scan_candidates with project "${project}". It long-polls: if it returns a "Scan in ` +
|
|
1898
|
-
`progress" status, call scan_candidates again (same args) and keep re-calling until it ` +
|
|
1899
|
-
`returns candidates. Never sleep or use background waits. If it returns no candidates, stop ` +
|
|
1900
|
-
`cleanly — nothing to do this cycle.`,
|
|
1901
|
-
`2. The result includes a project_voices field — the brand voice + guardrails for each ` +
|
|
1902
|
-
`candidate's project. Draft from project_voices[<matched_project>] (voice, description, ` +
|
|
1903
|
-
`differentiator, content_guardrails). NEVER read config files, call project_config, or run ` +
|
|
1904
|
-
`Bash/Read to find the voice — everything you need is already in the scan result.`,
|
|
1905
|
-
`3. For each candidate you judge genuinely worth engaging, write ONE on-brand reply (<=250 ` +
|
|
1906
|
-
`chars, match the thread's language, genuinely helpful — not spammy, no hard selling). Skip ` +
|
|
1907
|
-
`the rest.`,
|
|
1908
|
-
`4. Call submit_drafts with the batch_id from scan_candidates and a drafts array of ` +
|
|
1909
|
-
`{candidate_id, reply_text}. This queues them for the menu-bar approval UI. Do NOT call ` +
|
|
1910
|
-
`post_drafts — posting is the user's decision.`,
|
|
1911
|
-
`5. You are now DONE: report in ONE short line how many drafts you queued, then stop. ` +
|
|
1912
|
-
`"Queued 0 new draft(s)" is a NORMAL, expected result — it means the threads were already ` +
|
|
1913
|
-
`queued in an earlier cycle (dedup), NOT a failure. Treat ANY count, including zero, as ` +
|
|
1914
|
-
`success. NEVER re-read, parse, or inspect the submit_drafts result; NEVER open files under ` +
|
|
1915
|
-
`tool-results/ or anywhere else; NEVER run Bash/Read/python to confirm what happened. A ` +
|
|
1916
|
-
`zero or surprising count is not a reason to investigate — just report it and stop.`,
|
|
1917
|
-
``,
|
|
1918
|
-
`HARD GUARD: if scan_candidates is NOT available (ToolSearch returns no matching tool — can ` +
|
|
1919
|
-
`happen when a run spawns before the extension's MCP server has reconnected), STOP immediately ` +
|
|
1920
|
-
`and report "S4L tools unavailable, skipping this cycle." Do NOT search the connector registry, ` +
|
|
1921
|
-
`do NOT call list_connectors, do NOT improvise any other tool.`,
|
|
1922
|
-
].join("\n");
|
|
1923
|
-
return (`---\n` +
|
|
1924
|
-
`name: ${AUTOPILOT_TASK_ID}\n` +
|
|
1925
|
-
`description: Social Autoposter draft-only autopilot for project ${project} — scans X, drafts ` +
|
|
1926
|
-
`on-brand replies (voice inline), queues them for approval. Never posts.\n` +
|
|
1927
|
-
`---\n\n` +
|
|
1928
|
-
body +
|
|
1929
|
-
`\n\n<!-- ${AUTOPILOT_PROMPT_MARKER}: ${AUTOPILOT_PROMPT_VERSION} -->\n`);
|
|
1930
|
-
}
|
|
1931
|
-
// Read the project name back from an existing autopilot SKILL.md so a rewrite
|
|
1932
|
-
// keeps the same project. Null if it can't be determined (then leave it alone).
|
|
1933
|
-
function detectAutopilotProject(skillMd) {
|
|
1934
|
-
const m1 = /project[s]?\s+"([^"]+)"/i.exec(skillMd);
|
|
1935
|
-
if (m1)
|
|
1936
|
-
return m1[1];
|
|
1937
|
-
const m2 = /for project\s+([A-Za-z0-9_-]+)/i.exec(skillMd);
|
|
1938
|
-
if (m2)
|
|
1939
|
-
return m2[1];
|
|
1940
|
-
return null;
|
|
1941
|
-
}
|
|
1942
|
-
// Refresh the scheduled task's SKILL.md when this build ships a newer prompt than
|
|
1943
|
-
// what's on disk. Best-effort, synchronous, never throws. Only touches an EXISTING
|
|
1944
|
-
// task (onboarding creates the first one), only when stale, and only when the
|
|
1945
|
-
// project reads back, so we never write a wrong/blank project.
|
|
1946
|
-
function ensureAutopilotPromptCurrent() {
|
|
1947
|
-
try {
|
|
1948
|
-
const skillPath = autopilotSkillPath();
|
|
1949
|
-
if (!fs.existsSync(skillPath))
|
|
1950
|
-
return; // no scheduled task yet
|
|
1951
|
-
const cur = fs.readFileSync(skillPath, "utf-8");
|
|
1952
|
-
const m = new RegExp(`${AUTOPILOT_PROMPT_MARKER}:\\s*(\\d+)`).exec(cur);
|
|
1953
|
-
const curVer = m ? parseInt(m[1], 10) : 0;
|
|
1954
|
-
if (curVer >= AUTOPILOT_PROMPT_VERSION)
|
|
1955
|
-
return; // already current
|
|
1956
|
-
const project = detectAutopilotProject(cur);
|
|
1957
|
-
if (!project) {
|
|
1958
|
-
console.error(`[autopilot] prompt is stale (v${curVer}) but project unreadable from SKILL.md; leaving it`);
|
|
1959
|
-
return;
|
|
1960
|
-
}
|
|
1961
|
-
fs.writeFileSync(skillPath, autopilotSkillMd(project), "utf-8");
|
|
1962
|
-
console.error(`[autopilot] refreshed scheduled-task prompt -> v${AUTOPILOT_PROMPT_VERSION} (was v${curVer}), project=${project}`);
|
|
1963
|
-
}
|
|
1964
|
-
catch (e) {
|
|
1965
|
-
console.error(`[autopilot] ensureAutopilotPromptCurrent error: ${e?.message || e}`);
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
// ---- Pre-approve the autopilot's own tools in ~/.claude/settings.json --------
|
|
1969
|
-
// A Desktop scheduled task created via create_scheduled_task defaults to "Ask"
|
|
1970
|
-
// permission mode, and per the Desktop docs an un-pre-approved tool STALLS the
|
|
1971
|
-
// run until a human approves it. Allow rules from ~/.claude/settings.json DO
|
|
1972
|
-
// apply to scheduled-task sessions, so we ship pre-approval for THIS server's
|
|
1973
|
-
// tools (the only ones the autopilot is allowed to use) into that file on boot.
|
|
1974
|
-
// This makes the legitimate scan -> submit path never stall, independent of
|
|
1975
|
-
// whether anyone clicked "Always allow" during onboarding. ALLOW-only by design:
|
|
1976
|
-
// it pre-approves our tools; it does NOT (and on this lane cannot) restrict any
|
|
1977
|
-
// others — that is the prompt's job. Merge-in-place: read, add only missing
|
|
1978
|
-
// entries, write back; never overwrite a user's settings on a parse error.
|
|
1979
|
-
//
|
|
1980
|
-
// The tool id is mcp__<server>__<tool>. A .mcpb install registers this server
|
|
1981
|
-
// under the manifest name ("social-autoposter"); we also list the protocol name
|
|
1982
|
-
// ("S4L") so pre-approval matches however the host loaded it. Extra entries that
|
|
1983
|
-
// don't match the live namespace are harmless no-ops.
|
|
1984
|
-
const AUTOPILOT_ALLOWED_TOOLS = [
|
|
1985
|
-
"mcp__social-autoposter__scan_candidates",
|
|
1986
|
-
"mcp__social-autoposter__submit_drafts",
|
|
1987
|
-
"mcp__S4L__scan_candidates",
|
|
1988
|
-
"mcp__S4L__submit_drafts",
|
|
1989
|
-
];
|
|
1990
|
-
function ensureAutopilotToolsAllowed() {
|
|
1991
|
-
try {
|
|
1992
|
-
// Only touch settings.json for installs that actually have the autopilot
|
|
1993
|
-
// scheduled — mirrors the prompt-refresh gate, so we never edit a user's
|
|
1994
|
-
// global settings on a machine that doesn't use the scheduled task.
|
|
1995
|
-
if (!fs.existsSync(autopilotSkillPath()))
|
|
1996
|
-
return;
|
|
1997
|
-
const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
1998
|
-
const settingsPath = path.join(cfg, "settings.json");
|
|
1999
|
-
let settings = {};
|
|
2000
|
-
if (fs.existsSync(settingsPath)) {
|
|
2001
|
-
try {
|
|
2002
|
-
settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")) || {};
|
|
2003
|
-
}
|
|
2004
|
-
catch (e) {
|
|
2005
|
-
// Malformed user settings: do NOT clobber — log and bail.
|
|
2006
|
-
console.error(`[autopilot] settings.json unparseable; skipping tool pre-approval: ${e?.message || e}`);
|
|
2007
|
-
return;
|
|
2008
|
-
}
|
|
2009
|
-
}
|
|
2010
|
-
if (typeof settings !== "object" || Array.isArray(settings))
|
|
2011
|
-
return;
|
|
2012
|
-
const perms = (settings.permissions ??= {});
|
|
2013
|
-
if (typeof perms !== "object" || Array.isArray(perms))
|
|
2014
|
-
return;
|
|
2015
|
-
const allow = Array.isArray(perms.allow) ? perms.allow : (perms.allow = []);
|
|
2016
|
-
let added = 0;
|
|
2017
|
-
for (const t of AUTOPILOT_ALLOWED_TOOLS) {
|
|
2018
|
-
if (!allow.includes(t)) {
|
|
2019
|
-
allow.push(t);
|
|
2020
|
-
added++;
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
if (added === 0)
|
|
2024
|
-
return;
|
|
2025
|
-
fs.mkdirSync(cfg, { recursive: true });
|
|
2026
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
2027
|
-
console.error(`[autopilot] pre-approved ${added} autopilot tool(s) in ${settingsPath} (allow-only)`);
|
|
2028
|
-
}
|
|
2029
|
-
catch (e) {
|
|
2030
|
-
console.error(`[autopilot] ensureAutopilotToolsAllowed error: ${e?.message || e}`);
|
|
2031
|
-
}
|
|
2032
|
-
}
|
|
2033
1867
|
// ===========================================================================
|
|
2034
1868
|
// Queue-worker scheduled tasks + launchd kicker (2026-06-23)
|
|
2035
1869
|
//
|
|
2036
|
-
//
|
|
2037
|
-
//
|
|
1870
|
+
// The single drafting path. The REAL pipeline runs in DRAFT_ONLY mode under
|
|
1871
|
+
// launchd; its `claude -p` calls go
|
|
2038
1872
|
// through scripts/claude_job.py's file queue (run_claude.sh provider seam); two
|
|
2039
1873
|
// scheduled tasks drain that queue. Each task is single-purpose (one job type),
|
|
2040
1874
|
// fires every minute, claims ONE job, runs the pipeline's own prompt as its
|
|
@@ -2118,7 +1952,7 @@ function queueWorkerSkillMd(spec) {
|
|
|
2118
1952
|
`\n\n<!-- ${QUEUE_WORKER_PROMPT_MARKER}: ${QUEUE_WORKER_PROMPT_VERSION} -->\n`);
|
|
2119
1953
|
}
|
|
2120
1954
|
// Refresh each worker task's SKILL.md when this build ships a newer prompt than
|
|
2121
|
-
// what's on disk.
|
|
1955
|
+
// what's on disk. Best-effort, only touches
|
|
2122
1956
|
// an EXISTING task (onboarding creates them), only when stale. Also rewrites when
|
|
2123
1957
|
// the baked-in paths (python/repo) would have changed, since a stale absolute
|
|
2124
1958
|
// path would break the Bash commands; we detect that by always rewriting on a
|
|
@@ -2308,6 +2142,60 @@ async function ensureQueueKickerInstalled() {
|
|
|
2308
2142
|
return { ok: false, detail: e?.message || String(e) };
|
|
2309
2143
|
}
|
|
2310
2144
|
}
|
|
2145
|
+
// ---- launchd reaper: kill leaked agent-mode claude worker sessions ----------
|
|
2146
|
+
// Independent guardrail (NOT gated on a project being ready): the leak happens
|
|
2147
|
+
// whenever the scheduled-task workers fire, and a no-leak run is a cheap no-op.
|
|
2148
|
+
// Runs the stdlib-only reaper under SYSTEM python (always present, zero deps) so
|
|
2149
|
+
// it works even before the owned runtime provisions. Content-aware install so an
|
|
2150
|
+
// already-installed box picks up a changed interval/path on the next Claude boot.
|
|
2151
|
+
const REAPER_INTERVAL_SECS = 60; // match the ~1/min worker spawn cadence
|
|
2152
|
+
async function ensureClaudeReaperInstalled() {
|
|
2153
|
+
try {
|
|
2154
|
+
if (process.platform !== "darwin")
|
|
2155
|
+
return { ok: false, detail: "not macOS" };
|
|
2156
|
+
const logDir = path.join(repoDir(), "skill", "logs");
|
|
2157
|
+
try {
|
|
2158
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
2159
|
+
}
|
|
2160
|
+
catch {
|
|
2161
|
+
/* best-effort */
|
|
2162
|
+
}
|
|
2163
|
+
const xml = plistXml({
|
|
2164
|
+
label: REAPER_LABEL,
|
|
2165
|
+
programArgs: ["/usr/bin/python3", path.join(repoDir(), "scripts", "reap_stale_claude_sessions.py")],
|
|
2166
|
+
intervalSecs: REAPER_INTERVAL_SECS,
|
|
2167
|
+
runAtLoad: true, // clean up an existing backlog the instant Claude launches
|
|
2168
|
+
stdoutLog: path.join(logDir, "launchd-claude-reaper-stdout.log"),
|
|
2169
|
+
stderrLog: path.join(logDir, "launchd-claude-reaper-stderr.log"),
|
|
2170
|
+
});
|
|
2171
|
+
const uid = process.getuid ? process.getuid() : 0;
|
|
2172
|
+
let cur = null;
|
|
2173
|
+
try {
|
|
2174
|
+
cur = fs.readFileSync(REAPER_PLIST, "utf-8");
|
|
2175
|
+
}
|
|
2176
|
+
catch {
|
|
2177
|
+
cur = null;
|
|
2178
|
+
}
|
|
2179
|
+
let detail;
|
|
2180
|
+
if (cur === xml) {
|
|
2181
|
+
const res = await loadPlist(REAPER_LABEL, REAPER_PLIST, uid);
|
|
2182
|
+
detail = `current (load rc=${res.code})`;
|
|
2183
|
+
}
|
|
2184
|
+
else {
|
|
2185
|
+
if (cur !== null) {
|
|
2186
|
+
await unloadPlist(REAPER_LABEL, REAPER_PLIST, uid);
|
|
2187
|
+
}
|
|
2188
|
+
fs.mkdirSync(path.dirname(REAPER_PLIST), { recursive: true });
|
|
2189
|
+
fs.writeFileSync(REAPER_PLIST, xml, "utf-8");
|
|
2190
|
+
const res = await loadPlist(REAPER_LABEL, REAPER_PLIST, uid);
|
|
2191
|
+
detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
|
|
2192
|
+
}
|
|
2193
|
+
return { ok: true, detail };
|
|
2194
|
+
}
|
|
2195
|
+
catch (e) {
|
|
2196
|
+
return { ok: false, detail: e?.message || String(e) };
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2311
2199
|
// Assemble everything the panel needs in one shot (projects + X + autopilot +
|
|
2312
2200
|
// version). Resilient: any probe that throws degrades to a safe default rather
|
|
2313
2201
|
// than failing the whole snapshot.
|
|
@@ -2499,7 +2387,7 @@ function sapsStateDir() {
|
|
|
2499
2387
|
// of the in-process `postingActive` flag. The autopilot scan and the post
|
|
2500
2388
|
// sometimes run in the SAME MCP (the in-process flag covers that) and sometimes
|
|
2501
2389
|
// in TWO SEPARATE MCP instances (different agent sessions each spawn their own).
|
|
2502
|
-
// A file every instance's
|
|
2390
|
+
// A file every instance's draft-cycle scan reads makes the mutual exclusion hold
|
|
2503
2391
|
// regardless of which topology Claude Desktop happens to use. Heartbeat'd with a
|
|
2504
2392
|
// short TTL so a crashed poster's flag self-clears and never wedges scanning.
|
|
2505
2393
|
const POSTING_FLAG_TTL_MS = 45_000;
|
|
@@ -2542,7 +2430,7 @@ function stopPostingFlagHeartbeat() {
|
|
|
2542
2430
|
}
|
|
2543
2431
|
}
|
|
2544
2432
|
// True when ANY MCP instance has a FRESH posting flag on disk. Absent or expired
|
|
2545
|
-
// == not posting. This is what makes a sibling instance's
|
|
2433
|
+
// == not posting. This is what makes a sibling instance's draft-cycle scan defer.
|
|
2546
2434
|
function isPostingFlagFresh() {
|
|
2547
2435
|
try {
|
|
2548
2436
|
const j = JSON.parse(fs.readFileSync(postingFlagPath(), "utf-8"));
|
|
@@ -2607,112 +2495,6 @@ async function openInBrowser(url) {
|
|
|
2607
2495
|
console.error("[social-autoposter-mcp] openInBrowser failed:", e?.message || e);
|
|
2608
2496
|
}
|
|
2609
2497
|
}
|
|
2610
|
-
async function runScanCandidates(project, onProgress) {
|
|
2611
|
-
// SCAN_ONLY=1: run scan -> score -> top-N select, then STOP before drafting.
|
|
2612
|
-
// No DRAFT_ONLY, no `claude -p` drafting. TWITTER_PHASE1_LLM_DRAFT=0 forces the
|
|
2613
|
-
// deterministic query bank so the whole scan is claude-free (the agent does ALL
|
|
2614
|
-
// the AI). Off-screen by default (no BH_WINDOW_* / overlay): a scheduled run has
|
|
2615
|
-
// no human watching the Chrome.
|
|
2616
|
-
const env = {
|
|
2617
|
-
SCAN_ONLY: "1",
|
|
2618
|
-
TWITTER_PHASE1_LLM_DRAFT: "0",
|
|
2619
|
-
SAPS_REPO_DIR: repoDir(),
|
|
2620
|
-
PATH: pipelinePath(),
|
|
2621
|
-
};
|
|
2622
|
-
if (project)
|
|
2623
|
-
env.SAPS_FORCE_PROJECT = project;
|
|
2624
|
-
const chrome = resolveChrome();
|
|
2625
|
-
if (chrome)
|
|
2626
|
-
env.BH_CHROME_BIN = chrome;
|
|
2627
|
-
let step = 0;
|
|
2628
|
-
let lastMsg = "";
|
|
2629
|
-
// Specific phase label for the menu bar for the whole multi-minute scan, so the
|
|
2630
|
-
// long-poll doesn't leave it on a generic "working" (or flicker to idle between
|
|
2631
|
-
// re-calls). Cleared by the handler when the scan resolves.
|
|
2632
|
-
writeActivity("scanning", project ? `scanning X for ${project}` : "scanning X");
|
|
2633
|
-
// Single-flight: kill any pre-existing run-twitter-cycle.sh (zombies that
|
|
2634
|
-
// survived a prior preempt, or stale waiters parked behind a post) before
|
|
2635
|
-
// launching, so only ONE scan ever exists. The plist gets this from
|
|
2636
|
-
// run-twitter-cycle-singleton.sh; the MCP's direct launch must enforce it here.
|
|
2637
|
-
sigkillAllScans();
|
|
2638
|
-
const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
|
|
2639
|
-
env,
|
|
2640
|
-
timeoutMs: 900_000,
|
|
2641
|
-
onSpawn: (c) => {
|
|
2642
|
-
// Track the PLUGIN's own scan process so an approved post can abort exactly
|
|
2643
|
-
// this scan (posting preempts scanning). This is the plugin pipeline only —
|
|
2644
|
-
// the plist autopilot's scan is a separate launchd process the MCP server
|
|
2645
|
-
// never spawns and has no handle to, so it can never be touched here.
|
|
2646
|
-
scanChild = c;
|
|
2647
|
-
},
|
|
2648
|
-
onLine: (line) => {
|
|
2649
|
-
const t = line.replace(/\s+$/, "");
|
|
2650
|
-
if (t.trim())
|
|
2651
|
-
console.error(`[scan_candidates] ${t}`);
|
|
2652
|
-
if (!onProgress)
|
|
2653
|
-
return;
|
|
2654
|
-
const msg = cycleProgressMessage(t);
|
|
2655
|
-
if (msg && msg !== lastMsg) {
|
|
2656
|
-
lastMsg = msg;
|
|
2657
|
-
onProgress(msg, ++step);
|
|
2658
|
-
}
|
|
2659
|
-
},
|
|
2660
|
-
});
|
|
2661
|
-
scanChild = null; // scan finished on its own; nothing to preempt.
|
|
2662
|
-
const marker = /SCAN_ONLY_RESULT=(\/\S+\.json)/.exec(res.stdout + "\n" + res.stderr);
|
|
2663
|
-
if (marker && marker[1]) {
|
|
2664
|
-
try {
|
|
2665
|
-
const data = JSON.parse(fs.readFileSync(marker[1], "utf-8"));
|
|
2666
|
-
return {
|
|
2667
|
-
batchId: data.batch_id ?? null,
|
|
2668
|
-
candidates: (data.candidates ?? []),
|
|
2669
|
-
};
|
|
2670
|
-
}
|
|
2671
|
-
catch (e) {
|
|
2672
|
-
return {
|
|
2673
|
-
batchId: null,
|
|
2674
|
-
candidates: [],
|
|
2675
|
-
blocked: `Scan finished but its result file was unreadable: ${e?.message || e}`,
|
|
2676
|
-
};
|
|
2677
|
-
}
|
|
2678
|
-
}
|
|
2679
|
-
// If query-gen (when enabled) hits a real failure, the cycle still emits
|
|
2680
|
-
// DRAFT_ONLY_BLOCKED=<reason>. Surface it rather than "no candidates".
|
|
2681
|
-
const blockedMarker = /DRAFT_ONLY_BLOCKED=([a-z0-9_]+)/.exec(res.stdout + "\n" + res.stderr);
|
|
2682
|
-
if (blockedMarker && blockedMarker[1]) {
|
|
2683
|
-
return { batchId: null, candidates: [], blocked: blockedReasonMessage(blockedMarker[1]) };
|
|
2684
|
-
}
|
|
2685
|
-
// The pipeline's single-flight guard prints too_many_inflight and exits 0 when
|
|
2686
|
-
// ANOTHER scan already holds the twitter-cycle slot (commonly the legacy
|
|
2687
|
-
// claude -p autopilot, which we deliberately keep running). That is CONTENTION,
|
|
2688
|
-
// not an empty batch — flag it so the caller retries instead of concluding
|
|
2689
|
-
// "no candidates" (a clean empty scan returns a batch_id with count:0).
|
|
2690
|
-
if (/too_many_inflight/.test(res.stdout + res.stderr)) {
|
|
2691
|
-
return {
|
|
2692
|
-
batchId: null,
|
|
2693
|
-
candidates: [],
|
|
2694
|
-
busy: true,
|
|
2695
|
-
blocked: "Another scan already holds the pipeline slot (likely the legacy autopilot). Contention, " +
|
|
2696
|
-
"not an empty result — retry scan_candidates shortly.",
|
|
2697
|
-
};
|
|
2698
|
-
}
|
|
2699
|
-
return {
|
|
2700
|
-
batchId: null,
|
|
2701
|
-
candidates: [],
|
|
2702
|
-
blocked: `This scan produced no candidates (exit ${res.code}). Usually a cold-start ` +
|
|
2703
|
-
`project with no seeded search topics, or nothing fresh on-theme right now. Tail:\n` +
|
|
2704
|
-
res.stderr.split("\n").slice(-12).join("\n"),
|
|
2705
|
-
};
|
|
2706
|
-
}
|
|
2707
|
-
// At most one scan runs at a time (mirrors the pipeline's own max=1 lock). Kept
|
|
2708
|
-
// across calls so a later poll attaches to the SAME running scan instead of
|
|
2709
|
-
// starting a new one.
|
|
2710
|
-
let currentScanJob = null;
|
|
2711
|
-
// The PLUGIN's currently-spawned scan subprocess (captured via run()'s onSpawn),
|
|
2712
|
-
// so an approved post can abort exactly this scan and take the browser
|
|
2713
|
-
// immediately. Only ever references a scan the MCP server itself launched — never
|
|
2714
|
-
// the plist autopilot's launchd scan.
|
|
2715
|
-
let scanChild = null;
|
|
2716
2498
|
// ---- Cross-process browser-lock bridge (the REAL posting-priority fix) ------
|
|
2717
2499
|
// The SCANNER (run-twitter-cycle.sh) serializes browser access on a mkdir-based
|
|
2718
2500
|
// DIRECTORY lock at /tmp/social-autoposter-twitter-browser.lock (skill/lock.sh).
|
|
@@ -2725,10 +2507,10 @@ let scanChild = null;
|
|
|
2725
2507
|
// churned 118 queries for ~10min (proven live on the remote box 2026-06-23:
|
|
2726
2508
|
// /tmp lock pid=scanner AND json lock python:poster role=post, simultaneously).
|
|
2727
2509
|
//
|
|
2728
|
-
//
|
|
2729
|
-
// a
|
|
2730
|
-
//
|
|
2731
|
-
//
|
|
2510
|
+
// The scan that actually holds the browser is a run-twitter-cycle.sh process —
|
|
2511
|
+
// usually a SIBLING (the every-minute launchd cycle), which we have no
|
|
2512
|
+
// ChildProcess for. So we bridge to the lock the scanner truly respects: read
|
|
2513
|
+
// its /tmp pid file, and if a
|
|
2732
2514
|
// live run-twitter-cycle.sh holds it, signal it cross-process. Then the post
|
|
2733
2515
|
// HOLDS that same /tmp lock for the whole batch so the every-minute autopilot
|
|
2734
2516
|
// scan queues behind us (its acquire_lock waits on our live pid) instead of
|
|
@@ -2840,7 +2622,7 @@ function sigkillAllScans() {
|
|
|
2840
2622
|
const SHELL_LOCK_GRACE_MS = Number(process.env.SAPS_POST_LOCK_GRACE_MS) || 60_000;
|
|
2841
2623
|
let shellLockReleaseTimer = null;
|
|
2842
2624
|
// True from the start of a post batch until SHELL_LOCK_GRACE_MS after the last
|
|
2843
|
-
// card.
|
|
2625
|
+
// card. The draft-cycle scan checks this and DEFERS launching a scan while it's set —
|
|
2844
2626
|
// the real fix: posting and scanning are mutually exclusive at the SOURCE (both
|
|
2845
2627
|
// are children of THIS one MCP), so we never even launch a scan that would race
|
|
2846
2628
|
// the post for the browser lock. Having the post fight scans for the lock (the
|
|
@@ -2940,324 +2722,20 @@ function releaseShellBrowserLock() {
|
|
|
2940
2722
|
}
|
|
2941
2723
|
}
|
|
2942
2724
|
// Posting takes priority over scanning. When the user approves a post, abort any
|
|
2943
|
-
// in-flight scan so the browser frees up at once.
|
|
2944
|
-
//
|
|
2945
|
-
// the /tmp shell lock
|
|
2946
|
-
//
|
|
2725
|
+
// in-flight scan so the browser frees up at once. The scan that actually holds the
|
|
2726
|
+
// shared Chrome is a live run-twitter-cycle.sh (the every-minute launchd cycle);
|
|
2727
|
+
// kill it cross-process via the /tmp shell lock it truly respects. Best-effort;
|
|
2728
|
+
// never throws; never touches a locked pipeline script.
|
|
2947
2729
|
function preemptScanForPost() {
|
|
2948
|
-
try {
|
|
2949
|
-
if (scanChild && scanChild.pid && scanChild.exitCode === null) {
|
|
2950
|
-
console.error(`[post] preempting in-flight plugin scan (pid ${scanChild.pid}) so the approved post takes the browser — SIGKILL tree`);
|
|
2951
|
-
sigkillScanTree(scanChild.pid); // SIGKILL — scan bash traps SIGTERM
|
|
2952
|
-
}
|
|
2953
|
-
}
|
|
2954
|
-
catch {
|
|
2955
|
-
/* best effort */
|
|
2956
|
-
}
|
|
2957
|
-
// Drop the long-poll job so a queued poller stops waiting on the aborted scan;
|
|
2958
|
-
// the next scan_candidates call starts fresh.
|
|
2959
|
-
currentScanJob = null;
|
|
2960
|
-
scanChild = null;
|
|
2961
|
-
// Cross-process: the scan that actually holds the shared Chrome is usually a
|
|
2962
|
-
// sibling MCP instance's, not ours. Kill it via the lock it truly respects.
|
|
2963
2730
|
preemptScanHoldingBrowser();
|
|
2964
2731
|
}
|
|
2965
|
-
// Per-call long-poll window. Must stay under the ~60s MCP client request timeout
|
|
2966
|
-
// so every scan_candidates call returns a real response before the client gives
|
|
2967
|
-
// up. Override with SAPS_SCAN_POLL_WAIT_MS for non-standard clients.
|
|
2968
|
-
const SCAN_POLL_WAIT_MS = Number(process.env.SAPS_SCAN_POLL_WAIT_MS) || 45_000;
|
|
2969
|
-
// After a contention hit (too_many_inflight), wait this long before launching a
|
|
2970
|
-
// fresh scan, so polls don't hammer the pipeline with back-to-back no-op scans
|
|
2971
|
-
// while the other holder finishes. Polls during the cooldown just report "busy".
|
|
2972
|
-
const SCAN_BUSY_COOLDOWN_MS = Number(process.env.SAPS_SCAN_BUSY_COOLDOWN_MS) || 20_000;
|
|
2973
|
-
let scanBusyUntil = 0;
|
|
2974
|
-
function scanBusy() {
|
|
2975
|
-
return textContent("Another scan is already running on this machine (likely the legacy autopilot holding the " +
|
|
2976
|
-
"twitter-cycle slot). This is CONTENTION, not an empty result. Call scan_candidates again in " +
|
|
2977
|
-
"~20s to retry — do NOT conclude there are no candidates, and do NOT sleep; just re-call.");
|
|
2978
|
-
}
|
|
2979
|
-
// A post batch is in progress: scanning is deferred so posting has exclusive use
|
|
2980
|
-
// of the one shared browser (mutual exclusion at the source). Not an error.
|
|
2981
|
-
function scanDeferredForPost() {
|
|
2982
|
-
return textContent("A post is in progress on this machine, so scanning is deferred — only one task drives X at a " +
|
|
2983
|
-
"time and posting has priority. This is EXPECTED, not an error or an empty result. Call " +
|
|
2984
|
-
"scan_candidates again shortly; it runs as soon as the current post batch finishes. Do NOT " +
|
|
2985
|
-
"sleep or conclude there are no candidates; just re-call.");
|
|
2986
|
-
}
|
|
2987
|
-
// Resolve to {done:true,value} the moment `p` settles, or {done:false} after
|
|
2988
|
-
// `ms` — whichever is first. The job promise keeps running either way, so a
|
|
2989
|
-
// later poll re-attaches to it.
|
|
2990
|
-
function waitUpTo(p, ms) {
|
|
2991
|
-
return Promise.race([
|
|
2992
|
-
p.then((value) => ({ done: true, value })),
|
|
2993
|
-
new Promise((resolve) => setTimeout(() => resolve({ done: false }), ms)),
|
|
2994
|
-
]);
|
|
2995
|
-
}
|
|
2996
|
-
function startScanJob(project) {
|
|
2997
|
-
const job = {
|
|
2998
|
-
project,
|
|
2999
|
-
startedAt: Date.now(),
|
|
3000
|
-
done: false,
|
|
3001
|
-
promise: undefined,
|
|
3002
|
-
};
|
|
3003
|
-
job.promise = runScanCandidates(project)
|
|
3004
|
-
.then((r) => {
|
|
3005
|
-
job.result = r;
|
|
3006
|
-
job.done = true;
|
|
3007
|
-
return r;
|
|
3008
|
-
})
|
|
3009
|
-
.catch((e) => {
|
|
3010
|
-
const r = {
|
|
3011
|
-
batchId: null,
|
|
3012
|
-
candidates: [],
|
|
3013
|
-
blocked: `The scan crashed before producing a result: ${String(e?.message || e)}. ` +
|
|
3014
|
-
`Call scan_candidates again to retry.`,
|
|
3015
|
-
};
|
|
3016
|
-
job.result = r;
|
|
3017
|
-
job.done = true;
|
|
3018
|
-
return r;
|
|
3019
|
-
});
|
|
3020
|
-
return job;
|
|
3021
|
-
}
|
|
3022
|
-
// Read the on-brand drafting guidance for the given projects from config.json so
|
|
3023
|
-
// scan_candidates can hand it to the agent INLINE. Without this the autopilot run
|
|
3024
|
-
// has to read the config itself (project_config returns status, not the voice
|
|
3025
|
-
// fields) — and a headless run improvises Bash/Read for that, hitting an
|
|
3026
|
-
// un-approved tool and hanging. Returning the voice here removes the need to read
|
|
3027
|
-
// any file. Best-effort: a missing/unreadable config yields {} and the agent
|
|
3028
|
-
// drafts from thread context, same as before.
|
|
3029
|
-
function readProjectVoices(projectNames) {
|
|
3030
|
-
const out = {};
|
|
3031
|
-
const want = new Set(projectNames.filter(Boolean));
|
|
3032
|
-
if (!want.size)
|
|
3033
|
-
return out;
|
|
3034
|
-
try {
|
|
3035
|
-
const cfg = JSON.parse(fs.readFileSync(path.join(repoDir(), "config.json"), "utf-8"));
|
|
3036
|
-
const projects = Array.isArray(cfg?.projects) ? cfg.projects : [];
|
|
3037
|
-
for (const p of projects) {
|
|
3038
|
-
if (!p?.name || !want.has(p.name))
|
|
3039
|
-
continue;
|
|
3040
|
-
// Only the fields needed to draft on-brand — keep it lean.
|
|
3041
|
-
out[p.name] = {
|
|
3042
|
-
voice: p.voice,
|
|
3043
|
-
voice_relationship: p.voice_relationship,
|
|
3044
|
-
description: p.description,
|
|
3045
|
-
differentiator: p.differentiator,
|
|
3046
|
-
content_guardrails: p.content_guardrails,
|
|
3047
|
-
website: p.website,
|
|
3048
|
-
get_started_link: p.get_started_link,
|
|
3049
|
-
};
|
|
3050
|
-
}
|
|
3051
|
-
}
|
|
3052
|
-
catch {
|
|
3053
|
-
/* best effort — no inline voice, agent falls back to thread context */
|
|
3054
|
-
}
|
|
3055
|
-
return out;
|
|
3056
|
-
}
|
|
3057
|
-
function formatScanResult(scan) {
|
|
3058
|
-
if (!scan.batchId) {
|
|
3059
|
-
return textContent(scan.blocked || "No candidates found.");
|
|
3060
|
-
}
|
|
3061
|
-
// The brand voice for every project in this batch, INLINE, so the agent never
|
|
3062
|
-
// has to read a config file (that improvisation is what hangs headless runs).
|
|
3063
|
-
const projects = Array.from(new Set(scan.candidates.map((c) => c.matched_project).filter(Boolean)));
|
|
3064
|
-
const project_voices = readProjectVoices(projects);
|
|
3065
|
-
return jsonContent({
|
|
3066
|
-
batch_id: scan.batchId,
|
|
3067
|
-
count: scan.candidates.length,
|
|
3068
|
-
candidates: scan.candidates,
|
|
3069
|
-
project_voices,
|
|
3070
|
-
next_step: `Draft an on-brand reply (<=250 chars, match the thread's language) for each candidate you ` +
|
|
3071
|
-
`judge genuinely worth engaging; skip the rest. The brand voice + guardrails for each ` +
|
|
3072
|
-
`candidate's project are in project_voices[<matched_project>] (voice, description, ` +
|
|
3073
|
-
`differentiator, content_guardrails) — draft from THAT. Do NOT read config files or call any ` +
|
|
3074
|
-
`other tool to find the voice; everything you need is in this result. Then call submit_drafts ` +
|
|
3075
|
-
`with batch_id "${scan.batchId}" and one entry per drafted reply ({candidate_id, reply_text}). ` +
|
|
3076
|
-
`Nothing posts until the user approves.`,
|
|
3077
|
-
});
|
|
3078
|
-
}
|
|
3079
|
-
function scanInProgress(job) {
|
|
3080
|
-
const elapsed = Math.round((Date.now() - job.startedAt) / 1000);
|
|
3081
|
-
return textContent(`Scan in progress (${elapsed}s elapsed). The X scan drives a real browser and usually takes ` +
|
|
3082
|
-
`1-3 minutes. Call scan_candidates again RIGHT NOW to keep waiting — each call long-polls up ` +
|
|
3083
|
-
`to ~${Math.round(SCAN_POLL_WAIT_MS / 1000)}s and returns the candidates the moment the scan ` +
|
|
3084
|
-
`finishes. Do NOT sleep, run a background command, or do other work to bridge the gap; just ` +
|
|
3085
|
-
`re-call scan_candidates until it returns candidates or reports no candidates.`);
|
|
3086
|
-
}
|
|
3087
|
-
tool("scan_candidates", {
|
|
3088
|
-
title: "Scan X for reply candidates (no drafting, no posting)",
|
|
3089
|
-
description: "Step 1 of a hands-free / scheduled autopilot run. Runs the scan+score half of the pipeline " +
|
|
3090
|
-
"and returns the top scored X/Twitter threads worth replying to — WITHOUT drafting or posting, " +
|
|
3091
|
-
"and without spending any `claude -p` budget. You (this session) then draft an on-brand reply " +
|
|
3092
|
-
"for each good candidate YOURSELF and submit them with `submit_drafts`. Each candidate includes " +
|
|
3093
|
-
"its candidate_id (pass it back), the thread text/author, the matched project, and engagement " +
|
|
3094
|
-
"metrics. The result ALSO includes `project_voices` — the brand voice + guardrails for each " +
|
|
3095
|
-
"project in the batch — so you draft on-brand WITHOUT reading config files or calling other " +
|
|
3096
|
-
"tools (do not improvise a config read). Optional `project` scopes the scan to one configured " +
|
|
3097
|
-
"project. The scan drives a real " +
|
|
3098
|
-
"browser and can take 1-3 minutes: this call long-polls and may return a `Scan in progress` " +
|
|
3099
|
-
"status instead of candidates — if so, just call scan_candidates again (same args) and keep " +
|
|
3100
|
-
"re-calling until it returns candidates. Never sleep or use a background wait to bridge the gap.",
|
|
3101
|
-
inputSchema: { project: z.string().optional() },
|
|
3102
|
-
}, async (args) => {
|
|
3103
|
-
// MUTUAL EXCLUSION (the real hijack fix): while a post batch is in progress
|
|
3104
|
-
// (or its grace-hold is active), DEFER scanning entirely rather than launching
|
|
3105
|
-
// a scan that races the post for the one shared browser. Both the scan and the
|
|
3106
|
-
// post are children of THIS MCP, so this in-process gate is exact — the
|
|
3107
|
-
// autopilot never even starts a scan to interrupt a post. Posting always wins;
|
|
3108
|
-
// the autopilot just re-calls and runs once the batch drains.
|
|
3109
|
-
if (postingActive || isPostingFlagFresh())
|
|
3110
|
-
return scanDeferredForPost();
|
|
3111
|
-
// Long-poll the single in-flight scan job (see the ScanJob registry above).
|
|
3112
|
-
// A finished-but-unconsumed job: hand back its result and clear the slot so a
|
|
3113
|
-
// later call starts a fresh scan.
|
|
3114
|
-
if (currentScanJob?.done) {
|
|
3115
|
-
const finished = currentScanJob;
|
|
3116
|
-
currentScanJob = null;
|
|
3117
|
-
if (finished.result?.busy) {
|
|
3118
|
-
scanBusyUntil = Date.now() + SCAN_BUSY_COOLDOWN_MS;
|
|
3119
|
-
return scanBusy();
|
|
3120
|
-
}
|
|
3121
|
-
return formatScanResult(finished.result);
|
|
3122
|
-
}
|
|
3123
|
-
// Contention cooldown: another scan holds the slot — report busy without
|
|
3124
|
-
// launching yet another no-op scan against the locked pipeline.
|
|
3125
|
-
if (!currentScanJob && Date.now() < scanBusyUntil) {
|
|
3126
|
-
return scanBusy();
|
|
3127
|
-
}
|
|
3128
|
-
// Nothing running -> start one scan (single-flight: never spawn a second
|
|
3129
|
-
// run-twitter-cycle.sh that would just hit the pipeline's too_many_inflight).
|
|
3130
|
-
if (!currentScanJob) {
|
|
3131
|
-
currentScanJob = startScanJob(args?.project);
|
|
3132
|
-
}
|
|
3133
|
-
// Wait for the in-flight scan, but no longer than the client timeout allows.
|
|
3134
|
-
const waited = await waitUpTo(currentScanJob.promise, SCAN_POLL_WAIT_MS);
|
|
3135
|
-
if (waited.done) {
|
|
3136
|
-
currentScanJob = null;
|
|
3137
|
-
if (waited.value.busy) {
|
|
3138
|
-
scanBusyUntil = Date.now() + SCAN_BUSY_COOLDOWN_MS;
|
|
3139
|
-
return scanBusy();
|
|
3140
|
-
}
|
|
3141
|
-
return formatScanResult(waited.value);
|
|
3142
|
-
}
|
|
3143
|
-
// Still scanning — tell the agent to simply call scan_candidates again.
|
|
3144
|
-
return scanInProgress(currentScanJob);
|
|
3145
|
-
});
|
|
3146
|
-
tool("submit_drafts", {
|
|
3147
|
-
title: "Submit drafted replies for review",
|
|
3148
|
-
description: "Step 2 of a hands-free / scheduled autopilot run. Hand back the replies you drafted for the " +
|
|
3149
|
-
"candidates returned by scan_candidates. Writes them into the SAME review plan the menu-bar " +
|
|
3150
|
-
"approval UI and post_drafts already use — nothing is posted until the user approves. Provide " +
|
|
3151
|
-
"batch_id (from scan_candidates) and a `drafts` array; each entry needs candidate_id and " +
|
|
3152
|
-
"reply_text (engagement_style, language, link_keyword optional).",
|
|
3153
|
-
inputSchema: {
|
|
3154
|
-
batch_id: z.string(),
|
|
3155
|
-
drafts: z
|
|
3156
|
-
.array(z.object({
|
|
3157
|
-
candidate_id: z.union([z.string(), z.number()]),
|
|
3158
|
-
reply_text: z.string(),
|
|
3159
|
-
engagement_style: z.string().optional(),
|
|
3160
|
-
language: z.string().optional(),
|
|
3161
|
-
link_keyword: z.string().optional(),
|
|
3162
|
-
}))
|
|
3163
|
-
.min(1),
|
|
3164
|
-
},
|
|
3165
|
-
}, async (args) => {
|
|
3166
|
-
// Reload the scan candidates for thread metadata (url / author / text /
|
|
3167
|
-
// project / topic). If the scan file is gone (e.g. /tmp cleared), we still
|
|
3168
|
-
// build a plan from the drafts alone; the review cards just lack context.
|
|
3169
|
-
const scanById = new Map();
|
|
3170
|
-
try {
|
|
3171
|
-
const raw = JSON.parse(fs.readFileSync(scanResultPath(args.batch_id), "utf-8"));
|
|
3172
|
-
for (const c of (raw.candidates ?? [])) {
|
|
3173
|
-
scanById.set(String(c.id), c);
|
|
3174
|
-
}
|
|
3175
|
-
}
|
|
3176
|
-
catch {
|
|
3177
|
-
/* scan file missing — proceed with draft-only context */
|
|
3178
|
-
}
|
|
3179
|
-
const candidates = args.drafts.map((d) => {
|
|
3180
|
-
const sc = scanById.get(String(d.candidate_id));
|
|
3181
|
-
return {
|
|
3182
|
-
candidate_id: d.candidate_id,
|
|
3183
|
-
candidate_url: sc?.tweet_url,
|
|
3184
|
-
thread_author: sc?.author_handle,
|
|
3185
|
-
thread_text: sc?.tweet_text,
|
|
3186
|
-
reply_text: d.reply_text,
|
|
3187
|
-
engagement_style: d.engagement_style,
|
|
3188
|
-
language: d.language,
|
|
3189
|
-
link_keyword: d.link_keyword,
|
|
3190
|
-
search_topic: sc?.search_topic,
|
|
3191
|
-
matched_project: sc?.matched_project,
|
|
3192
|
-
};
|
|
3193
|
-
});
|
|
3194
|
-
const firstSc = scanById.get(String(args.drafts[0].candidate_id));
|
|
3195
|
-
const project = (candidates.map((c) => c.matched_project).find((p) => !!p) ||
|
|
3196
|
-
firstSc?.matched_project ||
|
|
3197
|
-
"default");
|
|
3198
|
-
// Stage the new drafts under the scan batch id and bake link targets into them
|
|
3199
|
-
// (sub-second at TWITTER_PAGE_GEN_RATE=0). Best-effort: posting falls back to the
|
|
3200
|
-
// plain project URL per-candidate if gen is skipped.
|
|
3201
|
-
writePlan(args.batch_id, { candidates });
|
|
3202
|
-
try {
|
|
3203
|
-
await runPython("scripts/twitter_gen_links.py", ["--plan", planPath(args.batch_id)], {
|
|
3204
|
-
timeoutMs: 120_000,
|
|
3205
|
-
env: { TWITTER_PAGE_GEN_RATE: "0", SAPS_REPO_DIR: repoDir(), PATH: pipelinePath() },
|
|
3206
|
-
});
|
|
3207
|
-
}
|
|
3208
|
-
catch {
|
|
3209
|
-
/* best effort — plan still posts with a plain-URL fallback */
|
|
3210
|
-
}
|
|
3211
|
-
const staged = readPlan(args.batch_id)?.candidates ?? candidates;
|
|
3212
|
-
// Accumulate into ONE persistent review queue so a continuous autopilot's drafts
|
|
3213
|
-
// PILE UP in the menu-bar cards instead of each run overwriting the last. New
|
|
3214
|
-
// drafts are appended; a thread already in the queue (by URL) is skipped (one
|
|
3215
|
-
// draft per thread). Posted entries are KEPT in place so the 1-based card
|
|
3216
|
-
// numbering stays stable across runs — the menu bar, the chat table, and
|
|
3217
|
-
// post_drafts all index the full array and filter finished rows.
|
|
3218
|
-
const queue = [
|
|
3219
|
-
...(readPlan(REVIEW_QUEUE_ID)?.candidates ?? []),
|
|
3220
|
-
];
|
|
3221
|
-
const seen = new Set(queue.map((c) => c.candidate_url).filter((u) => !!u));
|
|
3222
|
-
let added = 0;
|
|
3223
|
-
for (const nc of staged) {
|
|
3224
|
-
if (nc.candidate_url && seen.has(nc.candidate_url))
|
|
3225
|
-
continue;
|
|
3226
|
-
queue.push(nc);
|
|
3227
|
-
if (nc.candidate_url)
|
|
3228
|
-
seen.add(nc.candidate_url);
|
|
3229
|
-
added++;
|
|
3230
|
-
}
|
|
3231
|
-
writePlan(REVIEW_QUEUE_ID, { candidates: queue });
|
|
3232
|
-
// Pending = NOT YET DECIDED. A card that's posted, terminal (rejected/dead), OR
|
|
3233
|
-
// already approved is a settled decision and must never be re-presented for
|
|
3234
|
-
// review — approved ones just proceed to post (see drainApprovedBacklog).
|
|
3235
|
-
const pending = queue.filter((c) => c.posted !== true && c.terminal !== true && c.approved !== true);
|
|
3236
|
-
// Drafts queued = the pipeline verified end-to-end without posting. This is the
|
|
3237
|
-
// onboarding "draft_verified" terminal goal (formerly completed by draft_cycle).
|
|
3238
|
-
if (added > 0)
|
|
3239
|
-
completeOnboardingMilestone("draft_verified", { outcome: "review_batch", draft_count: added });
|
|
3240
|
-
// Point the menu-bar review cards at the accumulated queue.
|
|
3241
|
-
writeReviewRequest({
|
|
3242
|
-
batch_id: REVIEW_QUEUE_ID,
|
|
3243
|
-
project,
|
|
3244
|
-
count: pending.length,
|
|
3245
|
-
plan_path: planPath(REVIEW_QUEUE_ID),
|
|
3246
|
-
created_at: new Date().toISOString(),
|
|
3247
|
-
});
|
|
3248
|
-
return textContent(`Queued ${added} new draft(s); ${pending.length} now awaiting approval in the menu-bar cards ` +
|
|
3249
|
-
`(review queue "${REVIEW_QUEUE_ID}"). Nothing posts until approved.\n\n` +
|
|
3250
|
-
renderDraftsTable({ candidates: queue }) +
|
|
3251
|
-
`\n\nTo post: the user approves in the menu bar, or call post_drafts with batch_id ` +
|
|
3252
|
-
`"${REVIEW_QUEUE_ID}" and the numbers to post.`);
|
|
3253
|
-
});
|
|
3254
2732
|
appTool("dashboard", {
|
|
3255
2733
|
title: "S4L dashboard",
|
|
3256
2734
|
description: "Render the S4L dashboard in chat: a visual surface showing project setup, X " +
|
|
3257
2735
|
"connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, connect X, " +
|
|
3258
2736
|
"and refresh. Use when the user asks to see the dashboard, panel, " +
|
|
3259
2737
|
"status, or controls. ALSO call this at the end of any state-changing or results-producing " +
|
|
3260
|
-
"action (
|
|
2738
|
+
"action (run_draft_cycle, post_drafts, get_stats) so the user sees the " +
|
|
3261
2739
|
"updated dashboard. Hosts without UI support get the same data as text.",
|
|
3262
2740
|
inputSchema: {},
|
|
3263
2741
|
// fallback_url is set only when the host can't render the ui:// resource and
|
|
@@ -3483,15 +2961,6 @@ async function main() {
|
|
|
3483
2961
|
if (ensureRuntimeProvisioned()) {
|
|
3484
2962
|
console.error("[social-autoposter-mcp] owned runtime not ready; provisioning on boot");
|
|
3485
2963
|
}
|
|
3486
|
-
// Same problem for the scheduled task's prompt: the host owns its SKILL.md and
|
|
3487
|
-
// a plugin update never touches it. Rewrite it on boot when this build ships a
|
|
3488
|
-
// newer prompt version, so behavior changes (no-improvise / voice-inline /
|
|
3489
|
-
// stop-cleanly) reach an already-scheduled task on its next fire. Best-effort.
|
|
3490
|
-
ensureAutopilotPromptCurrent();
|
|
3491
|
-
// Pre-approve THIS server's tools in ~/.claude/settings.json so the unattended
|
|
3492
|
-
// scan -> submit path never stalls on an "Ask mode" permission prompt (see the
|
|
3493
|
-
// helper's note). Best-effort, allow-only, gated on the task existing.
|
|
3494
|
-
ensureAutopilotToolsAllowed();
|
|
3495
2964
|
// Queue-backed drafting (2026-06-23): keep the two worker-task prompts current,
|
|
3496
2965
|
// pre-approve their tools EAGERLY (before onboarding even creates the tasks, so
|
|
3497
2966
|
// the first unattended fire can't stall), and (re)install the launchd kicker
|
|
@@ -3502,6 +2971,13 @@ async function main() {
|
|
|
3502
2971
|
void ensureQueueKickerInstalled()
|
|
3503
2972
|
.then((r) => console.error(`[queue-worker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
3504
2973
|
.catch((e) => console.error("[queue-worker] kicker install failed:", e?.message || e));
|
|
2974
|
+
// Self-healing reaper for the agent-mode session leak the queue autopilot
|
|
2975
|
+
// produces (finished `claude` worker sessions Desktop never tears down). A
|
|
2976
|
+
// standalone guardrail; install unconditionally so it caps memory even on a
|
|
2977
|
+
// box whose project isn't ready yet. Best-effort; must never block boot.
|
|
2978
|
+
void ensureClaudeReaperInstalled()
|
|
2979
|
+
.then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
2980
|
+
.catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
|
|
3505
2981
|
// Heal installs onboarded before short_links_live defaulted to false: such a
|
|
3506
2982
|
// project wraps short links against the customer's own domain, which has no
|
|
3507
2983
|
// /r/[code] resolver, so every minted link 404s. Re-point them at the s4l.ai
|