@m13v/s4l 1.7.5-rc.9 → 1.7.5
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 +570 -97
- package/mcp/dist/panel.html +1 -1
- package/mcp/dist/redditAuth.js +115 -0
- package/mcp/dist/runtime.js +116 -12
- package/mcp/dist/setup.js +142 -5
- package/mcp/dist/telemetry.js +13 -3
- package/mcp/dist/version.js +7 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +2 -2
- package/mcp/menubar/s4l_card.py +431 -77
- package/mcp/menubar/s4l_card_canvas.py +712 -0
- package/mcp/menubar/s4l_menubar.py +439 -174
- package/mcp/menubar/s4l_state.py +138 -190
- package/mcp/package.json +1 -1
- package/mcp/shared/onboarding-ledger.cjs +63 -4
- package/package.json +2 -1
- package/scripts/active_experiments.py +24 -7
- package/scripts/browser_mutex.py +316 -0
- package/scripts/config.py +20 -2
- package/scripts/counterparty_history.py +44 -6
- package/scripts/engagement_signal_feedback.py +256 -0
- package/scripts/engagement_styles.py +32 -47
- package/scripts/feedback_digest.py +85 -27
- package/scripts/learned_preferences.py +37 -14
- package/scripts/merge_review_queue.py +34 -1
- package/scripts/platform_strike_events.py +232 -0
- package/scripts/post_reddit.py +167 -36
- package/scripts/preview_expiry_card.py +50 -0
- package/scripts/reddit_browser.py +62 -63
- package/scripts/reddit_browser_fetch.py +151 -64
- package/scripts/release-mcpb.sh +83 -2
- package/scripts/reset-test-machine.sh +13 -7
- package/scripts/scan_twitter_thread_followups.py +7 -1
- package/scripts/scan_x_profile.py +33 -8
- package/scripts/score_twitter_candidates.py +19 -5
- package/scripts/setup_reddit_auth.py +925 -0
- package/scripts/setup_twitter_auth.py +27 -3
- package/scripts/snapshot.py +103 -5
- package/scripts/stats.py +17 -13
- package/scripts/test_no_silent_fallbacks.py +281 -0
- package/scripts/trigger_promo_lane_test.sh +91 -0
- package/scripts/twitter_browser.py +26 -340
- package/scripts/twitter_post_plan.py +29 -2
- package/scripts/twitter_prompt_sandbox.py +163 -0
- package/scripts/twitter_scan.py +75 -10
- package/scripts/voice_exemplars.py +51 -15
- package/skill/audit-reddit-resurrect.sh +5 -2
- package/skill/dm-outreach-reddit.sh +5 -2
- package/skill/engage-dm-replies.sh +10 -2
- package/skill/engage-reddit.sh +5 -2
- package/skill/lib/browser-launch.sh +6 -1
- package/skill/lib/harness-common.sh +118 -2
- package/skill/lib/linkedin-backend.sh +18 -0
- package/skill/lib/reddit-backend.sh +21 -0
- package/skill/link-edit-reddit.sh +5 -2
- package/skill/lock.sh +57 -0
- package/skill/run-reddit-search-launchd.sh +4 -1
- package/skill/run-reddit-search.sh +31 -4
- package/skill/run-reddit-threads.sh +5 -2
- package/skill/run-twitter-cycle.sh +449 -137
- package/scripts/_compute_allowlist.py +0 -58
- package/scripts/_db_update.py +0 -20
- package/scripts/_filt.py +0 -9
- package/scripts/_li_notif_match.py +0 -76
- package/scripts/_li_notif_orchestrate.py +0 -126
- package/scripts/_process_li_notifs.py +0 -91
- package/scripts/_run_icp_precheck.py +0 -57
package/mcp/dist/index.js
CHANGED
|
@@ -20,8 +20,9 @@ import os from "node:os";
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
|
|
23
|
-
import { applySetup, resolveProject, hasReadyProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, normalizeStringList, } from "./setup.js";
|
|
23
|
+
import { applySetup, resolveProject, hasReadyProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, ensureConfigInStateDir, normalizeStringList, recordRedditAccount, } from "./setup.js";
|
|
24
24
|
import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
|
|
25
|
+
import { redditStatus, redditConnect, redditDetectSources, summarizeRedditAuth, } from "./redditAuth.js";
|
|
25
26
|
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, ensureHarnessPatched, retryProvisionIfStalled, } from "./runtime.js";
|
|
26
27
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
27
28
|
import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
|
|
@@ -68,6 +69,17 @@ const PHASE1_TASK_ID = "saps-phase1-query"; // legacy (was: "twitter-query" only
|
|
|
68
69
|
const PHASE2B_TASK_ID = "saps-phase2b-draft"; // legacy (was: "twitter-prep" only)
|
|
69
70
|
const TWITTER_AUTOPILOT_LABEL = "com.m13v.social-twitter-cycle";
|
|
70
71
|
const TWITTER_AUTOPILOT_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${TWITTER_AUTOPILOT_LABEL}.plist`);
|
|
72
|
+
// Reddit discovery kicker (optional platform, 2026-07-15). Runs the SAME
|
|
73
|
+
// launchd label the operator Mac has used by hand since May
|
|
74
|
+
// (com.m13v.social-reddit-search) so there is one canonical name fleet-wide,
|
|
75
|
+
// but ensureRedditKickerInstalled() refuses to touch a plist whose program
|
|
76
|
+
// path points outside the managed package (the operator's hand-built plist
|
|
77
|
+
// stays untouched). Installed only when reddit is connected AND a project is
|
|
78
|
+
// configured; never part of onboarding completion.
|
|
79
|
+
const REDDIT_SEARCH_LABEL = "com.m13v.social-reddit-search";
|
|
80
|
+
const REDDIT_SEARCH_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${REDDIT_SEARCH_LABEL}.plist`);
|
|
81
|
+
const REDDIT_SEARCH_INTERVAL_SECS = 900;
|
|
82
|
+
const REDDIT_CDP_URL_DEFAULT = "http://127.0.0.1:9557";
|
|
71
83
|
// Self-healing reaper for leaked Claude agent-mode worker sessions. The queue
|
|
72
84
|
// autopilot fires two scheduled tasks every ~1 min; each fire spawns a ~200 MB
|
|
73
85
|
// `claude` agent-mode session that finishes its one queue turn but never exits
|
|
@@ -313,23 +325,29 @@ const server = new McpServer({
|
|
|
313
325
|
"can run them hands-free on autopilot.\n\n" +
|
|
314
326
|
"ONBOARDING IS A TERMINAL GOAL. When the user asks to set up, install, configure, or onboard " +
|
|
315
327
|
"social-autoposter, keep taking the next safe action until the owned runtime is ready, a " +
|
|
316
|
-
"project is fully configured with seeded search topics,
|
|
328
|
+
"project is fully configured with seeded search topics, AT LEAST ONE platform is connected " +
|
|
329
|
+
"(ASK the user up front which platform(s) to connect — X, Reddit, or both — before connecting " +
|
|
330
|
+
"anything; X is a reasonable default to SUGGEST, but do not connect it, or any platform, without " +
|
|
331
|
+
"the user's answer. Connect exactly what they choose via project_config action:'connect_x' " +
|
|
332
|
+
"and/or action:'connect_reddit'), AND " +
|
|
317
333
|
"the two draft-autopilot scheduled tasks have been created via queue_setup and are firing " +
|
|
318
334
|
"(see SCHEDULE THE AUTOPILOT below). The owned runtime (private Python + Chromium) provisions " +
|
|
319
335
|
"ITSELF automatically on boot — you do NOT install it. Just poll runtime action:'status' until it " +
|
|
320
336
|
"reports ready; only call runtime action:'install' or action:'doctor' to TROUBLESHOOT if status " +
|
|
321
337
|
"shows the boot provision failed or stalled. Do not ask whether to inspect " +
|
|
322
|
-
"status, repair a failed runtime, choose an auto-detected browser profile,
|
|
323
|
-
"
|
|
338
|
+
"status, repair a failed runtime, choose an auto-detected browser profile, scan the profile, " +
|
|
339
|
+
"research the product website, save conservative inferred fields, or seed " +
|
|
324
340
|
"topics. Do those things. The " +
|
|
325
341
|
"explicit setup request authorizes runtime repair and importing ONLY x.com/" +
|
|
326
342
|
"twitter.com session cookies into the managed browser: warn that macOS keychain prompts may " +
|
|
327
|
-
"appear, then proceed without a separate yes/no turn.
|
|
343
|
+
"appear, then proceed without a separate yes/no turn. Besides the platform-choice question " +
|
|
344
|
+
"above, ask at most one MORE bundled question, only " +
|
|
328
345
|
"when no product can be identified from config, context, the X profile, or public research, " +
|
|
329
346
|
"or pause for an unavoidable interactive login or OS prompt. Never auto-POST during setup. " +
|
|
330
347
|
"DO schedule the draft autopilot as the final step (once running it only queues drafts for " +
|
|
331
348
|
"approval, it never posts on its own). When setup reaches done (" +
|
|
332
|
-
"runtime ready, a project configured with seeded topics,
|
|
349
|
+
"runtime ready, a project configured with seeded topics, at least one platform connected, and the " +
|
|
350
|
+
"two scheduled tasks " +
|
|
333
351
|
"created and firing), call the `dashboard` tool so the user " +
|
|
334
352
|
"sees the finished setup rendered, then give " +
|
|
335
353
|
"the completion summary.\n\n" +
|
|
@@ -351,6 +369,16 @@ const server = new McpServer({
|
|
|
351
369
|
"old 'social-autoposter-autopilot' task — it is deprecated. Do NOT ask the user to review drafts " +
|
|
352
370
|
"or pause for input mid-onboarding; once the tasks are created and firing, setup is done. If the " +
|
|
353
371
|
"host exposes no create_scheduled_task tool, tell the user the autopilot couldn't be scheduled here.\n\n" +
|
|
372
|
+
"REDDIT IS AN EQUAL PLATFORM CHOICE, ASKED ALONGSIDE X. Setup is complete with AT LEAST ONE " +
|
|
373
|
+
"platform connected: X-only, Reddit-only, or both — whichever the user picked when you asked " +
|
|
374
|
+
"up front. If they chose both, connect them one at a time (either order) in the same setup " +
|
|
375
|
+
"flow. `project_config` action:'connect_reddit' imports the user's reddit.com session into the " +
|
|
376
|
+
"autoposter's managed reddit browser, same confirm-first flow and keychain heads-up as " +
|
|
377
|
+
"connect_x. Once connected, reddit discovery and drafting run on the same draft-only queue and " +
|
|
378
|
+
"review cards as X; nothing posts without approval. If the result carries a `warning` (fresh or " +
|
|
379
|
+
"low-karma account), relay it so the user knows AutoMod may gate early replies. Never make " +
|
|
380
|
+
"either platform a requirement, and never nag a user who deliberately chose only one about the " +
|
|
381
|
+
"other.\n\n" +
|
|
354
382
|
"BE PROACTIVE ABOUT MARKETING MOMENTS. Whenever the user ships, finishes, merges, or releases " +
|
|
355
383
|
"something worth talking about in this session (a new feature, a launch, a long-awaited fix, a " +
|
|
356
384
|
"milestone), don't wait to be asked: point it out to the user — the draft autopilot runs " +
|
|
@@ -361,7 +389,8 @@ const server = new McpServer({
|
|
|
361
389
|
"release, call the `runtime` tool (action:'version'). If `update_available` is true, tell the " +
|
|
362
390
|
"user and offer to run `runtime` with action:'update'. The `project_config` tool's status also " +
|
|
363
391
|
"surfaces `update_available` and an `update_hint`.\n\n" +
|
|
364
|
-
"TYPICAL FLOW:
|
|
392
|
+
"TYPICAL FLOW: ASK which platform(s) to connect (X, Reddit, or both) -> `project_config` " +
|
|
393
|
+
"(connect the chosen platform(s) + scan the profile) -> `engagement_mode` (after the " +
|
|
365
394
|
"profile scan: personal-brand is ON by default, so ASK the user the ONE question — do they ALSO " +
|
|
366
395
|
"want to promote a product? — and call action:'set' with personal_brand:true and " +
|
|
367
396
|
"promotion:true|false; this provisions the persona) -> IF they wanted promotion, `project_config` " +
|
|
@@ -827,7 +856,7 @@ function readConfiguredTwitterHandle() {
|
|
|
827
856
|
if (env)
|
|
828
857
|
return env;
|
|
829
858
|
try {
|
|
830
|
-
const cfg = JSON.parse(fs.readFileSync(
|
|
859
|
+
const cfg = JSON.parse(fs.readFileSync(configPath(), "utf-8"));
|
|
831
860
|
const h = cfg?.accounts?.twitter?.handle;
|
|
832
861
|
const s = (typeof h === "string" ? h : "").trim().replace(/^@/, "");
|
|
833
862
|
return s || null;
|
|
@@ -982,6 +1011,27 @@ async function postApproved(batchId, plan) {
|
|
|
982
1011
|
plan = fresh;
|
|
983
1012
|
}
|
|
984
1013
|
}
|
|
1014
|
+
// Arm the cross-instance posting flag HERE, before the Reddit drain, not just
|
|
1015
|
+
// before the Twitter phase below. Root cause (found 2026-07-16): this flag
|
|
1016
|
+
// used to get set only right before ensureTwitterBrowserForPost(), AFTER the
|
|
1017
|
+
// whole Reddit drain loop above (one runPython("post_reddit.py") call per
|
|
1018
|
+
// approved Reddit card, each with real CDP/browser overhead — a batch of a
|
|
1019
|
+
// handful of cards routinely runs minutes). For that entire window this
|
|
1020
|
+
// instance's isPeerDrainActive() reported false, so a SECOND MCP instance's
|
|
1021
|
+
// postApproved() (e.g. a fresh one-shot worker boot running its own startup
|
|
1022
|
+
// backlog drain) could pass the SAME gate check above and start its own
|
|
1023
|
+
// drain concurrently. Both eventually reached their own Twitter phase and
|
|
1024
|
+
// both spawned scripts/twitter_post_plan.py with S4L_LOCK_ROLE=post — the
|
|
1025
|
+
// ONLY code path that ever sets that role. Two live "post" role holders
|
|
1026
|
+
// can't preempt each other (browser_mutex.py case 6: correct, preempting a
|
|
1027
|
+
// peer mid-post risks a double-post), so one always waited 45s and gave up
|
|
1028
|
+
// — the "Twitter browser locked by session python:<pid> ... waited 45s,
|
|
1029
|
+
// giving up" failures that silently killed 11 real approved drafts over
|
|
1030
|
+
// 2026-07-15/16. Arming the flag for the FULL function closes that window:
|
|
1031
|
+
// a peer's gate check now blocks for the entire drain, Reddit included, so
|
|
1032
|
+
// only one instance ever reaches the Twitter phase at a time.
|
|
1033
|
+
postingActive = true;
|
|
1034
|
+
startPostingFlagHeartbeat();
|
|
985
1035
|
// Post every card the user APPROVED that hasn't already landed or been ruled out.
|
|
986
1036
|
// `approved` is now a DURABLE decision (sticky, never cleared by a later call), so
|
|
987
1037
|
// filtering out posted/terminal here makes this idempotent: re-running it only
|
|
@@ -1000,8 +1050,11 @@ async function postApproved(batchId, plan) {
|
|
|
1000
1050
|
delete c.discard_reason;
|
|
1001
1051
|
}
|
|
1002
1052
|
}
|
|
1003
|
-
if (approved.length === 0)
|
|
1053
|
+
if (approved.length === 0) {
|
|
1054
|
+
postingActive = false;
|
|
1055
|
+
stopPostingFlagHeartbeat();
|
|
1004
1056
|
return { attempted: 0, exit_code: 0, summary: "nothing approved" };
|
|
1057
|
+
}
|
|
1005
1058
|
// ---- Reddit cards (2026-07-14): drain them FIRST, independently ----------
|
|
1006
1059
|
// A reddit card carries the verbatim draft decision (reddit_decision) plus
|
|
1007
1060
|
// plan metadata (reddit_plan_meta), so approval reconstructs a one-decision
|
|
@@ -1014,84 +1067,179 @@ async function postApproved(batchId, plan) {
|
|
|
1014
1067
|
const approvedTwitter = approved.filter((c) => c.platform !== "reddit");
|
|
1015
1068
|
let redditPosted = 0;
|
|
1016
1069
|
let redditFailed = 0;
|
|
1070
|
+
let redditSkippedPeerDrain = false;
|
|
1017
1071
|
if (approvedReddit.length) {
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
session_id: meta.session_id || meta.draft_session_id,
|
|
1036
|
-
};
|
|
1037
|
-
const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
|
|
1038
|
-
let r;
|
|
1072
|
+
// Cross-instance drain gate (2026-07-14 drain storm): every one-shot MCP
|
|
1073
|
+
// boot (the queue worker fires ~every minute; each boot runs the startup
|
|
1074
|
+
// backlog drain) drained the SAME sticky approvals concurrently — 26
|
|
1075
|
+
// drain plans in ~30 minutes, all fighting over the one reddit tab and
|
|
1076
|
+
// false-positiving each other's posts. reddit-posting-active.json is the
|
|
1077
|
+
// arbiter: post_reddit.py heartbeats it per row, and we stamp it around
|
|
1078
|
+
// the whole drain here. A fresh flag (<120s) means a peer is mid-drain:
|
|
1079
|
+
// leave the cards for it — approvals are sticky, nothing is lost.
|
|
1080
|
+
const redditFlagPath = path.join(s4lStateDir(), "reddit-posting-active.json");
|
|
1081
|
+
let redditPeerFresh = false;
|
|
1082
|
+
try {
|
|
1083
|
+
redditPeerFresh = Date.now() - fs.statSync(redditFlagPath).mtimeMs < 120_000;
|
|
1084
|
+
}
|
|
1085
|
+
catch {
|
|
1086
|
+
redditPeerFresh = false;
|
|
1087
|
+
}
|
|
1088
|
+
const stampRedditFlag = () => {
|
|
1039
1089
|
try {
|
|
1040
|
-
fs.writeFileSync(
|
|
1041
|
-
r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
|
|
1042
|
-
timeoutMs: 900_000,
|
|
1043
|
-
env: {
|
|
1044
|
-
REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
|
|
1045
|
-
// Reviewed posts never get the active-campaign suffix (same rule
|
|
1046
|
-
// as the twitter manual-approval path below).
|
|
1047
|
-
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1048
|
-
},
|
|
1049
|
-
onLine: (line) => {
|
|
1050
|
-
const t = line.replace(/\s+$/, "");
|
|
1051
|
-
if (t.trim())
|
|
1052
|
-
console.error(`[post-reddit] ${t}`);
|
|
1053
|
-
},
|
|
1054
|
-
});
|
|
1090
|
+
fs.writeFileSync(redditFlagPath, JSON.stringify({ pid: process.pid, hb: Math.floor(Date.now() / 1000) }));
|
|
1055
1091
|
}
|
|
1056
|
-
catch
|
|
1057
|
-
|
|
1092
|
+
catch {
|
|
1093
|
+
/* best effort */
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
if (redditPeerFresh) {
|
|
1097
|
+
redditSkippedPeerDrain = true;
|
|
1098
|
+
logPostEvent(`postApproved_reddit_skip batch=${batchId} reason=peer_drain_active cards=${approvedReddit.length}`);
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
stampRedditFlag();
|
|
1102
|
+
try {
|
|
1103
|
+
for (const c of approvedReddit) {
|
|
1104
|
+
stampRedditFlag();
|
|
1105
|
+
const cc = c;
|
|
1106
|
+
const dec = cc.reddit_decision;
|
|
1107
|
+
const meta = (cc.reddit_plan_meta || {});
|
|
1108
|
+
if (!dec) {
|
|
1109
|
+
c.terminal = true;
|
|
1110
|
+
c.terminal_reason = "reddit_decision_missing";
|
|
1111
|
+
redditFailed++;
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
// The card's reply_text is the CANONICAL text (post_drafts edits write
|
|
1115
|
+
// there); the embedded decision still carries the draft-time original.
|
|
1116
|
+
// Posting dec.text verbatim would silently discard a human edit.
|
|
1117
|
+
const decPost = {
|
|
1118
|
+
...dec,
|
|
1119
|
+
text: (typeof cc.reply_text === "string" && cc.reply_text.trim())
|
|
1120
|
+
? cc.reply_text
|
|
1121
|
+
: dec.text,
|
|
1122
|
+
};
|
|
1123
|
+
if (cc.engagement_style)
|
|
1124
|
+
decPost.engagement_style = cc.engagement_style;
|
|
1125
|
+
// Two-draft cards (2026-07-15): a human draft-switch (see the generic
|
|
1126
|
+
// edit-handling above, `if (e.variant && Array.isArray(c.drafts))`)
|
|
1127
|
+
// stamps the CHOSEN draft's assigned_style/assigned_mode onto the card
|
|
1128
|
+
// (cc). Forward both into decPost so post_reddit.py's _post_iteration
|
|
1129
|
+
// validates/logs against whichever draft is ACTUALLY posting, not
|
|
1130
|
+
// whichever was recommended at plan-write time — mirrors
|
|
1131
|
+
// twitter_post_plan.py's identical per-candidate override.
|
|
1132
|
+
if (cc.assigned_style !== undefined)
|
|
1133
|
+
decPost.assigned_style = cc.assigned_style;
|
|
1134
|
+
if (cc.assigned_mode !== undefined)
|
|
1135
|
+
decPost.assigned_mode = cc.assigned_mode;
|
|
1136
|
+
const miniPlan = {
|
|
1137
|
+
project_name: meta.project_name || cc.matched_project,
|
|
1138
|
+
batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
|
|
1139
|
+
phase: "draft",
|
|
1140
|
+
decisions: [decPost],
|
|
1141
|
+
style_assignment: meta.style_assignment || {},
|
|
1142
|
+
generation_trace_path: meta.generation_trace_path,
|
|
1143
|
+
session_id: meta.session_id || meta.draft_session_id,
|
|
1144
|
+
};
|
|
1145
|
+
const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
|
|
1146
|
+
let r;
|
|
1147
|
+
try {
|
|
1148
|
+
fs.writeFileSync(tmpPlan, JSON.stringify(miniPlan));
|
|
1149
|
+
r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
|
|
1150
|
+
timeoutMs: 900_000,
|
|
1151
|
+
env: {
|
|
1152
|
+
REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
|
|
1153
|
+
// Reviewed posts never get the active-campaign suffix (same rule
|
|
1154
|
+
// as the twitter manual-approval path below).
|
|
1155
|
+
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1156
|
+
},
|
|
1157
|
+
onLine: (line) => {
|
|
1158
|
+
const t = line.replace(/\s+$/, "");
|
|
1159
|
+
if (t.trim())
|
|
1160
|
+
console.error(`[post-reddit] ${t}`);
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
catch (err) {
|
|
1165
|
+
r = { code: -1, stdout: "", stderr: String(err) };
|
|
1166
|
+
}
|
|
1167
|
+
finally {
|
|
1168
|
+
try {
|
|
1169
|
+
fs.unlinkSync(tmpPlan);
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
/* best effort */
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
// post_reddit.py's summary marker: `[post_reddit] phase=post ... posted=N failed=M`
|
|
1176
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
1177
|
+
const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
|
|
1178
|
+
if (r.code === 0 && postedN > 0) {
|
|
1179
|
+
c.posted = true;
|
|
1180
|
+
c.terminal = false;
|
|
1181
|
+
const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
|
|
1182
|
+
if (urlMatch)
|
|
1183
|
+
c.our_url = urlMatch[1];
|
|
1184
|
+
redditPosted++;
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
// Leave the approval sticky (approved && !posted && !terminal) so the
|
|
1188
|
+
// next post_drafts call retries, mirroring twitter's failed-drain
|
|
1189
|
+
// semantics; only stamp terminal on a conclusive CDP refusal. This
|
|
1190
|
+
// list MUST stay a superset of post_reddit.py's own
|
|
1191
|
+
// _PERMANENT_CDP_ERRORS (account_blocked_in_sub, no_permalink) —
|
|
1192
|
+
// otherwise post_reddit.py already gives up on the row (marks the DB
|
|
1193
|
+
// candidate permanently failed, e.g. adds the sub to
|
|
1194
|
+
// subreddit_bans.comment_blocked) while this card stays non-terminal
|
|
1195
|
+
// and the drain retries the same doomed post forever (found
|
|
1196
|
+
// 2026-07-16: an approved r/tifu card retried every cycle for over
|
|
1197
|
+
// an hour after the sub was already in subreddit_bans.comment_blocked).
|
|
1198
|
+
const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
|
|
1199
|
+
if (cdpReason &&
|
|
1200
|
+
[
|
|
1201
|
+
"thread_locked",
|
|
1202
|
+
"thread_archived",
|
|
1203
|
+
"thread_not_found",
|
|
1204
|
+
"blocked_by_author",
|
|
1205
|
+
"account_blocked_in_sub",
|
|
1206
|
+
"no_permalink",
|
|
1207
|
+
].includes(cdpReason)) {
|
|
1208
|
+
c.terminal = true;
|
|
1209
|
+
c.terminal_reason = `reddit_${cdpReason}`;
|
|
1210
|
+
}
|
|
1211
|
+
redditFailed++;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1058
1214
|
}
|
|
1059
1215
|
finally {
|
|
1216
|
+
// Clear only OUR stamp; a peer that took over mid-drain keeps its own.
|
|
1060
1217
|
try {
|
|
1061
|
-
fs.
|
|
1218
|
+
const cur = JSON.parse(fs.readFileSync(redditFlagPath, "utf-8"));
|
|
1219
|
+
if (cur && cur.pid === process.pid)
|
|
1220
|
+
fs.unlinkSync(redditFlagPath);
|
|
1062
1221
|
}
|
|
1063
1222
|
catch {
|
|
1064
1223
|
/* best effort */
|
|
1065
1224
|
}
|
|
1066
1225
|
}
|
|
1067
|
-
|
|
1068
|
-
const out = `${r.stdout}\n${r.stderr}`;
|
|
1069
|
-
const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
|
|
1070
|
-
if (r.code === 0 && postedN > 0) {
|
|
1071
|
-
c.posted = true;
|
|
1072
|
-
c.terminal = false;
|
|
1073
|
-
const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
|
|
1074
|
-
if (urlMatch)
|
|
1075
|
-
c.our_url = urlMatch[1];
|
|
1076
|
-
redditPosted++;
|
|
1077
|
-
}
|
|
1078
|
-
else {
|
|
1079
|
-
// Leave the approval sticky (approved && !posted && !terminal) so the
|
|
1080
|
-
// next post_drafts call retries, mirroring twitter's failed-drain
|
|
1081
|
-
// semantics; only stamp terminal on a conclusive CDP refusal.
|
|
1082
|
-
const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
|
|
1083
|
-
if (cdpReason && ["thread_locked", "thread_archived", "thread_not_found", "blocked_by_author"].includes(cdpReason)) {
|
|
1084
|
-
c.terminal = true;
|
|
1085
|
-
c.terminal_reason = `reddit_${cdpReason}`;
|
|
1086
|
-
}
|
|
1087
|
-
redditFailed++;
|
|
1088
|
-
}
|
|
1226
|
+
logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
|
|
1089
1227
|
}
|
|
1090
|
-
logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
|
|
1091
1228
|
}
|
|
1092
1229
|
if (approvedTwitter.length === 0) {
|
|
1093
1230
|
// All-reddit batch: persist the stamps and return without touching the
|
|
1094
|
-
// twitter preflight/lock path.
|
|
1231
|
+
// twitter preflight/lock path. Must clear the flag armed above ourselves —
|
|
1232
|
+
// this branch never reaches the Twitter phase's scheduleShellLockRelease().
|
|
1233
|
+
postingActive = false;
|
|
1234
|
+
stopPostingFlagHeartbeat();
|
|
1235
|
+
if (redditSkippedPeerDrain) {
|
|
1236
|
+
return {
|
|
1237
|
+
attempted: 0,
|
|
1238
|
+
posted: 0,
|
|
1239
|
+
exit_code: 0,
|
|
1240
|
+
summary: "reddit: skipped (peer drain active); cards stay approved for the next drain",
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1095
1243
|
if (approvedReddit.length)
|
|
1096
1244
|
mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1097
1245
|
return {
|
|
@@ -1109,6 +1257,8 @@ async function postApproved(batchId, plan) {
|
|
|
1109
1257
|
if (!readConfiguredTwitterHandle())
|
|
1110
1258
|
await ensurePostingHandle();
|
|
1111
1259
|
if (!readConfiguredTwitterHandle()) {
|
|
1260
|
+
postingActive = false;
|
|
1261
|
+
stopPostingFlagHeartbeat();
|
|
1112
1262
|
return {
|
|
1113
1263
|
attempted: 0,
|
|
1114
1264
|
exit_code: 0,
|
|
@@ -1309,11 +1459,31 @@ async function postApproved(batchId, plan) {
|
|
|
1309
1459
|
c.our_url = r.our_url;
|
|
1310
1460
|
touchedPlan = true;
|
|
1311
1461
|
}
|
|
1312
|
-
else if (r.outcome === "skipped"
|
|
1462
|
+
else if (r.outcome === "skipped") {
|
|
1463
|
+
// twitter_post_plan.py only returns "skipped" for reasons it has
|
|
1464
|
+
// already decided are conclusive (reply_restricted, tweet_unavailable,
|
|
1465
|
+
// blocked_by_author, tweet_not_found, rate_limited, reply_box_not_found
|
|
1466
|
+
// — see its own PERMANENT/skip comment) or a captured-URL edge case
|
|
1467
|
+
// it explicitly doesn't want re-attempted (duplicate risk). Terminal
|
|
1468
|
+
// is correct here.
|
|
1313
1469
|
c.terminal = true;
|
|
1314
1470
|
c.terminal_reason = r.reason || r.outcome;
|
|
1315
1471
|
touchedPlan = true;
|
|
1316
1472
|
}
|
|
1473
|
+
else if (r.outcome === "failed") {
|
|
1474
|
+
// "failed" is twitter_post_plan.py's catch-all for everything it did
|
|
1475
|
+
// NOT classify as conclusive — timeouts, parse errors, exceptions,
|
|
1476
|
+
// and browser_mutex contention ("Twitter browser locked by session
|
|
1477
|
+
// ... waited 45s, giving up."). None of those mean the draft is
|
|
1478
|
+
// doomed, only that this attempt didn't get a turn with the shared
|
|
1479
|
+
// browser. Leave the card sticky (approved && !posted && !terminal)
|
|
1480
|
+
// so the next post_drafts call retries it, mirroring Reddit's
|
|
1481
|
+
// _TRANSIENT_CDP_ERRORS handling. Unconditionally marking terminal
|
|
1482
|
+
// here (found 2026-07-16) silently and permanently discarded 7 real
|
|
1483
|
+
// approved drafts on nothing worse than lock contention — Reddit's
|
|
1484
|
+
// equivalent transient failures self-healed on the very next drain.
|
|
1485
|
+
touchedPlan = true;
|
|
1486
|
+
}
|
|
1317
1487
|
});
|
|
1318
1488
|
}
|
|
1319
1489
|
else if (realPosted > 0 || (res.code === 0 && !summObj)) {
|
|
@@ -1854,12 +2024,16 @@ tool("project_config", {
|
|
|
1854
2024
|
"products (call once per product, identified by name); fill or edit a project's fields " +
|
|
1855
2025
|
"INCREMENTALLY across several calls — pass whatever you have, it merges and tells you what's " +
|
|
1856
2026
|
"still missing.\n" +
|
|
1857
|
-
"2) Connect X/Twitter (action:'connect_x')
|
|
2027
|
+
"2) Connect X/Twitter (action:'connect_x'), ONLY once the user has told you they want X connected " +
|
|
2028
|
+
"(the top-level setup flow asks which platform(s) — X, Reddit, or both — before this tool is " +
|
|
2029
|
+
"called for either): the autoposter posts through its OWN managed Chrome, " +
|
|
1858
2030
|
"which needs your logged-in x.com session. This imports x.com/twitter.com cookies from your " +
|
|
1859
2031
|
"everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into that browser — nothing else is " +
|
|
1860
|
-
"touched.
|
|
1861
|
-
"Storage prompts may appear, then call action:'connect_x', confirm:true immediately
|
|
1862
|
-
"
|
|
2032
|
+
"touched. Once the user has said they want X, briefly warn that macOS Safe " +
|
|
2033
|
+
"Storage prompts may appear, then call action:'connect_x', confirm:true immediately — no further " +
|
|
2034
|
+
"round-trip needed for THIS decision, it was already made. Use " +
|
|
2035
|
+
"action:'detect_x_sources' first and choose its recommendation instead of asking the user which " +
|
|
2036
|
+
"browser profile to import from.\n" +
|
|
1863
2037
|
"Call with status:true (or no name) to list every configured project, its remaining fields, AND " +
|
|
1864
2038
|
"whether X is connected. Use config, conversation context, profile_scan, and website research " +
|
|
1865
2039
|
"before asking for fields. Ask only if no product can be identified or an interactive login is " +
|
|
@@ -1868,7 +2042,14 @@ tool("project_config", {
|
|
|
1868
2042
|
inputSchema: {
|
|
1869
2043
|
status: z.boolean().optional(),
|
|
1870
2044
|
action: z
|
|
1871
|
-
.enum([
|
|
2045
|
+
.enum([
|
|
2046
|
+
"get",
|
|
2047
|
+
"connect_x",
|
|
2048
|
+
"detect_x_sources",
|
|
2049
|
+
"profile_scan",
|
|
2050
|
+
"connect_reddit",
|
|
2051
|
+
"detect_reddit_sources",
|
|
2052
|
+
])
|
|
1872
2053
|
.optional()
|
|
1873
2054
|
.describe("get = read the CURRENT SAVED VALUES of every project's editable fields (website, " +
|
|
1874
2055
|
"description, icp, voice, differentiator, search_topics, get_started_link, " +
|
|
@@ -1886,7 +2067,14 @@ tool("project_config", {
|
|
|
1886
2067
|
"replies to build a 'grounding truth' corpus. Use it to draft voice/icp/search_topics in " +
|
|
1887
2068
|
"the USER'S OWN register (their phrases, vibe, profession), then save a conservative best " +
|
|
1888
2069
|
"draft without requiring a confirmation round-trip. Returns {profile, posts, comments, " +
|
|
1889
|
-
"grounding_instructions}."
|
|
2070
|
+
"grounding_instructions}. " +
|
|
2071
|
+
"connect_reddit = call ONLY once the user has said they want Reddit connected (setup " +
|
|
2072
|
+
"completes with X-only, Reddit-only, or both — whichever they picked when asked up front; " +
|
|
2073
|
+
"if X connection fails after they chose it, offer Reddit as a fallback): import/validate " +
|
|
2074
|
+
"the user's reddit.com session in the " +
|
|
2075
|
+
"autoposter's managed reddit browser, same confirm-first shape as connect_x. " +
|
|
2076
|
+
"detect_reddit_sources = list the browsers/profiles the Reddit session can be imported " +
|
|
2077
|
+
"from (read-only, no keychain prompt)."),
|
|
1890
2078
|
confirm: z
|
|
1891
2079
|
.boolean()
|
|
1892
2080
|
.optional()
|
|
@@ -1903,6 +2091,17 @@ tool("project_config", {
|
|
|
1903
2091
|
"X login window and waits for them to log in. By default (false), connect_x does NOT pop a " +
|
|
1904
2092
|
"browser window on an auto-import miss; it returns needs_login and you offer manual login as " +
|
|
1905
2093
|
"an opt-in. The login window still opens automatically if the user DENIED the keychain prompt."),
|
|
2094
|
+
reddit_source: z
|
|
2095
|
+
.string()
|
|
2096
|
+
.optional()
|
|
2097
|
+
.describe("Optional browser profile to import the Reddit session from, e.g. 'arc:Default', " +
|
|
2098
|
+
"'chrome:Profile 1'. Default: auto-detect the browser that holds a reddit.com session."),
|
|
2099
|
+
reddit_manual_login: z
|
|
2100
|
+
.boolean()
|
|
2101
|
+
.optional()
|
|
2102
|
+
.describe("Set true ONLY when the user explicitly wants to sign into Reddit by hand. Opens a focused " +
|
|
2103
|
+
"Reddit login window in the autoposter's reddit browser and waits. Same opt-in discipline " +
|
|
2104
|
+
"as x_manual_login."),
|
|
1906
2105
|
name: z
|
|
1907
2106
|
.string()
|
|
1908
2107
|
.optional()
|
|
@@ -2072,6 +2271,130 @@ tool("project_config", {
|
|
|
2072
2271
|
: "X is not connected yet. " + summarizeXAuth(r),
|
|
2073
2272
|
});
|
|
2074
2273
|
}
|
|
2274
|
+
// ---- List Reddit import sources (read-only, no keychain prompt) --------
|
|
2275
|
+
if (args.action === "detect_reddit_sources") {
|
|
2276
|
+
const r = await redditDetectSources();
|
|
2277
|
+
return jsonContent({
|
|
2278
|
+
action: "detect_reddit_sources",
|
|
2279
|
+
ok: r.ok,
|
|
2280
|
+
sources: r.sources,
|
|
2281
|
+
recommended: r.recommended,
|
|
2282
|
+
error: r.error,
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
// ---- Connect Reddit: OPTIONAL platform, mirrors connect_x --------------
|
|
2286
|
+
// Preview-or-run, same confirm-first shape as connect_x. Reddit is never
|
|
2287
|
+
// required for onboarding completion; offer it only after X setup is done
|
|
2288
|
+
// and the user wants it.
|
|
2289
|
+
if (args.action === "connect_reddit") {
|
|
2290
|
+
if (args.confirm !== true) {
|
|
2291
|
+
// Cheap probe so the explanation reflects current state (no Chrome launch).
|
|
2292
|
+
const cur = await redditStatus();
|
|
2293
|
+
if (cur.connected) {
|
|
2294
|
+
return jsonContent({
|
|
2295
|
+
action: "connect_reddit",
|
|
2296
|
+
already_connected: true,
|
|
2297
|
+
state: cur.state,
|
|
2298
|
+
username: cur.username,
|
|
2299
|
+
note: "Reddit is already connected in the autoposter's reddit browser. Nothing to import.",
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
return jsonContent({
|
|
2303
|
+
action: "connect_reddit",
|
|
2304
|
+
requires_confirmation: true,
|
|
2305
|
+
current_state: cur.state,
|
|
2306
|
+
what_will_happen: "To engage on Reddit for you, the autoposter uses its OWN managed Google Chrome (separate " +
|
|
2307
|
+
"from your everyday browser and from the X one). It needs your logged-in reddit.com " +
|
|
2308
|
+
"session. If you confirm, it will: (1) start that managed reddit browser if it isn't " +
|
|
2309
|
+
"running, (2) copy ONLY your reddit.com cookies (reddit_session, token_v2) from your " +
|
|
2310
|
+
"everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into it, and (3) verify you're " +
|
|
2311
|
+
"logged in via Reddit's own me.json. No other site's cookies are read, and your passwords " +
|
|
2312
|
+
"are never seen. If it can't import a valid session, a Chrome window will open for you to " +
|
|
2313
|
+
"sign in once.",
|
|
2314
|
+
keychain_prompt: "Reading the saved session requires macOS to unlock the browser's encrypted cookie store, so " +
|
|
2315
|
+
"one or more keychain prompts will appear (“... wants to use your confidential information " +
|
|
2316
|
+
"stored in '... Safe Storage' in your keychain”). This is expected. The user enters their Mac " +
|
|
2317
|
+
"login password and clicks Allow (or Always Allow to avoid repeats). If they use more than one " +
|
|
2318
|
+
"browser, the prompt can appear a few times, once per browser.",
|
|
2319
|
+
say_to_user: "Heads up: your Mac will pop up a keychain prompt asking to use your browser's Safe Storage. " +
|
|
2320
|
+
"That's just us reading your saved Reddit login, nothing else. Type your Mac login password and " +
|
|
2321
|
+
"click Allow (or Always Allow). If you use more than one browser you may see it a couple of " +
|
|
2322
|
+
"times, once per browser.",
|
|
2323
|
+
how_to_proceed: "If the user explicitly requested connecting Reddit, relay the say_to_user line as a brief " +
|
|
2324
|
+
"heads-up and immediately call project_config again with action:'connect_reddit', confirm:true; " +
|
|
2325
|
+
"do not wait for another yes/no reply. Optionally pass a reddit_source from " +
|
|
2326
|
+
"detect_reddit_sources. If the user only asked what connection would do, stop after this " +
|
|
2327
|
+
"preview. Reddit is OPTIONAL: never treat it as required for setup completion.",
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
recordOnboardingAttempt("reddit_connected", {
|
|
2331
|
+
state: args.reddit_source ? "source_selected" : "auto_detect",
|
|
2332
|
+
});
|
|
2333
|
+
const r = await redditConnect(args.reddit_source, args.reddit_manual_login);
|
|
2334
|
+
let verified = false;
|
|
2335
|
+
let configNote;
|
|
2336
|
+
if (r.connected) {
|
|
2337
|
+
completeOnboardingMilestone("reddit_connected", { state: r.state });
|
|
2338
|
+
// Persist the discovered username through the server's config-write
|
|
2339
|
+
// path (accounts.reddit.username, the field account_resolver reads),
|
|
2340
|
+
// never overwriting a deliberately-set value.
|
|
2341
|
+
if (r.username) {
|
|
2342
|
+
const w = recordRedditAccount(r.username);
|
|
2343
|
+
configNote = w.detail;
|
|
2344
|
+
}
|
|
2345
|
+
// Doctor-style persistence re-check before reddit_verified: a fresh
|
|
2346
|
+
// read-only status probe confirms the session actually persisted
|
|
2347
|
+
// (live me.json or the on-disk profile cookie row) rather than
|
|
2348
|
+
// trusting the connect call's own success.
|
|
2349
|
+
const recheck = await redditStatus();
|
|
2350
|
+
if (recheck.connected) {
|
|
2351
|
+
completeOnboardingMilestone("reddit_verified", { state: recheck.state });
|
|
2352
|
+
verified = true;
|
|
2353
|
+
}
|
|
2354
|
+
else {
|
|
2355
|
+
recordOnboardingAttempt("reddit_verified", {
|
|
2356
|
+
state: recheck.state || "unverified",
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
// Reddit is live: install the discovery kicker (idempotent; gated on
|
|
2360
|
+
// runtime + project readiness inside, and it never touches a
|
|
2361
|
+
// hand-built plist pointing outside the managed package).
|
|
2362
|
+
void ensureRedditKickerInstalled()
|
|
2363
|
+
.then((res) => console.error(`[reddit-kicker] post-connect install: ${res.ok ? "ok" : "skip"} (${res.detail})`))
|
|
2364
|
+
.catch((e) => console.error("[reddit-kicker] post-connect install failed:", e?.message || e));
|
|
2365
|
+
}
|
|
2366
|
+
else {
|
|
2367
|
+
blockOnboardingMilestone("reddit_connected", `reddit_${r.state || "not_connected"}`, r.error || r.note || summarizeRedditAuth(r), { state: r.state || "not_connected" });
|
|
2368
|
+
}
|
|
2369
|
+
return jsonContent({
|
|
2370
|
+
action: "connect_reddit",
|
|
2371
|
+
connected: r.connected,
|
|
2372
|
+
state: r.state,
|
|
2373
|
+
username: r.username,
|
|
2374
|
+
account_age_days: r.account_age_days,
|
|
2375
|
+
comment_karma: r.comment_karma,
|
|
2376
|
+
// Ready-to-relay expectation setter for fresh/low-karma accounts
|
|
2377
|
+
// (AutoMod gates those in most subreddits). Advisory only; never a
|
|
2378
|
+
// reason to block or undo the connect.
|
|
2379
|
+
warning: r.warning,
|
|
2380
|
+
verified,
|
|
2381
|
+
config: configNote,
|
|
2382
|
+
summary: summarizeRedditAuth(r),
|
|
2383
|
+
note: r.note,
|
|
2384
|
+
attempts: r.attempts,
|
|
2385
|
+
onboarding: onboardingSnapshot(),
|
|
2386
|
+
next_step: r.connected
|
|
2387
|
+
? "Reddit is connected. The reddit discovery job installs itself once a project is " +
|
|
2388
|
+
"configured; drafts flow into the same review cards as X and nothing posts without " +
|
|
2389
|
+
"approval." +
|
|
2390
|
+
(r.warning ? " Relay the `warning` field to the user so expectations are set." : "")
|
|
2391
|
+
: r.state === "needs_login"
|
|
2392
|
+
? "The user must finish signing in to reddit.com in the Chrome window that opened. Tell " +
|
|
2393
|
+
"them that single required action, then call project_config action:'connect_reddit', " +
|
|
2394
|
+
"confirm:true again."
|
|
2395
|
+
: "Reddit is not connected yet. " + summarizeRedditAuth(r),
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2075
2398
|
// ---- Profile scan: grounding-truth corpus from the connected account ----
|
|
2076
2399
|
// Reuses the authenticated managed-Chrome session (so it must run AFTER a
|
|
2077
2400
|
// successful connect_x) to read the user's bio + recent posts + recent
|
|
@@ -2177,6 +2500,9 @@ tool("project_config", {
|
|
|
2177
2500
|
const projects = Array.isArray(snap.projects) ? snap.projects : [];
|
|
2178
2501
|
const rtReady = !!snap.runtime_ready;
|
|
2179
2502
|
const xConnected = !!snap.x_connected;
|
|
2503
|
+
const redditConnected = !!snap.reddit_connected;
|
|
2504
|
+
// Any-of platform completion: X or Reddit satisfies the platform leg.
|
|
2505
|
+
const anyPlatform = xConnected || redditConnected;
|
|
2180
2506
|
const configured = (snap.projects_ready || 0) > 0;
|
|
2181
2507
|
return jsonContent({
|
|
2182
2508
|
configured,
|
|
@@ -2185,6 +2511,9 @@ tool("project_config", {
|
|
|
2185
2511
|
x_connected: xConnected,
|
|
2186
2512
|
x_state: snap.x_state,
|
|
2187
2513
|
x_handle: snap.x_handle ?? null,
|
|
2514
|
+
reddit_connected: redditConnected,
|
|
2515
|
+
reddit_state: snap.reddit_state,
|
|
2516
|
+
reddit_username: snap.reddit_username ?? null,
|
|
2188
2517
|
setup_complete: !!snap.setup_complete,
|
|
2189
2518
|
mcp_version: snap.version,
|
|
2190
2519
|
latest_version: snap.latest_version,
|
|
@@ -2206,14 +2535,15 @@ tool("project_config", {
|
|
|
2206
2535
|
? "Runtime is not ready yet. It provisions automatically on boot — poll runtime action:'status' until ready (only call runtime action:'install' to retry if status shows the boot provision failed or stalled), then continue setup automatically."
|
|
2207
2536
|
: projects.length === 0
|
|
2208
2537
|
? "No projects yet. Discover the product from conversation context and the connected X profile; research its website, infer a conservative complete project, and call project_config. Ask only if no product can be identified." +
|
|
2209
|
-
(
|
|
2538
|
+
(anyPlatform ? "" : " No platform is connected yet either. X is the default: detect_x_sources, warn about keychain prompts, then run connect_x with confirm:true without a separate permission turn. If the user prefers Reddit (or X can't be connected), connect_reddit works standalone.")
|
|
2210
2539
|
: projects.every((p) => p.ready)
|
|
2211
|
-
? (
|
|
2212
|
-
? "All configured projects are ready and
|
|
2213
|
-
|
|
2214
|
-
"
|
|
2540
|
+
? (anyPlatform
|
|
2541
|
+
? "All configured projects are ready and a platform is connected" +
|
|
2542
|
+
(xConnected ? "" : " (Reddit; X was skipped, which is fine)") +
|
|
2543
|
+
". SCHEDULE THE AUTOPILOT: (1) call queue_setup and create each returned task with create_scheduled_task (prompt and notifyOnCompletion verbatim — notifyOnCompletion must be false; 'already exists' is fine, but then call update_scheduled_task with notifyOnCompletion:false to silence per-run notifications); (2) the autopilot then runs on its own (launchd kicker + queue worker). Call the `dashboard` tool to confirm the schedule is firing (schedule_state 'ok') — that is the terminal step; do NOT wait for or verify a draft card. Do NOT pause to ask the user to review drafts."
|
|
2544
|
+
: "All configured projects are ready, but NO platform is connected yet; posting needs a logged-in session. X is the default: detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed. If the user prefers Reddit or X can't be connected, run action:'connect_reddit' instead; ONE connected platform completes setup.")
|
|
2215
2545
|
: "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." +
|
|
2216
|
-
(
|
|
2546
|
+
(anyPlatform ? "" : " No platform is connected yet either; X is the default (connect_x with confirm:true), or connect_reddit if the user prefers Reddit."),
|
|
2217
2547
|
});
|
|
2218
2548
|
}
|
|
2219
2549
|
// Apply mode (incremental): merge whatever fields were supplied onto the
|
|
@@ -2562,7 +2892,22 @@ tool("post_drafts", {
|
|
|
2562
2892
|
warnings,
|
|
2563
2893
|
});
|
|
2564
2894
|
}
|
|
2565
|
-
|
|
2895
|
+
// postApproved now arms the cross-instance posting flag before the Reddit
|
|
2896
|
+
// drain (see its own comment), not just before the Twitter phase. Every
|
|
2897
|
+
// op inside that drain is already individually best-effort try/caught, so
|
|
2898
|
+
// this is belt-and-suspenders: if something still throws past all of
|
|
2899
|
+
// that, clear the flag before it escapes rather than leave this MCP
|
|
2900
|
+
// instance's posting permanently gated behind a flag nothing will ever
|
|
2901
|
+
// reset.
|
|
2902
|
+
let result;
|
|
2903
|
+
try {
|
|
2904
|
+
result = await postApproved(batch_id, plan);
|
|
2905
|
+
}
|
|
2906
|
+
catch (e) {
|
|
2907
|
+
postingActive = false;
|
|
2908
|
+
stopPostingFlagHeartbeat();
|
|
2909
|
+
throw e;
|
|
2910
|
+
}
|
|
2566
2911
|
// Report the REAL posted count from the pipeline, not the approved count.
|
|
2567
2912
|
// A run can approve N yet land 0 (browser/session failure); reporting
|
|
2568
2913
|
// approve.size here told the agent "posted: N" on a total failure.
|
|
@@ -3748,6 +4093,101 @@ async function ensureQueueKickerInstalled() {
|
|
|
3748
4093
|
return { ok: false, detail: e?.message || String(e) };
|
|
3749
4094
|
}
|
|
3750
4095
|
}
|
|
4096
|
+
// ---- Reddit discovery kicker (optional platform) ----------------------------
|
|
4097
|
+
// Mirrors ensureQueueKickerInstalled for the reddit lane: a 15-minute launchd
|
|
4098
|
+
// job that runs skill/run-reddit-search-launchd.sh (the detach wrapper; the
|
|
4099
|
+
// real cycle honors the global draft-only flag via s4l_mode.py, and its
|
|
4100
|
+
// drafting turn rides the same s4l-worker queue as X). Gates:
|
|
4101
|
+
// - runtime ready
|
|
4102
|
+
// - reddit connected (connected OR connected_idle; the profile persists)
|
|
4103
|
+
// - a managed project is configured and ready
|
|
4104
|
+
// It is NEVER an onboarding requirement: installs that never connect reddit
|
|
4105
|
+
// simply never get this plist.
|
|
4106
|
+
//
|
|
4107
|
+
// OPERATOR-MAC SAFETY: a hand-built com.m13v.social-reddit-search plist
|
|
4108
|
+
// pointing at ~/social-autoposter (StartCalendarInterval, custom env) exists
|
|
4109
|
+
// on the dev box. If an existing plist's program path is not the managed
|
|
4110
|
+
// package's wrapper, we log and leave it completely alone: no rewrite, no
|
|
4111
|
+
// reload, no unload.
|
|
4112
|
+
async function ensureRedditKickerInstalled() {
|
|
4113
|
+
try {
|
|
4114
|
+
if (process.platform !== "darwin")
|
|
4115
|
+
return { ok: false, detail: "not macOS" };
|
|
4116
|
+
if (!runtimeReady())
|
|
4117
|
+
return { ok: false, detail: "runtime not ready" };
|
|
4118
|
+
const productReady = listManagedProjectStatus().some((p) => p.ready);
|
|
4119
|
+
if (!productReady)
|
|
4120
|
+
return { ok: false, detail: "no ready project yet" };
|
|
4121
|
+
// Reddit session gate. status is read-only (never launches Chrome): a live
|
|
4122
|
+
// me.json validation when the harness is up, the on-disk profile cookie
|
|
4123
|
+
// check (connected_idle) when it isn't.
|
|
4124
|
+
const rs = await redditStatus();
|
|
4125
|
+
if (!rs.connected) {
|
|
4126
|
+
return { ok: false, detail: `reddit not connected (${rs.state})` };
|
|
4127
|
+
}
|
|
4128
|
+
const managedWrapper = path.join(repoDir(), "skill", "run-reddit-search-launchd.sh");
|
|
4129
|
+
// Foreign-plist guard BEFORE any write: if a plist already exists and its
|
|
4130
|
+
// ProgramArguments do not point at the managed package's wrapper, it is a
|
|
4131
|
+
// hand-built / operator arrangement. Leave it untouched.
|
|
4132
|
+
let cur = null;
|
|
4133
|
+
try {
|
|
4134
|
+
cur = fs.readFileSync(REDDIT_SEARCH_PLIST, "utf-8");
|
|
4135
|
+
}
|
|
4136
|
+
catch {
|
|
4137
|
+
cur = null;
|
|
4138
|
+
}
|
|
4139
|
+
if (cur !== null && !cur.includes(`<string>${managedWrapper}</string>`)) {
|
|
4140
|
+
const msg = `existing ${REDDIT_SEARCH_LABEL} plist points outside the managed package; leaving it untouched`;
|
|
4141
|
+
console.error(`[reddit-kicker] ${msg}`);
|
|
4142
|
+
return { ok: true, detail: `skip: ${msg}` };
|
|
4143
|
+
}
|
|
4144
|
+
const logDir = path.join(repoDir(), "skill", "logs");
|
|
4145
|
+
try {
|
|
4146
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
4147
|
+
}
|
|
4148
|
+
catch {
|
|
4149
|
+
/* best-effort */
|
|
4150
|
+
}
|
|
4151
|
+
const xml = plistXml({
|
|
4152
|
+
label: REDDIT_SEARCH_LABEL,
|
|
4153
|
+
programArgs: ["bash", managedWrapper],
|
|
4154
|
+
intervalSecs: REDDIT_SEARCH_INTERVAL_SECS,
|
|
4155
|
+
runAtLoad: false, // never fire a discovery cycle the instant Claude launches
|
|
4156
|
+
stdoutLog: path.join(logDir, "launchd-reddit-search-stdout.log"),
|
|
4157
|
+
stderrLog: path.join(logDir, "launchd-reddit-search-stderr.log"),
|
|
4158
|
+
// S4L_REPO_DIR + S4L_PYTHON are baked by plistXml. State dir + CDP url
|
|
4159
|
+
// pin the wrapper's children to the managed state and the harness port.
|
|
4160
|
+
extraEnv: {
|
|
4161
|
+
S4L_STATE_DIR: s4lStateDir(),
|
|
4162
|
+
REDDIT_CDP_URL: REDDIT_CDP_URL_DEFAULT,
|
|
4163
|
+
},
|
|
4164
|
+
// The wrapper double-forks the real cycle into its own session already,
|
|
4165
|
+
// but keep launchd away from the harness Chrome all the same.
|
|
4166
|
+
abandonProcessGroup: true,
|
|
4167
|
+
});
|
|
4168
|
+
const uid = process.getuid ? process.getuid() : 0;
|
|
4169
|
+
if (cur === xml) {
|
|
4170
|
+
const res = await loadPlist(REDDIT_SEARCH_LABEL, REDDIT_SEARCH_PLIST, uid);
|
|
4171
|
+
return { ok: true, detail: `current (load rc=${res.code})` };
|
|
4172
|
+
}
|
|
4173
|
+
if (cur !== null) {
|
|
4174
|
+
await unloadPlist(REDDIT_SEARCH_LABEL, REDDIT_SEARCH_PLIST, uid);
|
|
4175
|
+
}
|
|
4176
|
+
fs.mkdirSync(path.dirname(REDDIT_SEARCH_PLIST), { recursive: true });
|
|
4177
|
+
fs.writeFileSync(REDDIT_SEARCH_PLIST, xml, "utf-8");
|
|
4178
|
+
const res = await loadPlist(REDDIT_SEARCH_LABEL, REDDIT_SEARCH_PLIST, uid);
|
|
4179
|
+
// No first-run kickstart on purpose: reddit discovery is a background
|
|
4180
|
+
// add-on, and the connect flow just finished using the harness browser.
|
|
4181
|
+
// The first StartInterval tick (≤15 min) is soon enough.
|
|
4182
|
+
return {
|
|
4183
|
+
ok: true,
|
|
4184
|
+
detail: cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`,
|
|
4185
|
+
};
|
|
4186
|
+
}
|
|
4187
|
+
catch (e) {
|
|
4188
|
+
return { ok: false, detail: e?.message || String(e) };
|
|
4189
|
+
}
|
|
4190
|
+
}
|
|
3751
4191
|
// ---- launchd reaper: kill leaked agent-mode claude worker sessions ----------
|
|
3752
4192
|
// Independent guardrail (NOT gated on a project being ready): the leak happens
|
|
3753
4193
|
// whenever the scheduled-task workers fire, and a no-leak run is a cheap no-op.
|
|
@@ -4235,6 +4675,13 @@ async function buildSnapshot() {
|
|
|
4235
4675
|
completeOnboardingMilestone("runtime_ready");
|
|
4236
4676
|
if (snap.x_connected)
|
|
4237
4677
|
completeOnboardingMilestone("x_connected", { state: snap.x_state || "connected" });
|
|
4678
|
+
// Reddit (optional platform) heal: a live connected reading on a LATER poll
|
|
4679
|
+
// is itself persistence evidence, so it may also complete reddit_verified
|
|
4680
|
+
// for an install whose post-connect verify probe transiently failed.
|
|
4681
|
+
if (snap.reddit_connected) {
|
|
4682
|
+
completeOnboardingMilestone("reddit_connected", { state: snap.reddit_state || "connected" });
|
|
4683
|
+
completeOnboardingMilestone("reddit_verified", { source: "live_snapshot" });
|
|
4684
|
+
}
|
|
4238
4685
|
if ((snap.projects_ready || 0) > 0)
|
|
4239
4686
|
completeOnboardingMilestone("project_ready", { missing_count: 0 });
|
|
4240
4687
|
if (snap.schedule_state === "ok")
|
|
@@ -5255,6 +5702,10 @@ async function drainApprovedBacklog() {
|
|
|
5255
5702
|
}
|
|
5256
5703
|
catch (e) {
|
|
5257
5704
|
console.error("[post] drainApprovedBacklog error:", e?.message || e);
|
|
5705
|
+
// Same reasoning as the other postApproved call site: don't let an
|
|
5706
|
+
// escaped exception leave the cross-instance posting flag stuck true.
|
|
5707
|
+
postingActive = false;
|
|
5708
|
+
stopPostingFlagHeartbeat();
|
|
5258
5709
|
}
|
|
5259
5710
|
}
|
|
5260
5711
|
async function main() {
|
|
@@ -5278,6 +5729,12 @@ async function main() {
|
|
|
5278
5729
|
// disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
|
|
5279
5730
|
// the version first materialized at install). Synchronous + best-effort.
|
|
5280
5731
|
ensurePipelineCurrent();
|
|
5732
|
+
// Config's canonical home is the state dir (2026-07-13): migrate a legacy
|
|
5733
|
+
// in-repo config.json up on first boot after update, keep the legacy path
|
|
5734
|
+
// working via symlink, and self-heal a missing/wrong link on every boot.
|
|
5735
|
+
// Must run AFTER ensurePipelineCurrent() so repoDir() points at the final
|
|
5736
|
+
// materialized repo.
|
|
5737
|
+
console.error(`[social-autoposter-mcp] config home: ${ensureConfigInStateDir()}`);
|
|
5281
5738
|
// Deterministically provision the owned runtime on boot: whenever it isn't
|
|
5282
5739
|
// ready (a fresh install, or one interrupted mid-way because a step failed or
|
|
5283
5740
|
// Claude/the host died mid-install) kick the full install in the background
|
|
@@ -5333,6 +5790,17 @@ async function main() {
|
|
|
5333
5790
|
else {
|
|
5334
5791
|
console.error("[queue-worker] launchd kicker: skip (paused)");
|
|
5335
5792
|
}
|
|
5793
|
+
// Reddit discovery kicker (optional platform): installs only when reddit is
|
|
5794
|
+
// connected AND a project is ready; a box that never connected reddit is a
|
|
5795
|
+
// cheap no-op skip. Paused-gated like the X kicker (it produces drafts).
|
|
5796
|
+
if (!isPaused()) {
|
|
5797
|
+
void ensureRedditKickerInstalled()
|
|
5798
|
+
.then((r) => console.error(`[reddit-kicker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5799
|
+
.catch((e) => console.error("[reddit-kicker] kicker install failed:", e?.message || e));
|
|
5800
|
+
}
|
|
5801
|
+
else {
|
|
5802
|
+
console.error("[reddit-kicker] launchd kicker: skip (paused)");
|
|
5803
|
+
}
|
|
5336
5804
|
// Self-healing reaper for the agent-mode session leak the queue autopilot
|
|
5337
5805
|
// produces (finished `claude` worker sessions Desktop never tears down). A
|
|
5338
5806
|
// standalone guardrail; install unconditionally so it caps memory even on a
|
|
@@ -5509,17 +5977,22 @@ async function main() {
|
|
|
5509
5977
|
void sendStateSnapshot("startup");
|
|
5510
5978
|
const ss = setInterval(() => void sendStateSnapshot("interval"), 15 * 60_000);
|
|
5511
5979
|
ss.unref();
|
|
5512
|
-
//
|
|
5513
|
-
//
|
|
5514
|
-
// the connected X profile and
|
|
5515
|
-
//
|
|
5516
|
-
// (
|
|
5517
|
-
//
|
|
5518
|
-
//
|
|
5519
|
-
//
|
|
5520
|
-
//
|
|
5521
|
-
//
|
|
5522
|
-
//
|
|
5980
|
+
// Voice-exemplar catch-up + periodic refresh, checked on every boot
|
|
5981
|
+
// (2026-07-16, extended from the original one-shot-for-legacy-installs
|
|
5982
|
+
// version): rescans the connected X profile and re-stores voice.examples +
|
|
5983
|
+
// the persona_corpus.txt exemplar section whenever
|
|
5984
|
+
// scripts/voice_exemplars.py's _needs_rescan() says so -- never scanned,
|
|
5985
|
+
// scanned by an older SCANNER_VERSION (so a scraper fix like the 2026-07-15
|
|
5986
|
+
// stall-detection bug reaches already-scanned installs on their next boot
|
|
5987
|
+
// instead of silently never re-running), or the last scan is more than
|
|
5988
|
+
// RESCAN_MAX_AGE_DAYS (14) old. Additive only (regenerates just its own
|
|
5989
|
+
// marked corpus section; respects hand-written examples). Each boot is
|
|
5990
|
+
// cheap when nothing needs it (stamped examples_scanned_at +
|
|
5991
|
+
// examples_scanner_version make it a no-op until either goes stale), and
|
|
5992
|
+
// when something DOES need it, it WAITS politely on the twitter-browser
|
|
5993
|
+
// lock, polling while holding nothing, until cycles/DM runs free the
|
|
5994
|
+
// browser, up to 12h before deferring to the next boot. Delayed so
|
|
5995
|
+
// boot-time work (runtime provision, kicker install) settles first.
|
|
5523
5996
|
const backfill = setTimeout(() => {
|
|
5524
5997
|
if (isPaused())
|
|
5525
5998
|
return;
|