@m13v/s4l 1.7.5-rc.3 → 1.7.5-rc.30
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 +582 -79
- package/mcp/dist/panel.html +2 -3
- 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.json +2 -2
- package/mcp/manifest.json +2 -2
- package/mcp/menubar/s4l_card.py +331 -40
- package/mcp/menubar/s4l_card_canvas.py +1023 -0
- package/mcp/menubar/s4l_menubar.py +252 -160
- package/mcp/menubar/s4l_state.py +97 -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/autopilot_stall_watch.py +3 -6
- package/scripts/browser_mutex.py +316 -0
- package/scripts/claude_job.py +21 -0
- package/scripts/config.py +118 -0
- package/scripts/counterparty_history.py +44 -6
- package/scripts/engage_github.py +7 -3
- package/scripts/engage_reddit.py +9 -3
- package/scripts/engagement_signal_feedback.py +256 -0
- package/scripts/engagement_styles.py +32 -47
- package/scripts/learned_preferences.py +37 -14
- package/scripts/memory_snapshot.py +45 -0
- package/scripts/merge_review_queue.py +89 -3
- package/scripts/post_reddit.py +400 -46
- 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 +24 -8
- package/scripts/schedule_state.py +1 -1
- package/scripts/score_twitter_candidates.py +19 -5
- package/scripts/setup_reddit_auth.py +925 -0
- package/scripts/snapshot.py +96 -4
- package/scripts/stats.py +37 -13
- package/scripts/test_no_silent_fallbacks.py +281 -0
- package/scripts/trigger_promo_lane_test.sh +91 -0
- package/scripts/twitter_browser.py +129 -340
- package/scripts/twitter_post_plan.py +19 -0
- package/scripts/twitter_prompt_sandbox.py +144 -0
- package/scripts/twitter_scan.py +75 -10
- package/scripts/watchdog_hung_runs.py +15 -9
- package/skill/lib/browser-launch.sh +6 -1
- package/skill/lib/harness-common.sh +344 -0
- package/skill/lib/linkedin-backend.sh +61 -161
- package/skill/lib/reddit-backend.sh +39 -125
- package/skill/lib/twitter-backend.sh +51 -273
- package/skill/lock.sh +57 -0
- package/skill/run-reddit-search-launchd.sh +4 -1
- package/skill/run-reddit-search.sh +51 -2
- package/skill/run-twitter-cycle.sh +310 -96
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,7 +325,9 @@ 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
|
+
"(X with its real handle is the DEFAULT first suggestion; Reddit via connect_reddit counts " +
|
|
330
|
+
"equally if the user prefers it or X connection fails or is skipped), AND " +
|
|
317
331
|
"the two draft-autopilot scheduled tasks have been created via queue_setup and are firing " +
|
|
318
332
|
"(see SCHEDULE THE AUTOPILOT below). The owned runtime (private Python + Chromium) provisions " +
|
|
319
333
|
"ITSELF automatically on boot — you do NOT install it. Just poll runtime action:'status' until it " +
|
|
@@ -329,7 +343,8 @@ const server = new McpServer({
|
|
|
329
343
|
"or pause for an unavoidable interactive login or OS prompt. Never auto-POST during setup. " +
|
|
330
344
|
"DO schedule the draft autopilot as the final step (once running it only queues drafts for " +
|
|
331
345
|
"approval, it never posts on its own). When setup reaches done (" +
|
|
332
|
-
"runtime ready, a project configured with seeded topics,
|
|
346
|
+
"runtime ready, a project configured with seeded topics, at least one platform connected, and the " +
|
|
347
|
+
"two scheduled tasks " +
|
|
333
348
|
"created and firing), call the `dashboard` tool so the user " +
|
|
334
349
|
"sees the finished setup rendered, then give " +
|
|
335
350
|
"the completion summary.\n\n" +
|
|
@@ -351,6 +366,16 @@ const server = new McpServer({
|
|
|
351
366
|
"old 'social-autoposter-autopilot' task — it is deprecated. Do NOT ask the user to review drafts " +
|
|
352
367
|
"or pause for input mid-onboarding; once the tasks are created and firing, setup is done. If the " +
|
|
353
368
|
"host exposes no create_scheduled_task tool, tell the user the autopilot couldn't be scheduled here.\n\n" +
|
|
369
|
+
"REDDIT IS AN EQUAL, OPTIONAL PLATFORM. Setup is complete with AT LEAST ONE platform " +
|
|
370
|
+
"connected: X-only, Reddit-only, or both. X stays the default first suggestion; offer Reddit " +
|
|
371
|
+
"as an add-on after X setup completes, OR standalone when the user prefers Reddit or X " +
|
|
372
|
+
"connection fails or is skipped. `project_config` action:'connect_reddit' imports the user's " +
|
|
373
|
+
"reddit.com session into the autoposter's managed reddit browser, same confirm-first flow and " +
|
|
374
|
+
"keychain heads-up as connect_x. Once connected, reddit discovery and drafting run on the same " +
|
|
375
|
+
"draft-only queue and review cards as X; nothing posts without approval. If the result carries " +
|
|
376
|
+
"a `warning` (fresh or low-karma account), relay it so the user knows AutoMod may gate early " +
|
|
377
|
+
"replies. Never make Reddit a requirement, and never nag a happily X-only (or reddit-only) " +
|
|
378
|
+
"user about the other platform.\n\n" +
|
|
354
379
|
"BE PROACTIVE ABOUT MARKETING MOMENTS. Whenever the user ships, finishes, merges, or releases " +
|
|
355
380
|
"something worth talking about in this session (a new feature, a launch, a long-awaited fix, a " +
|
|
356
381
|
"milestone), don't wait to be asked: point it out to the user — the draft autopilot runs " +
|
|
@@ -827,7 +852,7 @@ function readConfiguredTwitterHandle() {
|
|
|
827
852
|
if (env)
|
|
828
853
|
return env;
|
|
829
854
|
try {
|
|
830
|
-
const cfg = JSON.parse(fs.readFileSync(
|
|
855
|
+
const cfg = JSON.parse(fs.readFileSync(configPath(), "utf-8"));
|
|
831
856
|
const h = cfg?.accounts?.twitter?.handle;
|
|
832
857
|
const s = (typeof h === "string" ? h : "").trim().replace(/^@/, "");
|
|
833
858
|
return s || null;
|
|
@@ -885,6 +910,57 @@ function expiredStampOverridable(c) {
|
|
|
885
910
|
c.posted !== true &&
|
|
886
911
|
c.discard_reason === "backend_status_expired");
|
|
887
912
|
}
|
|
913
|
+
function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
914
|
+
// Merge posted/terminal stamps into a FRESH read of the store instead of
|
|
915
|
+
// rewriting the whole plan from the copy taken minutes ago. The old
|
|
916
|
+
// whole-file write was last-writer-wins: while a batch posted, the menubar
|
|
917
|
+
// (decision re-stamps) and any peer drain also wrote the store, and
|
|
918
|
+
// whichever run finished last erased the others' posted flags (2026-07-06:
|
|
919
|
+
// card 344877 posted at 00:29Z ended `posted=None,
|
|
920
|
+
// terminal=duplicate_thread_pre_post` after a later run's stale write).
|
|
921
|
+
// Merge rules: `posted` is sticky and wins over terminal; `terminal` never
|
|
922
|
+
// overwrites a fresh `posted=true`. Fallback: candidates without a
|
|
923
|
+
// candidate_id can't be matched into the fresh copy, so keep the legacy
|
|
924
|
+
// whole-plan write for those older plans.
|
|
925
|
+
try {
|
|
926
|
+
const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
927
|
+
const fresh = mergeable ? readPlan(batchId) : null;
|
|
928
|
+
if (fresh && Array.isArray(fresh.candidates)) {
|
|
929
|
+
const freshById = new Map();
|
|
930
|
+
fresh.candidates.forEach((c) => {
|
|
931
|
+
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
932
|
+
freshById.set(String(c.candidate_id), c);
|
|
933
|
+
});
|
|
934
|
+
for (const c of stamped) {
|
|
935
|
+
const f = freshById.get(String(c.candidate_id));
|
|
936
|
+
if (!f)
|
|
937
|
+
continue;
|
|
938
|
+
if (c.posted === true) {
|
|
939
|
+
f.posted = true;
|
|
940
|
+
f.terminal = false;
|
|
941
|
+
if (c.our_url)
|
|
942
|
+
f.our_url = c.our_url;
|
|
943
|
+
}
|
|
944
|
+
else if (c.terminal === true && f.posted !== true) {
|
|
945
|
+
f.terminal = true;
|
|
946
|
+
f.terminal_reason = c.terminal_reason;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
writePlan(batchId, fresh);
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
writePlan(batchId, plan);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
try {
|
|
957
|
+
writePlan(batchId, plan);
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
/* best effort */
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
888
964
|
async function postApproved(batchId, plan) {
|
|
889
965
|
// Drain serialization (2026-07-06 incident). Every call drains the WHOLE
|
|
890
966
|
// approved backlog, so overlapping drains are pure waste and actively harmful:
|
|
@@ -951,6 +1027,181 @@ async function postApproved(batchId, plan) {
|
|
|
951
1027
|
}
|
|
952
1028
|
if (approved.length === 0)
|
|
953
1029
|
return { attempted: 0, exit_code: 0, summary: "nothing approved" };
|
|
1030
|
+
// ---- Reddit cards (2026-07-14): drain them FIRST, independently ----------
|
|
1031
|
+
// A reddit card carries the verbatim draft decision (reddit_decision) plus
|
|
1032
|
+
// plan metadata (reddit_plan_meta), so approval reconstructs a one-decision
|
|
1033
|
+
// plan and reuses `post_reddit.py --phase post` unchanged — per-row
|
|
1034
|
+
// reddit-browser lease, URL wrapping, campaign suffixes, and log_post all
|
|
1035
|
+
// stay in the one battle-tested poster. The twitter handle preflight and
|
|
1036
|
+
// twitter-browser lock ceremony below are twitter-only, so reddit must not
|
|
1037
|
+
// be gated on them (and an all-reddit batch returns before any of it).
|
|
1038
|
+
const approvedReddit = approved.filter((c) => c.platform === "reddit");
|
|
1039
|
+
const approvedTwitter = approved.filter((c) => c.platform !== "reddit");
|
|
1040
|
+
let redditPosted = 0;
|
|
1041
|
+
let redditFailed = 0;
|
|
1042
|
+
let redditSkippedPeerDrain = false;
|
|
1043
|
+
if (approvedReddit.length) {
|
|
1044
|
+
// Cross-instance drain gate (2026-07-14 drain storm): every one-shot MCP
|
|
1045
|
+
// boot (the queue worker fires ~every minute; each boot runs the startup
|
|
1046
|
+
// backlog drain) drained the SAME sticky approvals concurrently — 26
|
|
1047
|
+
// drain plans in ~30 minutes, all fighting over the one reddit tab and
|
|
1048
|
+
// false-positiving each other's posts. reddit-posting-active.json is the
|
|
1049
|
+
// arbiter: post_reddit.py heartbeats it per row, and we stamp it around
|
|
1050
|
+
// the whole drain here. A fresh flag (<120s) means a peer is mid-drain:
|
|
1051
|
+
// leave the cards for it — approvals are sticky, nothing is lost.
|
|
1052
|
+
const redditFlagPath = path.join(s4lStateDir(), "reddit-posting-active.json");
|
|
1053
|
+
let redditPeerFresh = false;
|
|
1054
|
+
try {
|
|
1055
|
+
redditPeerFresh = Date.now() - fs.statSync(redditFlagPath).mtimeMs < 120_000;
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
redditPeerFresh = false;
|
|
1059
|
+
}
|
|
1060
|
+
const stampRedditFlag = () => {
|
|
1061
|
+
try {
|
|
1062
|
+
fs.writeFileSync(redditFlagPath, JSON.stringify({ pid: process.pid, hb: Math.floor(Date.now() / 1000) }));
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
/* best effort */
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
if (redditPeerFresh) {
|
|
1069
|
+
redditSkippedPeerDrain = true;
|
|
1070
|
+
logPostEvent(`postApproved_reddit_skip batch=${batchId} reason=peer_drain_active cards=${approvedReddit.length}`);
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
stampRedditFlag();
|
|
1074
|
+
try {
|
|
1075
|
+
for (const c of approvedReddit) {
|
|
1076
|
+
stampRedditFlag();
|
|
1077
|
+
const cc = c;
|
|
1078
|
+
const dec = cc.reddit_decision;
|
|
1079
|
+
const meta = (cc.reddit_plan_meta || {});
|
|
1080
|
+
if (!dec) {
|
|
1081
|
+
c.terminal = true;
|
|
1082
|
+
c.terminal_reason = "reddit_decision_missing";
|
|
1083
|
+
redditFailed++;
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
// The card's reply_text is the CANONICAL text (post_drafts edits write
|
|
1087
|
+
// there); the embedded decision still carries the draft-time original.
|
|
1088
|
+
// Posting dec.text verbatim would silently discard a human edit.
|
|
1089
|
+
const decPost = {
|
|
1090
|
+
...dec,
|
|
1091
|
+
text: (typeof cc.reply_text === "string" && cc.reply_text.trim())
|
|
1092
|
+
? cc.reply_text
|
|
1093
|
+
: dec.text,
|
|
1094
|
+
};
|
|
1095
|
+
if (cc.engagement_style)
|
|
1096
|
+
decPost.engagement_style = cc.engagement_style;
|
|
1097
|
+
// Two-draft cards (2026-07-15): a human draft-switch (see the generic
|
|
1098
|
+
// edit-handling above, `if (e.variant && Array.isArray(c.drafts))`)
|
|
1099
|
+
// stamps the CHOSEN draft's assigned_style/assigned_mode onto the card
|
|
1100
|
+
// (cc). Forward both into decPost so post_reddit.py's _post_iteration
|
|
1101
|
+
// validates/logs against whichever draft is ACTUALLY posting, not
|
|
1102
|
+
// whichever was recommended at plan-write time — mirrors
|
|
1103
|
+
// twitter_post_plan.py's identical per-candidate override.
|
|
1104
|
+
if (cc.assigned_style !== undefined)
|
|
1105
|
+
decPost.assigned_style = cc.assigned_style;
|
|
1106
|
+
if (cc.assigned_mode !== undefined)
|
|
1107
|
+
decPost.assigned_mode = cc.assigned_mode;
|
|
1108
|
+
const miniPlan = {
|
|
1109
|
+
project_name: meta.project_name || cc.matched_project,
|
|
1110
|
+
batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
|
|
1111
|
+
phase: "draft",
|
|
1112
|
+
decisions: [decPost],
|
|
1113
|
+
style_assignment: meta.style_assignment || {},
|
|
1114
|
+
generation_trace_path: meta.generation_trace_path,
|
|
1115
|
+
session_id: meta.session_id || meta.draft_session_id,
|
|
1116
|
+
};
|
|
1117
|
+
const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
|
|
1118
|
+
let r;
|
|
1119
|
+
try {
|
|
1120
|
+
fs.writeFileSync(tmpPlan, JSON.stringify(miniPlan));
|
|
1121
|
+
r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
|
|
1122
|
+
timeoutMs: 900_000,
|
|
1123
|
+
env: {
|
|
1124
|
+
REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
|
|
1125
|
+
// Reviewed posts never get the active-campaign suffix (same rule
|
|
1126
|
+
// as the twitter manual-approval path below).
|
|
1127
|
+
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1128
|
+
},
|
|
1129
|
+
onLine: (line) => {
|
|
1130
|
+
const t = line.replace(/\s+$/, "");
|
|
1131
|
+
if (t.trim())
|
|
1132
|
+
console.error(`[post-reddit] ${t}`);
|
|
1133
|
+
},
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
catch (err) {
|
|
1137
|
+
r = { code: -1, stdout: "", stderr: String(err) };
|
|
1138
|
+
}
|
|
1139
|
+
finally {
|
|
1140
|
+
try {
|
|
1141
|
+
fs.unlinkSync(tmpPlan);
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
/* best effort */
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
// post_reddit.py's summary marker: `[post_reddit] phase=post ... posted=N failed=M`
|
|
1148
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
1149
|
+
const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
|
|
1150
|
+
if (r.code === 0 && postedN > 0) {
|
|
1151
|
+
c.posted = true;
|
|
1152
|
+
c.terminal = false;
|
|
1153
|
+
const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
|
|
1154
|
+
if (urlMatch)
|
|
1155
|
+
c.our_url = urlMatch[1];
|
|
1156
|
+
redditPosted++;
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
// Leave the approval sticky (approved && !posted && !terminal) so the
|
|
1160
|
+
// next post_drafts call retries, mirroring twitter's failed-drain
|
|
1161
|
+
// semantics; only stamp terminal on a conclusive CDP refusal.
|
|
1162
|
+
const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
|
|
1163
|
+
if (cdpReason && ["thread_locked", "thread_archived", "thread_not_found", "blocked_by_author"].includes(cdpReason)) {
|
|
1164
|
+
c.terminal = true;
|
|
1165
|
+
c.terminal_reason = `reddit_${cdpReason}`;
|
|
1166
|
+
}
|
|
1167
|
+
redditFailed++;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
finally {
|
|
1172
|
+
// Clear only OUR stamp; a peer that took over mid-drain keeps its own.
|
|
1173
|
+
try {
|
|
1174
|
+
const cur = JSON.parse(fs.readFileSync(redditFlagPath, "utf-8"));
|
|
1175
|
+
if (cur && cur.pid === process.pid)
|
|
1176
|
+
fs.unlinkSync(redditFlagPath);
|
|
1177
|
+
}
|
|
1178
|
+
catch {
|
|
1179
|
+
/* best effort */
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (approvedTwitter.length === 0) {
|
|
1186
|
+
// All-reddit batch: persist the stamps and return without touching the
|
|
1187
|
+
// twitter preflight/lock path.
|
|
1188
|
+
if (redditSkippedPeerDrain) {
|
|
1189
|
+
return {
|
|
1190
|
+
attempted: 0,
|
|
1191
|
+
posted: 0,
|
|
1192
|
+
exit_code: 0,
|
|
1193
|
+
summary: "reddit: skipped (peer drain active); cards stay approved for the next drain",
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
if (approvedReddit.length)
|
|
1197
|
+
mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1198
|
+
return {
|
|
1199
|
+
attempted: approvedReddit.length,
|
|
1200
|
+
posted: redditPosted,
|
|
1201
|
+
exit_code: redditFailed ? 1 : 0,
|
|
1202
|
+
summary: `reddit: posted=${redditPosted} failed=${redditFailed}`,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
954
1205
|
// PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
|
|
955
1206
|
// EVERY reply with no_account_configured and the whole batch skips — invisibly.
|
|
956
1207
|
// If onboarding never persisted it, self-heal from the live session; if even that
|
|
@@ -1002,7 +1253,7 @@ async function postApproved(batchId, plan) {
|
|
|
1002
1253
|
};
|
|
1003
1254
|
}
|
|
1004
1255
|
const approvedBatch = `${batchId}_approved`;
|
|
1005
|
-
writePlan(approvedBatch, { ...plan, candidates:
|
|
1256
|
+
writePlan(approvedBatch, { ...plan, candidates: approvedTwitter });
|
|
1006
1257
|
// S4L_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
|
|
1007
1258
|
// never get the active-campaign suffix (e.g. " written with ai") appended.
|
|
1008
1259
|
// twitter_browser.py's reply handler reads this env (inherited through
|
|
@@ -1015,7 +1266,7 @@ async function postApproved(batchId, plan) {
|
|
|
1015
1266
|
const failure = {
|
|
1016
1267
|
posted: 0,
|
|
1017
1268
|
skipped: 0,
|
|
1018
|
-
failed:
|
|
1269
|
+
failed: approvedTwitter.length,
|
|
1019
1270
|
failure_reasons: "browser_bootstrap_failed",
|
|
1020
1271
|
skip_reasons: "",
|
|
1021
1272
|
};
|
|
@@ -1031,7 +1282,7 @@ async function postApproved(batchId, plan) {
|
|
|
1031
1282
|
// (Karol 2026-07-09: 0/131 posted, exit=-1). 60s/card headroom covers
|
|
1032
1283
|
// slow candidates; the 2h cap bounds a hung poster (the browser-lock
|
|
1033
1284
|
// expiry and per-reply subprocess timeouts still fire underneath).
|
|
1034
|
-
timeoutMs: Math.min(7_200_000, Math.max(900_000,
|
|
1285
|
+
timeoutMs: Math.min(7_200_000, Math.max(900_000, approvedTwitter.length * 60_000)),
|
|
1035
1286
|
env: ({
|
|
1036
1287
|
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1037
1288
|
// Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
|
|
@@ -1098,7 +1349,7 @@ async function postApproved(batchId, plan) {
|
|
|
1098
1349
|
const postLogDir = path.join(repoDir(), "skill", "logs");
|
|
1099
1350
|
fs.mkdirSync(postLogDir, { recursive: true });
|
|
1100
1351
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1101
|
-
fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${
|
|
1352
|
+
fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${approvedTwitter.length} exit=${res.code} ` +
|
|
1102
1353
|
`shell_lock=${heldShellLock}\n\n=== stdout ===\n${res.stdout}\n\n=== stderr ===\n${res.stderr}\n`);
|
|
1103
1354
|
}
|
|
1104
1355
|
catch {
|
|
@@ -1121,7 +1372,7 @@ async function postApproved(batchId, plan) {
|
|
|
1121
1372
|
const realPosted = summObj && typeof summObj.posted === "number"
|
|
1122
1373
|
? summObj.posted
|
|
1123
1374
|
: res.code === 0 && !summObj
|
|
1124
|
-
?
|
|
1375
|
+
? approvedTwitter.length
|
|
1125
1376
|
: 0;
|
|
1126
1377
|
// Mark candidates according to the poster's per-candidate outcome. This keeps
|
|
1127
1378
|
// the review queue honest: posted drafts disappear as posted, terminal skips
|
|
@@ -1142,14 +1393,14 @@ async function postApproved(batchId, plan) {
|
|
|
1142
1393
|
.filter((r) => r.candidate_id && ["posted", "skipped", "failed"].includes(r.outcome))
|
|
1143
1394
|
: parsePostCandidateResults(res.stdout);
|
|
1144
1395
|
const approvedById = new Map();
|
|
1145
|
-
|
|
1396
|
+
approvedTwitter.forEach((c) => {
|
|
1146
1397
|
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
1147
1398
|
approvedById.set(String(c.candidate_id), c);
|
|
1148
1399
|
});
|
|
1149
1400
|
let touchedPlan = false;
|
|
1150
1401
|
if (resultRows.length) {
|
|
1151
1402
|
resultRows.forEach((r, idx) => {
|
|
1152
|
-
const c = approvedById.get(r.candidate_id) ||
|
|
1403
|
+
const c = approvedById.get(r.candidate_id) || approvedTwitter[idx];
|
|
1153
1404
|
if (!c)
|
|
1154
1405
|
return;
|
|
1155
1406
|
if (r.outcome === "posted") {
|
|
@@ -1169,59 +1420,14 @@ async function postApproved(batchId, plan) {
|
|
|
1169
1420
|
else if (realPosted > 0 || (res.code === 0 && !summObj)) {
|
|
1170
1421
|
// Legacy fallback for older poster output without parseable per-candidate
|
|
1171
1422
|
// lines. Mark only when we have no finer-grained signal.
|
|
1172
|
-
for (const c of
|
|
1423
|
+
for (const c of approvedTwitter)
|
|
1173
1424
|
c.posted = true;
|
|
1174
1425
|
touchedPlan = true;
|
|
1175
1426
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
// was last-writer-wins: while this batch posted, the menubar (decision
|
|
1181
|
-
// re-stamps) and any peer drain also wrote the store, and whichever run
|
|
1182
|
-
// finished last erased the others' posted flags (2026-07-06: card 344877
|
|
1183
|
-
// posted at 00:29Z ended `posted=None, terminal=duplicate_thread_pre_post`
|
|
1184
|
-
// after a later run's stale write). Merge rules: `posted` is sticky and
|
|
1185
|
-
// wins over terminal; `terminal` never overwrites a fresh `posted=true`.
|
|
1186
|
-
// Fallback: candidates without a candidate_id can't be matched into the
|
|
1187
|
-
// fresh copy, so keep the legacy whole-plan write for those older plans.
|
|
1188
|
-
const mergeable = approved.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
1189
|
-
const fresh = mergeable ? readPlan(batchId) : null;
|
|
1190
|
-
if (fresh && Array.isArray(fresh.candidates)) {
|
|
1191
|
-
const freshById = new Map();
|
|
1192
|
-
fresh.candidates.forEach((c) => {
|
|
1193
|
-
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
1194
|
-
freshById.set(String(c.candidate_id), c);
|
|
1195
|
-
});
|
|
1196
|
-
for (const c of approved) {
|
|
1197
|
-
const f = freshById.get(String(c.candidate_id));
|
|
1198
|
-
if (!f)
|
|
1199
|
-
continue;
|
|
1200
|
-
if (c.posted === true) {
|
|
1201
|
-
f.posted = true;
|
|
1202
|
-
f.terminal = false;
|
|
1203
|
-
if (c.our_url)
|
|
1204
|
-
f.our_url = c.our_url;
|
|
1205
|
-
}
|
|
1206
|
-
else if (c.terminal === true && f.posted !== true) {
|
|
1207
|
-
f.terminal = true;
|
|
1208
|
-
f.terminal_reason = c.terminal_reason;
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
writePlan(batchId, fresh);
|
|
1212
|
-
}
|
|
1213
|
-
else {
|
|
1214
|
-
writePlan(batchId, plan);
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
catch {
|
|
1218
|
-
try {
|
|
1219
|
-
writePlan(batchId, plan);
|
|
1220
|
-
}
|
|
1221
|
-
catch {
|
|
1222
|
-
/* best effort */
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1427
|
+
// Reddit stamps (set in the reddit drain above) merge alongside the twitter
|
|
1428
|
+
// ones: `approved` here spans both platforms.
|
|
1429
|
+
if (touchedPlan || redditPosted || redditFailed) {
|
|
1430
|
+
mergeApprovedStampsIntoStore(batchId, plan, approved);
|
|
1225
1431
|
}
|
|
1226
1432
|
// Post failures are HANDLED in the pipeline (it returns a count, never throws),
|
|
1227
1433
|
// so they never reach Sentry on their own. Capture an explicit event whenever
|
|
@@ -1240,10 +1446,10 @@ async function postApproved(batchId, plan) {
|
|
|
1240
1446
|
// actionable error on its own.
|
|
1241
1447
|
const hasRealFailure = res.code !== 0 || Boolean(summObj?.failure_reasons);
|
|
1242
1448
|
if (hasRealFailure) {
|
|
1243
|
-
captureError(new Error(`post_drafts: ${realPosted}/${
|
|
1449
|
+
captureError(new Error(`post_drafts: ${realPosted}/${approvedTwitter.length} posted (exit=${res.code})`), {
|
|
1244
1450
|
component: "post",
|
|
1245
1451
|
exit_code: String(res.code),
|
|
1246
|
-
attempted: String(
|
|
1452
|
+
attempted: String(approvedTwitter.length),
|
|
1247
1453
|
posted: String(realPosted),
|
|
1248
1454
|
failure_reasons: String(summObj?.failure_reasons || ""),
|
|
1249
1455
|
skip_reasons: String(summObj?.skip_reasons || ""),
|
|
@@ -1254,9 +1460,11 @@ async function postApproved(batchId, plan) {
|
|
|
1254
1460
|
void flushLogs();
|
|
1255
1461
|
return {
|
|
1256
1462
|
attempted: approved.length,
|
|
1257
|
-
posted: realPosted,
|
|
1463
|
+
posted: realPosted + redditPosted,
|
|
1258
1464
|
exit_code: res.code,
|
|
1259
|
-
summary
|
|
1465
|
+
summary: approvedReddit.length > 0
|
|
1466
|
+
? { twitter: summary, reddit: { posted: redditPosted, failed: redditFailed } }
|
|
1467
|
+
: summary,
|
|
1260
1468
|
stderr_tail: res.stderr.split("\n").slice(-8).join("\n"),
|
|
1261
1469
|
};
|
|
1262
1470
|
}
|
|
@@ -1761,7 +1969,14 @@ tool("project_config", {
|
|
|
1761
1969
|
inputSchema: {
|
|
1762
1970
|
status: z.boolean().optional(),
|
|
1763
1971
|
action: z
|
|
1764
|
-
.enum([
|
|
1972
|
+
.enum([
|
|
1973
|
+
"get",
|
|
1974
|
+
"connect_x",
|
|
1975
|
+
"detect_x_sources",
|
|
1976
|
+
"profile_scan",
|
|
1977
|
+
"connect_reddit",
|
|
1978
|
+
"detect_reddit_sources",
|
|
1979
|
+
])
|
|
1765
1980
|
.optional()
|
|
1766
1981
|
.describe("get = read the CURRENT SAVED VALUES of every project's editable fields (website, " +
|
|
1767
1982
|
"description, icp, voice, differentiator, search_topics, get_started_link, " +
|
|
@@ -1779,7 +1994,13 @@ tool("project_config", {
|
|
|
1779
1994
|
"replies to build a 'grounding truth' corpus. Use it to draft voice/icp/search_topics in " +
|
|
1780
1995
|
"the USER'S OWN register (their phrases, vibe, profession), then save a conservative best " +
|
|
1781
1996
|
"draft without requiring a confirmation round-trip. Returns {profile, posts, comments, " +
|
|
1782
|
-
"grounding_instructions}."
|
|
1997
|
+
"grounding_instructions}. " +
|
|
1998
|
+
"connect_reddit = OPTIONAL equal platform (setup completes with X-only, Reddit-only, or " +
|
|
1999
|
+
"both; offer Reddit after X setup, or standalone when the user prefers it or X fails/is " +
|
|
2000
|
+
"skipped): import/validate the user's reddit.com session in the " +
|
|
2001
|
+
"autoposter's managed reddit browser, same confirm-first shape as connect_x. " +
|
|
2002
|
+
"detect_reddit_sources = list the browsers/profiles the Reddit session can be imported " +
|
|
2003
|
+
"from (read-only, no keychain prompt)."),
|
|
1783
2004
|
confirm: z
|
|
1784
2005
|
.boolean()
|
|
1785
2006
|
.optional()
|
|
@@ -1796,6 +2017,17 @@ tool("project_config", {
|
|
|
1796
2017
|
"X login window and waits for them to log in. By default (false), connect_x does NOT pop a " +
|
|
1797
2018
|
"browser window on an auto-import miss; it returns needs_login and you offer manual login as " +
|
|
1798
2019
|
"an opt-in. The login window still opens automatically if the user DENIED the keychain prompt."),
|
|
2020
|
+
reddit_source: z
|
|
2021
|
+
.string()
|
|
2022
|
+
.optional()
|
|
2023
|
+
.describe("Optional browser profile to import the Reddit session from, e.g. 'arc:Default', " +
|
|
2024
|
+
"'chrome:Profile 1'. Default: auto-detect the browser that holds a reddit.com session."),
|
|
2025
|
+
reddit_manual_login: z
|
|
2026
|
+
.boolean()
|
|
2027
|
+
.optional()
|
|
2028
|
+
.describe("Set true ONLY when the user explicitly wants to sign into Reddit by hand. Opens a focused " +
|
|
2029
|
+
"Reddit login window in the autoposter's reddit browser and waits. Same opt-in discipline " +
|
|
2030
|
+
"as x_manual_login."),
|
|
1799
2031
|
name: z
|
|
1800
2032
|
.string()
|
|
1801
2033
|
.optional()
|
|
@@ -1965,6 +2197,130 @@ tool("project_config", {
|
|
|
1965
2197
|
: "X is not connected yet. " + summarizeXAuth(r),
|
|
1966
2198
|
});
|
|
1967
2199
|
}
|
|
2200
|
+
// ---- List Reddit import sources (read-only, no keychain prompt) --------
|
|
2201
|
+
if (args.action === "detect_reddit_sources") {
|
|
2202
|
+
const r = await redditDetectSources();
|
|
2203
|
+
return jsonContent({
|
|
2204
|
+
action: "detect_reddit_sources",
|
|
2205
|
+
ok: r.ok,
|
|
2206
|
+
sources: r.sources,
|
|
2207
|
+
recommended: r.recommended,
|
|
2208
|
+
error: r.error,
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
// ---- Connect Reddit: OPTIONAL platform, mirrors connect_x --------------
|
|
2212
|
+
// Preview-or-run, same confirm-first shape as connect_x. Reddit is never
|
|
2213
|
+
// required for onboarding completion; offer it only after X setup is done
|
|
2214
|
+
// and the user wants it.
|
|
2215
|
+
if (args.action === "connect_reddit") {
|
|
2216
|
+
if (args.confirm !== true) {
|
|
2217
|
+
// Cheap probe so the explanation reflects current state (no Chrome launch).
|
|
2218
|
+
const cur = await redditStatus();
|
|
2219
|
+
if (cur.connected) {
|
|
2220
|
+
return jsonContent({
|
|
2221
|
+
action: "connect_reddit",
|
|
2222
|
+
already_connected: true,
|
|
2223
|
+
state: cur.state,
|
|
2224
|
+
username: cur.username,
|
|
2225
|
+
note: "Reddit is already connected in the autoposter's reddit browser. Nothing to import.",
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
return jsonContent({
|
|
2229
|
+
action: "connect_reddit",
|
|
2230
|
+
requires_confirmation: true,
|
|
2231
|
+
current_state: cur.state,
|
|
2232
|
+
what_will_happen: "To engage on Reddit for you, the autoposter uses its OWN managed Google Chrome (separate " +
|
|
2233
|
+
"from your everyday browser and from the X one). It needs your logged-in reddit.com " +
|
|
2234
|
+
"session. If you confirm, it will: (1) start that managed reddit browser if it isn't " +
|
|
2235
|
+
"running, (2) copy ONLY your reddit.com cookies (reddit_session, token_v2) from your " +
|
|
2236
|
+
"everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into it, and (3) verify you're " +
|
|
2237
|
+
"logged in via Reddit's own me.json. No other site's cookies are read, and your passwords " +
|
|
2238
|
+
"are never seen. If it can't import a valid session, a Chrome window will open for you to " +
|
|
2239
|
+
"sign in once.",
|
|
2240
|
+
keychain_prompt: "Reading the saved session requires macOS to unlock the browser's encrypted cookie store, so " +
|
|
2241
|
+
"one or more keychain prompts will appear (“... wants to use your confidential information " +
|
|
2242
|
+
"stored in '... Safe Storage' in your keychain”). This is expected. The user enters their Mac " +
|
|
2243
|
+
"login password and clicks Allow (or Always Allow to avoid repeats). If they use more than one " +
|
|
2244
|
+
"browser, the prompt can appear a few times, once per browser.",
|
|
2245
|
+
say_to_user: "Heads up: your Mac will pop up a keychain prompt asking to use your browser's Safe Storage. " +
|
|
2246
|
+
"That's just us reading your saved Reddit login, nothing else. Type your Mac login password and " +
|
|
2247
|
+
"click Allow (or Always Allow). If you use more than one browser you may see it a couple of " +
|
|
2248
|
+
"times, once per browser.",
|
|
2249
|
+
how_to_proceed: "If the user explicitly requested connecting Reddit, relay the say_to_user line as a brief " +
|
|
2250
|
+
"heads-up and immediately call project_config again with action:'connect_reddit', confirm:true; " +
|
|
2251
|
+
"do not wait for another yes/no reply. Optionally pass a reddit_source from " +
|
|
2252
|
+
"detect_reddit_sources. If the user only asked what connection would do, stop after this " +
|
|
2253
|
+
"preview. Reddit is OPTIONAL: never treat it as required for setup completion.",
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
recordOnboardingAttempt("reddit_connected", {
|
|
2257
|
+
state: args.reddit_source ? "source_selected" : "auto_detect",
|
|
2258
|
+
});
|
|
2259
|
+
const r = await redditConnect(args.reddit_source, args.reddit_manual_login);
|
|
2260
|
+
let verified = false;
|
|
2261
|
+
let configNote;
|
|
2262
|
+
if (r.connected) {
|
|
2263
|
+
completeOnboardingMilestone("reddit_connected", { state: r.state });
|
|
2264
|
+
// Persist the discovered username through the server's config-write
|
|
2265
|
+
// path (accounts.reddit.username, the field account_resolver reads),
|
|
2266
|
+
// never overwriting a deliberately-set value.
|
|
2267
|
+
if (r.username) {
|
|
2268
|
+
const w = recordRedditAccount(r.username);
|
|
2269
|
+
configNote = w.detail;
|
|
2270
|
+
}
|
|
2271
|
+
// Doctor-style persistence re-check before reddit_verified: a fresh
|
|
2272
|
+
// read-only status probe confirms the session actually persisted
|
|
2273
|
+
// (live me.json or the on-disk profile cookie row) rather than
|
|
2274
|
+
// trusting the connect call's own success.
|
|
2275
|
+
const recheck = await redditStatus();
|
|
2276
|
+
if (recheck.connected) {
|
|
2277
|
+
completeOnboardingMilestone("reddit_verified", { state: recheck.state });
|
|
2278
|
+
verified = true;
|
|
2279
|
+
}
|
|
2280
|
+
else {
|
|
2281
|
+
recordOnboardingAttempt("reddit_verified", {
|
|
2282
|
+
state: recheck.state || "unverified",
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
// Reddit is live: install the discovery kicker (idempotent; gated on
|
|
2286
|
+
// runtime + project readiness inside, and it never touches a
|
|
2287
|
+
// hand-built plist pointing outside the managed package).
|
|
2288
|
+
void ensureRedditKickerInstalled()
|
|
2289
|
+
.then((res) => console.error(`[reddit-kicker] post-connect install: ${res.ok ? "ok" : "skip"} (${res.detail})`))
|
|
2290
|
+
.catch((e) => console.error("[reddit-kicker] post-connect install failed:", e?.message || e));
|
|
2291
|
+
}
|
|
2292
|
+
else {
|
|
2293
|
+
blockOnboardingMilestone("reddit_connected", `reddit_${r.state || "not_connected"}`, r.error || r.note || summarizeRedditAuth(r), { state: r.state || "not_connected" });
|
|
2294
|
+
}
|
|
2295
|
+
return jsonContent({
|
|
2296
|
+
action: "connect_reddit",
|
|
2297
|
+
connected: r.connected,
|
|
2298
|
+
state: r.state,
|
|
2299
|
+
username: r.username,
|
|
2300
|
+
account_age_days: r.account_age_days,
|
|
2301
|
+
comment_karma: r.comment_karma,
|
|
2302
|
+
// Ready-to-relay expectation setter for fresh/low-karma accounts
|
|
2303
|
+
// (AutoMod gates those in most subreddits). Advisory only; never a
|
|
2304
|
+
// reason to block or undo the connect.
|
|
2305
|
+
warning: r.warning,
|
|
2306
|
+
verified,
|
|
2307
|
+
config: configNote,
|
|
2308
|
+
summary: summarizeRedditAuth(r),
|
|
2309
|
+
note: r.note,
|
|
2310
|
+
attempts: r.attempts,
|
|
2311
|
+
onboarding: onboardingSnapshot(),
|
|
2312
|
+
next_step: r.connected
|
|
2313
|
+
? "Reddit is connected. The reddit discovery job installs itself once a project is " +
|
|
2314
|
+
"configured; drafts flow into the same review cards as X and nothing posts without " +
|
|
2315
|
+
"approval." +
|
|
2316
|
+
(r.warning ? " Relay the `warning` field to the user so expectations are set." : "")
|
|
2317
|
+
: r.state === "needs_login"
|
|
2318
|
+
? "The user must finish signing in to reddit.com in the Chrome window that opened. Tell " +
|
|
2319
|
+
"them that single required action, then call project_config action:'connect_reddit', " +
|
|
2320
|
+
"confirm:true again."
|
|
2321
|
+
: "Reddit is not connected yet. " + summarizeRedditAuth(r),
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
1968
2324
|
// ---- Profile scan: grounding-truth corpus from the connected account ----
|
|
1969
2325
|
// Reuses the authenticated managed-Chrome session (so it must run AFTER a
|
|
1970
2326
|
// successful connect_x) to read the user's bio + recent posts + recent
|
|
@@ -2070,6 +2426,9 @@ tool("project_config", {
|
|
|
2070
2426
|
const projects = Array.isArray(snap.projects) ? snap.projects : [];
|
|
2071
2427
|
const rtReady = !!snap.runtime_ready;
|
|
2072
2428
|
const xConnected = !!snap.x_connected;
|
|
2429
|
+
const redditConnected = !!snap.reddit_connected;
|
|
2430
|
+
// Any-of platform completion: X or Reddit satisfies the platform leg.
|
|
2431
|
+
const anyPlatform = xConnected || redditConnected;
|
|
2073
2432
|
const configured = (snap.projects_ready || 0) > 0;
|
|
2074
2433
|
return jsonContent({
|
|
2075
2434
|
configured,
|
|
@@ -2078,6 +2437,9 @@ tool("project_config", {
|
|
|
2078
2437
|
x_connected: xConnected,
|
|
2079
2438
|
x_state: snap.x_state,
|
|
2080
2439
|
x_handle: snap.x_handle ?? null,
|
|
2440
|
+
reddit_connected: redditConnected,
|
|
2441
|
+
reddit_state: snap.reddit_state,
|
|
2442
|
+
reddit_username: snap.reddit_username ?? null,
|
|
2081
2443
|
setup_complete: !!snap.setup_complete,
|
|
2082
2444
|
mcp_version: snap.version,
|
|
2083
2445
|
latest_version: snap.latest_version,
|
|
@@ -2099,14 +2461,15 @@ tool("project_config", {
|
|
|
2099
2461
|
? "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."
|
|
2100
2462
|
: projects.length === 0
|
|
2101
2463
|
? "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." +
|
|
2102
|
-
(
|
|
2464
|
+
(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.")
|
|
2103
2465
|
: projects.every((p) => p.ready)
|
|
2104
|
-
? (
|
|
2105
|
-
? "All configured projects are ready and
|
|
2106
|
-
|
|
2107
|
-
"
|
|
2466
|
+
? (anyPlatform
|
|
2467
|
+
? "All configured projects are ready and a platform is connected" +
|
|
2468
|
+
(xConnected ? "" : " (Reddit; X was skipped, which is fine)") +
|
|
2469
|
+
". 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."
|
|
2470
|
+
: "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.")
|
|
2108
2471
|
: "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." +
|
|
2109
|
-
(
|
|
2472
|
+
(anyPlatform ? "" : " No platform is connected yet either; X is the default (connect_x with confirm:true), or connect_reddit if the user prefers Reddit."),
|
|
2110
2473
|
});
|
|
2111
2474
|
}
|
|
2112
2475
|
// Apply mode (incremental): merge whatever fields were supplied onto the
|
|
@@ -2332,6 +2695,27 @@ tool("post_drafts", {
|
|
|
2332
2695
|
}
|
|
2333
2696
|
c.approved = false;
|
|
2334
2697
|
rejected.push(n);
|
|
2698
|
+
// Reddit cards: also retire the reddit_candidates row (permanent
|
|
2699
|
+
// mark_attempt) so Phase 0 salvage never re-pulls and re-drafts a
|
|
2700
|
+
// thread a human just rejected. Twitter gets this for free via the
|
|
2701
|
+
// review-events row flip; reddit's id-keyed flows are deliberately
|
|
2702
|
+
// firewalled (rd- prefixed ids), so this direct by-thread-url PATCH is
|
|
2703
|
+
// the reddit equivalent. Fire-and-forget: a failure leaves the row
|
|
2704
|
+
// pending, which at worst re-drafts one card next cycle.
|
|
2705
|
+
const ccr = c;
|
|
2706
|
+
if (ccr.platform === "reddit" && (ccr.thread_url || ccr.candidate_url)) {
|
|
2707
|
+
const rejUrl = String(ccr.thread_url || ccr.candidate_url);
|
|
2708
|
+
void runPython("-c", [
|
|
2709
|
+
"import sys; sys.path.insert(0, 'scripts')\n" +
|
|
2710
|
+
"from http_api import api_patch\n" +
|
|
2711
|
+
"api_patch('/api/v1/reddit-candidates/by-thread-url', " +
|
|
2712
|
+
"{'thread_url': sys.argv[1], 'action': 'mark_attempt', " +
|
|
2713
|
+
"'reason': 'human_rejected', 'permanent': True}, ok_on_404=True)",
|
|
2714
|
+
rejUrl,
|
|
2715
|
+
], { timeoutMs: 30_000 }).catch(() => {
|
|
2716
|
+
/* best effort */
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2335
2719
|
});
|
|
2336
2720
|
// Apply edits first; an edited draft is always posted.
|
|
2337
2721
|
const approve = new Set();
|
|
@@ -2686,9 +3070,9 @@ tool("posting_volume", {
|
|
|
2686
3070
|
title: "Read or set posting volume (Aggressive / Steady / Chill)",
|
|
2687
3071
|
description: "Read or set this install's posting-volume mode, the quality bar that decides how many drafts " +
|
|
2688
3072
|
"per day the twitter cycle produces. Three modes, shown to users as Aggressive (~100+ posts/day), " +
|
|
2689
|
-
"Steady (~30/day), and Chill (~5/day, only the very best
|
|
2690
|
-
"
|
|
2691
|
-
"
|
|
3073
|
+
"Steady (~30/day, the default every install starts on), and Chill (~5/day, only the very best " +
|
|
3074
|
+
"candidates). Internally the modes are high|medium|low and both spellings are accepted. " +
|
|
3075
|
+
"action:'get' returns the current mode plus per-mode estimated " +
|
|
2692
3076
|
"posts/day computed from this install's own recent candidate pool (show those numbers with the " +
|
|
2693
3077
|
"Aggressive/Steady/Chill names when the user is choosing). Use when the user asks to post more, " +
|
|
2694
3078
|
"post less, slow down, be more aggressive, chill out, raise the quality bar, or change posting " +
|
|
@@ -2697,9 +3081,9 @@ tool("posting_volume", {
|
|
|
2697
3081
|
inputSchema: {
|
|
2698
3082
|
action: z.enum(["get", "set"]).default("get").describe("get = read mode + rates; set = change it"),
|
|
2699
3083
|
mode: z
|
|
2700
|
-
.enum(["aggressive", "steady", "chill", "high", "medium", "low"
|
|
3084
|
+
.enum(["aggressive", "steady", "chill", "high", "medium", "low"])
|
|
2701
3085
|
.optional()
|
|
2702
|
-
.describe("Required for action:'set'. aggressive=high, steady=medium, chill=low
|
|
3086
|
+
.describe("Required for action:'set'. aggressive=high, steady=medium, chill=low."),
|
|
2703
3087
|
},
|
|
2704
3088
|
}, async (args) => {
|
|
2705
3089
|
const action = args.action || "get";
|
|
@@ -2707,7 +3091,7 @@ tool("posting_volume", {
|
|
|
2707
3091
|
const ALIAS = { aggressive: "high", steady: "medium", chill: "low" };
|
|
2708
3092
|
if (action === "set") {
|
|
2709
3093
|
if (!args.mode) {
|
|
2710
|
-
return jsonContent({ error: "mode is required for action:'set' (aggressive|steady|chill
|
|
3094
|
+
return jsonContent({ error: "mode is required for action:'set' (aggressive|steady|chill)" });
|
|
2711
3095
|
}
|
|
2712
3096
|
const stored = ALIAS[String(args.mode)] || String(args.mode);
|
|
2713
3097
|
const r = await runPython("scripts/s4l_posting_mode.py", ["set", stored], {
|
|
@@ -3620,6 +4004,101 @@ async function ensureQueueKickerInstalled() {
|
|
|
3620
4004
|
return { ok: false, detail: e?.message || String(e) };
|
|
3621
4005
|
}
|
|
3622
4006
|
}
|
|
4007
|
+
// ---- Reddit discovery kicker (optional platform) ----------------------------
|
|
4008
|
+
// Mirrors ensureQueueKickerInstalled for the reddit lane: a 15-minute launchd
|
|
4009
|
+
// job that runs skill/run-reddit-search-launchd.sh (the detach wrapper; the
|
|
4010
|
+
// real cycle honors the global draft-only flag via s4l_mode.py, and its
|
|
4011
|
+
// drafting turn rides the same s4l-worker queue as X). Gates:
|
|
4012
|
+
// - runtime ready
|
|
4013
|
+
// - reddit connected (connected OR connected_idle; the profile persists)
|
|
4014
|
+
// - a managed project is configured and ready
|
|
4015
|
+
// It is NEVER an onboarding requirement: installs that never connect reddit
|
|
4016
|
+
// simply never get this plist.
|
|
4017
|
+
//
|
|
4018
|
+
// OPERATOR-MAC SAFETY: a hand-built com.m13v.social-reddit-search plist
|
|
4019
|
+
// pointing at ~/social-autoposter (StartCalendarInterval, custom env) exists
|
|
4020
|
+
// on the dev box. If an existing plist's program path is not the managed
|
|
4021
|
+
// package's wrapper, we log and leave it completely alone: no rewrite, no
|
|
4022
|
+
// reload, no unload.
|
|
4023
|
+
async function ensureRedditKickerInstalled() {
|
|
4024
|
+
try {
|
|
4025
|
+
if (process.platform !== "darwin")
|
|
4026
|
+
return { ok: false, detail: "not macOS" };
|
|
4027
|
+
if (!runtimeReady())
|
|
4028
|
+
return { ok: false, detail: "runtime not ready" };
|
|
4029
|
+
const productReady = listManagedProjectStatus().some((p) => p.ready);
|
|
4030
|
+
if (!productReady)
|
|
4031
|
+
return { ok: false, detail: "no ready project yet" };
|
|
4032
|
+
// Reddit session gate. status is read-only (never launches Chrome): a live
|
|
4033
|
+
// me.json validation when the harness is up, the on-disk profile cookie
|
|
4034
|
+
// check (connected_idle) when it isn't.
|
|
4035
|
+
const rs = await redditStatus();
|
|
4036
|
+
if (!rs.connected) {
|
|
4037
|
+
return { ok: false, detail: `reddit not connected (${rs.state})` };
|
|
4038
|
+
}
|
|
4039
|
+
const managedWrapper = path.join(repoDir(), "skill", "run-reddit-search-launchd.sh");
|
|
4040
|
+
// Foreign-plist guard BEFORE any write: if a plist already exists and its
|
|
4041
|
+
// ProgramArguments do not point at the managed package's wrapper, it is a
|
|
4042
|
+
// hand-built / operator arrangement. Leave it untouched.
|
|
4043
|
+
let cur = null;
|
|
4044
|
+
try {
|
|
4045
|
+
cur = fs.readFileSync(REDDIT_SEARCH_PLIST, "utf-8");
|
|
4046
|
+
}
|
|
4047
|
+
catch {
|
|
4048
|
+
cur = null;
|
|
4049
|
+
}
|
|
4050
|
+
if (cur !== null && !cur.includes(`<string>${managedWrapper}</string>`)) {
|
|
4051
|
+
const msg = `existing ${REDDIT_SEARCH_LABEL} plist points outside the managed package; leaving it untouched`;
|
|
4052
|
+
console.error(`[reddit-kicker] ${msg}`);
|
|
4053
|
+
return { ok: true, detail: `skip: ${msg}` };
|
|
4054
|
+
}
|
|
4055
|
+
const logDir = path.join(repoDir(), "skill", "logs");
|
|
4056
|
+
try {
|
|
4057
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
4058
|
+
}
|
|
4059
|
+
catch {
|
|
4060
|
+
/* best-effort */
|
|
4061
|
+
}
|
|
4062
|
+
const xml = plistXml({
|
|
4063
|
+
label: REDDIT_SEARCH_LABEL,
|
|
4064
|
+
programArgs: ["bash", managedWrapper],
|
|
4065
|
+
intervalSecs: REDDIT_SEARCH_INTERVAL_SECS,
|
|
4066
|
+
runAtLoad: false, // never fire a discovery cycle the instant Claude launches
|
|
4067
|
+
stdoutLog: path.join(logDir, "launchd-reddit-search-stdout.log"),
|
|
4068
|
+
stderrLog: path.join(logDir, "launchd-reddit-search-stderr.log"),
|
|
4069
|
+
// S4L_REPO_DIR + S4L_PYTHON are baked by plistXml. State dir + CDP url
|
|
4070
|
+
// pin the wrapper's children to the managed state and the harness port.
|
|
4071
|
+
extraEnv: {
|
|
4072
|
+
S4L_STATE_DIR: s4lStateDir(),
|
|
4073
|
+
REDDIT_CDP_URL: REDDIT_CDP_URL_DEFAULT,
|
|
4074
|
+
},
|
|
4075
|
+
// The wrapper double-forks the real cycle into its own session already,
|
|
4076
|
+
// but keep launchd away from the harness Chrome all the same.
|
|
4077
|
+
abandonProcessGroup: true,
|
|
4078
|
+
});
|
|
4079
|
+
const uid = process.getuid ? process.getuid() : 0;
|
|
4080
|
+
if (cur === xml) {
|
|
4081
|
+
const res = await loadPlist(REDDIT_SEARCH_LABEL, REDDIT_SEARCH_PLIST, uid);
|
|
4082
|
+
return { ok: true, detail: `current (load rc=${res.code})` };
|
|
4083
|
+
}
|
|
4084
|
+
if (cur !== null) {
|
|
4085
|
+
await unloadPlist(REDDIT_SEARCH_LABEL, REDDIT_SEARCH_PLIST, uid);
|
|
4086
|
+
}
|
|
4087
|
+
fs.mkdirSync(path.dirname(REDDIT_SEARCH_PLIST), { recursive: true });
|
|
4088
|
+
fs.writeFileSync(REDDIT_SEARCH_PLIST, xml, "utf-8");
|
|
4089
|
+
const res = await loadPlist(REDDIT_SEARCH_LABEL, REDDIT_SEARCH_PLIST, uid);
|
|
4090
|
+
// No first-run kickstart on purpose: reddit discovery is a background
|
|
4091
|
+
// add-on, and the connect flow just finished using the harness browser.
|
|
4092
|
+
// The first StartInterval tick (≤15 min) is soon enough.
|
|
4093
|
+
return {
|
|
4094
|
+
ok: true,
|
|
4095
|
+
detail: cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`,
|
|
4096
|
+
};
|
|
4097
|
+
}
|
|
4098
|
+
catch (e) {
|
|
4099
|
+
return { ok: false, detail: e?.message || String(e) };
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
3623
4102
|
// ---- launchd reaper: kill leaked agent-mode claude worker sessions ----------
|
|
3624
4103
|
// Independent guardrail (NOT gated on a project being ready): the leak happens
|
|
3625
4104
|
// whenever the scheduled-task workers fire, and a no-leak run is a cheap no-op.
|
|
@@ -4107,6 +4586,13 @@ async function buildSnapshot() {
|
|
|
4107
4586
|
completeOnboardingMilestone("runtime_ready");
|
|
4108
4587
|
if (snap.x_connected)
|
|
4109
4588
|
completeOnboardingMilestone("x_connected", { state: snap.x_state || "connected" });
|
|
4589
|
+
// Reddit (optional platform) heal: a live connected reading on a LATER poll
|
|
4590
|
+
// is itself persistence evidence, so it may also complete reddit_verified
|
|
4591
|
+
// for an install whose post-connect verify probe transiently failed.
|
|
4592
|
+
if (snap.reddit_connected) {
|
|
4593
|
+
completeOnboardingMilestone("reddit_connected", { state: snap.reddit_state || "connected" });
|
|
4594
|
+
completeOnboardingMilestone("reddit_verified", { source: "live_snapshot" });
|
|
4595
|
+
}
|
|
4110
4596
|
if ((snap.projects_ready || 0) > 0)
|
|
4111
4597
|
completeOnboardingMilestone("project_ready", { missing_count: 0 });
|
|
4112
4598
|
if (snap.schedule_state === "ok")
|
|
@@ -5150,6 +5636,12 @@ async function main() {
|
|
|
5150
5636
|
// disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
|
|
5151
5637
|
// the version first materialized at install). Synchronous + best-effort.
|
|
5152
5638
|
ensurePipelineCurrent();
|
|
5639
|
+
// Config's canonical home is the state dir (2026-07-13): migrate a legacy
|
|
5640
|
+
// in-repo config.json up on first boot after update, keep the legacy path
|
|
5641
|
+
// working via symlink, and self-heal a missing/wrong link on every boot.
|
|
5642
|
+
// Must run AFTER ensurePipelineCurrent() so repoDir() points at the final
|
|
5643
|
+
// materialized repo.
|
|
5644
|
+
console.error(`[social-autoposter-mcp] config home: ${ensureConfigInStateDir()}`);
|
|
5153
5645
|
// Deterministically provision the owned runtime on boot: whenever it isn't
|
|
5154
5646
|
// ready (a fresh install, or one interrupted mid-way because a step failed or
|
|
5155
5647
|
// Claude/the host died mid-install) kick the full install in the background
|
|
@@ -5205,6 +5697,17 @@ async function main() {
|
|
|
5205
5697
|
else {
|
|
5206
5698
|
console.error("[queue-worker] launchd kicker: skip (paused)");
|
|
5207
5699
|
}
|
|
5700
|
+
// Reddit discovery kicker (optional platform): installs only when reddit is
|
|
5701
|
+
// connected AND a project is ready; a box that never connected reddit is a
|
|
5702
|
+
// cheap no-op skip. Paused-gated like the X kicker (it produces drafts).
|
|
5703
|
+
if (!isPaused()) {
|
|
5704
|
+
void ensureRedditKickerInstalled()
|
|
5705
|
+
.then((r) => console.error(`[reddit-kicker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5706
|
+
.catch((e) => console.error("[reddit-kicker] kicker install failed:", e?.message || e));
|
|
5707
|
+
}
|
|
5708
|
+
else {
|
|
5709
|
+
console.error("[reddit-kicker] launchd kicker: skip (paused)");
|
|
5710
|
+
}
|
|
5208
5711
|
// Self-healing reaper for the agent-mode session leak the queue autopilot
|
|
5209
5712
|
// produces (finished `claude` worker sessions Desktop never tears down). A
|
|
5210
5713
|
// standalone guardrail; install unconditionally so it caps memory even on a
|