@m13v/s4l 1.7.5-rc.39 → 1.7.5-rc.4
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 +99 -488
- package/mcp/dist/panel.html +3 -2
- package/mcp/dist/runtime.js +12 -116
- package/mcp/dist/setup.js +5 -142
- package/mcp/dist/telemetry.js +3 -13
- package/mcp/dist/version.js +1 -7
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +2 -2
- package/mcp/menubar/s4l_card.py +83 -457
- package/mcp/menubar/s4l_menubar.py +171 -433
- package/mcp/menubar/s4l_state.py +190 -97
- package/mcp/package.json +1 -1
- package/mcp/shared/onboarding-ledger.cjs +4 -63
- package/package.json +1 -2
- package/scripts/active_experiments.py +7 -24
- package/scripts/claude_job.py +1 -4
- package/scripts/config.py +2 -20
- package/scripts/counterparty_history.py +6 -44
- package/scripts/engagement_styles.py +47 -32
- package/scripts/learned_preferences.py +14 -37
- package/scripts/memory_snapshot.py +0 -45
- package/scripts/merge_review_queue.py +1 -34
- package/scripts/post_reddit.py +36 -167
- package/scripts/reddit_browser.py +63 -62
- package/scripts/reddit_browser_fetch.py +64 -151
- package/scripts/release-mcpb.sh +2 -83
- package/scripts/reset-test-machine.sh +7 -13
- package/scripts/scan_twitter_thread_followups.py +1 -7
- package/scripts/scan_x_profile.py +8 -33
- package/scripts/score_twitter_candidates.py +5 -19
- package/scripts/setup_twitter_auth.py +3 -27
- package/scripts/snapshot.py +5 -103
- package/scripts/stats.py +13 -37
- package/scripts/twitter_browser.py +340 -129
- package/scripts/twitter_post_plan.py +0 -19
- package/scripts/twitter_scan.py +10 -75
- package/scripts/voice_exemplars.py +15 -51
- package/scripts/watchdog_hung_runs.py +9 -15
- package/skill/audit-reddit-resurrect.sh +2 -5
- package/skill/dm-outreach-reddit.sh +2 -5
- package/skill/engage-dm-replies.sh +2 -10
- package/skill/engage-reddit.sh +2 -5
- package/skill/lib/browser-launch.sh +1 -6
- package/skill/lib/harness-common.sh +7 -80
- package/skill/lib/linkedin-backend.sh +0 -18
- package/skill/lib/reddit-backend.sh +0 -21
- package/skill/link-edit-reddit.sh +2 -5
- package/skill/lock.sh +0 -57
- package/skill/run-reddit-search-launchd.sh +1 -4
- package/skill/run-reddit-search.sh +4 -31
- package/skill/run-reddit-threads.sh +2 -5
- package/skill/run-twitter-cycle.sh +137 -449
- package/mcp/dist/redditAuth.js +0 -115
- package/mcp/menubar/s4l_card_canvas.py +0 -712
- package/scripts/browser_mutex.py +0 -316
- package/scripts/engagement_signal_feedback.py +0 -256
- package/scripts/preview_expiry_card.py +0 -50
- package/scripts/setup_reddit_auth.py +0 -925
- package/scripts/test_no_silent_fallbacks.py +0 -281
- package/scripts/trigger_promo_lane_test.sh +0 -91
- package/scripts/twitter_prompt_sandbox.py +0 -163
package/mcp/dist/index.js
CHANGED
|
@@ -20,9 +20,8 @@ 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,
|
|
23
|
+
import { applySetup, resolveProject, hasReadyProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, normalizeStringList, } from "./setup.js";
|
|
24
24
|
import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
|
|
25
|
-
import { redditStatus, redditConnect, redditDetectSources, summarizeRedditAuth, } from "./redditAuth.js";
|
|
26
25
|
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, ensureHarnessPatched, retryProvisionIfStalled, } from "./runtime.js";
|
|
27
26
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
28
27
|
import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
|
|
@@ -69,17 +68,6 @@ const PHASE1_TASK_ID = "saps-phase1-query"; // legacy (was: "twitter-query" only
|
|
|
69
68
|
const PHASE2B_TASK_ID = "saps-phase2b-draft"; // legacy (was: "twitter-prep" only)
|
|
70
69
|
const TWITTER_AUTOPILOT_LABEL = "com.m13v.social-twitter-cycle";
|
|
71
70
|
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";
|
|
83
71
|
// Self-healing reaper for leaked Claude agent-mode worker sessions. The queue
|
|
84
72
|
// autopilot fires two scheduled tasks every ~1 min; each fire spawns a ~200 MB
|
|
85
73
|
// `claude` agent-mode session that finishes its one queue turn but never exits
|
|
@@ -325,29 +313,23 @@ const server = new McpServer({
|
|
|
325
313
|
"can run them hands-free on autopilot.\n\n" +
|
|
326
314
|
"ONBOARDING IS A TERMINAL GOAL. When the user asks to set up, install, configure, or onboard " +
|
|
327
315
|
"social-autoposter, keep taking the next safe action until the owned runtime is ready, a " +
|
|
328
|
-
"project is fully configured with seeded search topics,
|
|
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 " +
|
|
316
|
+
"project is fully configured with seeded search topics, X is connected with its real handle, AND " +
|
|
333
317
|
"the two draft-autopilot scheduled tasks have been created via queue_setup and are firing " +
|
|
334
318
|
"(see SCHEDULE THE AUTOPILOT below). The owned runtime (private Python + Chromium) provisions " +
|
|
335
319
|
"ITSELF automatically on boot — you do NOT install it. Just poll runtime action:'status' until it " +
|
|
336
320
|
"reports ready; only call runtime action:'install' or action:'doctor' to TROUBLESHOOT if status " +
|
|
337
321
|
"shows the boot provision failed or stalled. Do not ask whether to inspect " +
|
|
338
|
-
"status, repair a failed runtime, choose an auto-detected browser profile,
|
|
339
|
-
"research the product website, save conservative inferred fields, or seed " +
|
|
322
|
+
"status, repair a failed runtime, choose an auto-detected browser profile, connect " +
|
|
323
|
+
"X, scan the profile, research the product website, save conservative inferred fields, or seed " +
|
|
340
324
|
"topics. Do those things. The " +
|
|
341
325
|
"explicit setup request authorizes runtime repair and importing ONLY x.com/" +
|
|
342
326
|
"twitter.com session cookies into the managed browser: warn that macOS keychain prompts may " +
|
|
343
|
-
"appear, then proceed without a separate yes/no turn.
|
|
344
|
-
"above, ask at most one MORE bundled question, only " +
|
|
327
|
+
"appear, then proceed without a separate yes/no turn. Ask at most one bundled question, only " +
|
|
345
328
|
"when no product can be identified from config, context, the X profile, or public research, " +
|
|
346
329
|
"or pause for an unavoidable interactive login or OS prompt. Never auto-POST during setup. " +
|
|
347
330
|
"DO schedule the draft autopilot as the final step (once running it only queues drafts for " +
|
|
348
331
|
"approval, it never posts on its own). When setup reaches done (" +
|
|
349
|
-
"runtime ready, a project configured with seeded topics,
|
|
350
|
-
"two scheduled tasks " +
|
|
332
|
+
"runtime ready, a project configured with seeded topics, X connected, and the two scheduled tasks " +
|
|
351
333
|
"created and firing), call the `dashboard` tool so the user " +
|
|
352
334
|
"sees the finished setup rendered, then give " +
|
|
353
335
|
"the completion summary.\n\n" +
|
|
@@ -369,16 +351,6 @@ const server = new McpServer({
|
|
|
369
351
|
"old 'social-autoposter-autopilot' task — it is deprecated. Do NOT ask the user to review drafts " +
|
|
370
352
|
"or pause for input mid-onboarding; once the tasks are created and firing, setup is done. If the " +
|
|
371
353
|
"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" +
|
|
382
354
|
"BE PROACTIVE ABOUT MARKETING MOMENTS. Whenever the user ships, finishes, merges, or releases " +
|
|
383
355
|
"something worth talking about in this session (a new feature, a launch, a long-awaited fix, a " +
|
|
384
356
|
"milestone), don't wait to be asked: point it out to the user — the draft autopilot runs " +
|
|
@@ -389,8 +361,7 @@ const server = new McpServer({
|
|
|
389
361
|
"release, call the `runtime` tool (action:'version'). If `update_available` is true, tell the " +
|
|
390
362
|
"user and offer to run `runtime` with action:'update'. The `project_config` tool's status also " +
|
|
391
363
|
"surfaces `update_available` and an `update_hint`.\n\n" +
|
|
392
|
-
"TYPICAL FLOW:
|
|
393
|
-
"(connect the chosen platform(s) + scan the profile) -> `engagement_mode` (after the " +
|
|
364
|
+
"TYPICAL FLOW: `project_config` (connect X + scan the profile) -> `engagement_mode` (after the " +
|
|
394
365
|
"profile scan: personal-brand is ON by default, so ASK the user the ONE question — do they ALSO " +
|
|
395
366
|
"want to promote a product? — and call action:'set' with personal_brand:true and " +
|
|
396
367
|
"promotion:true|false; this provisions the persona) -> IF they wanted promotion, `project_config` " +
|
|
@@ -856,7 +827,7 @@ function readConfiguredTwitterHandle() {
|
|
|
856
827
|
if (env)
|
|
857
828
|
return env;
|
|
858
829
|
try {
|
|
859
|
-
const cfg = JSON.parse(fs.readFileSync(
|
|
830
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(repoDir(), "config.json"), "utf-8"));
|
|
860
831
|
const h = cfg?.accounts?.twitter?.handle;
|
|
861
832
|
const s = (typeof h === "string" ? h : "").trim().replace(/^@/, "");
|
|
862
833
|
return s || null;
|
|
@@ -1043,160 +1014,84 @@ async function postApproved(batchId, plan) {
|
|
|
1043
1014
|
const approvedTwitter = approved.filter((c) => c.platform !== "reddit");
|
|
1044
1015
|
let redditPosted = 0;
|
|
1045
1016
|
let redditFailed = 0;
|
|
1046
|
-
let redditSkippedPeerDrain = false;
|
|
1047
1017
|
if (approvedReddit.length) {
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
let redditPeerFresh = false;
|
|
1058
|
-
try {
|
|
1059
|
-
redditPeerFresh = Date.now() - fs.statSync(redditFlagPath).mtimeMs < 120_000;
|
|
1060
|
-
}
|
|
1061
|
-
catch {
|
|
1062
|
-
redditPeerFresh = false;
|
|
1063
|
-
}
|
|
1064
|
-
const stampRedditFlag = () => {
|
|
1065
|
-
try {
|
|
1066
|
-
fs.writeFileSync(redditFlagPath, JSON.stringify({ pid: process.pid, hb: Math.floor(Date.now() / 1000) }));
|
|
1067
|
-
}
|
|
1068
|
-
catch {
|
|
1069
|
-
/* best effort */
|
|
1018
|
+
for (const c of approvedReddit) {
|
|
1019
|
+
const cc = c;
|
|
1020
|
+
const dec = cc.reddit_decision;
|
|
1021
|
+
const meta = (cc.reddit_plan_meta || {});
|
|
1022
|
+
if (!dec) {
|
|
1023
|
+
c.terminal = true;
|
|
1024
|
+
c.terminal_reason = "reddit_decision_missing";
|
|
1025
|
+
redditFailed++;
|
|
1026
|
+
continue;
|
|
1070
1027
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1028
|
+
const miniPlan = {
|
|
1029
|
+
project_name: meta.project_name || cc.matched_project,
|
|
1030
|
+
batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
|
|
1031
|
+
phase: "draft",
|
|
1032
|
+
decisions: [dec],
|
|
1033
|
+
style_assignment: meta.style_assignment || {},
|
|
1034
|
+
generation_trace_path: meta.generation_trace_path,
|
|
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;
|
|
1078
1039
|
try {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
: dec.text,
|
|
1098
|
-
};
|
|
1099
|
-
if (cc.engagement_style)
|
|
1100
|
-
decPost.engagement_style = cc.engagement_style;
|
|
1101
|
-
// Two-draft cards (2026-07-15): a human draft-switch (see the generic
|
|
1102
|
-
// edit-handling above, `if (e.variant && Array.isArray(c.drafts))`)
|
|
1103
|
-
// stamps the CHOSEN draft's assigned_style/assigned_mode onto the card
|
|
1104
|
-
// (cc). Forward both into decPost so post_reddit.py's _post_iteration
|
|
1105
|
-
// validates/logs against whichever draft is ACTUALLY posting, not
|
|
1106
|
-
// whichever was recommended at plan-write time — mirrors
|
|
1107
|
-
// twitter_post_plan.py's identical per-candidate override.
|
|
1108
|
-
if (cc.assigned_style !== undefined)
|
|
1109
|
-
decPost.assigned_style = cc.assigned_style;
|
|
1110
|
-
if (cc.assigned_mode !== undefined)
|
|
1111
|
-
decPost.assigned_mode = cc.assigned_mode;
|
|
1112
|
-
const miniPlan = {
|
|
1113
|
-
project_name: meta.project_name || cc.matched_project,
|
|
1114
|
-
batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
|
|
1115
|
-
phase: "draft",
|
|
1116
|
-
decisions: [decPost],
|
|
1117
|
-
style_assignment: meta.style_assignment || {},
|
|
1118
|
-
generation_trace_path: meta.generation_trace_path,
|
|
1119
|
-
session_id: meta.session_id || meta.draft_session_id,
|
|
1120
|
-
};
|
|
1121
|
-
const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
|
|
1122
|
-
let r;
|
|
1123
|
-
try {
|
|
1124
|
-
fs.writeFileSync(tmpPlan, JSON.stringify(miniPlan));
|
|
1125
|
-
r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
|
|
1126
|
-
timeoutMs: 900_000,
|
|
1127
|
-
env: {
|
|
1128
|
-
REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
|
|
1129
|
-
// Reviewed posts never get the active-campaign suffix (same rule
|
|
1130
|
-
// as the twitter manual-approval path below).
|
|
1131
|
-
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1132
|
-
},
|
|
1133
|
-
onLine: (line) => {
|
|
1134
|
-
const t = line.replace(/\s+$/, "");
|
|
1135
|
-
if (t.trim())
|
|
1136
|
-
console.error(`[post-reddit] ${t}`);
|
|
1137
|
-
},
|
|
1138
|
-
});
|
|
1139
|
-
}
|
|
1140
|
-
catch (err) {
|
|
1141
|
-
r = { code: -1, stdout: "", stderr: String(err) };
|
|
1142
|
-
}
|
|
1143
|
-
finally {
|
|
1144
|
-
try {
|
|
1145
|
-
fs.unlinkSync(tmpPlan);
|
|
1146
|
-
}
|
|
1147
|
-
catch {
|
|
1148
|
-
/* best effort */
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
// post_reddit.py's summary marker: `[post_reddit] phase=post ... posted=N failed=M`
|
|
1152
|
-
const out = `${r.stdout}\n${r.stderr}`;
|
|
1153
|
-
const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
|
|
1154
|
-
if (r.code === 0 && postedN > 0) {
|
|
1155
|
-
c.posted = true;
|
|
1156
|
-
c.terminal = false;
|
|
1157
|
-
const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
|
|
1158
|
-
if (urlMatch)
|
|
1159
|
-
c.our_url = urlMatch[1];
|
|
1160
|
-
redditPosted++;
|
|
1161
|
-
}
|
|
1162
|
-
else {
|
|
1163
|
-
// Leave the approval sticky (approved && !posted && !terminal) so the
|
|
1164
|
-
// next post_drafts call retries, mirroring twitter's failed-drain
|
|
1165
|
-
// semantics; only stamp terminal on a conclusive CDP refusal.
|
|
1166
|
-
const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
|
|
1167
|
-
if (cdpReason && ["thread_locked", "thread_archived", "thread_not_found", "blocked_by_author"].includes(cdpReason)) {
|
|
1168
|
-
c.terminal = true;
|
|
1169
|
-
c.terminal_reason = `reddit_${cdpReason}`;
|
|
1170
|
-
}
|
|
1171
|
-
redditFailed++;
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1040
|
+
fs.writeFileSync(tmpPlan, JSON.stringify(miniPlan));
|
|
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
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
catch (err) {
|
|
1057
|
+
r = { code: -1, stdout: "", stderr: String(err) };
|
|
1174
1058
|
}
|
|
1175
1059
|
finally {
|
|
1176
|
-
// Clear only OUR stamp; a peer that took over mid-drain keeps its own.
|
|
1177
1060
|
try {
|
|
1178
|
-
|
|
1179
|
-
if (cur && cur.pid === process.pid)
|
|
1180
|
-
fs.unlinkSync(redditFlagPath);
|
|
1061
|
+
fs.unlinkSync(tmpPlan);
|
|
1181
1062
|
}
|
|
1182
1063
|
catch {
|
|
1183
1064
|
/* best effort */
|
|
1184
1065
|
}
|
|
1185
1066
|
}
|
|
1186
|
-
|
|
1067
|
+
// post_reddit.py's summary marker: `[post_reddit] phase=post ... posted=N failed=M`
|
|
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
|
+
}
|
|
1187
1089
|
}
|
|
1090
|
+
logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
|
|
1188
1091
|
}
|
|
1189
1092
|
if (approvedTwitter.length === 0) {
|
|
1190
1093
|
// All-reddit batch: persist the stamps and return without touching the
|
|
1191
1094
|
// twitter preflight/lock path.
|
|
1192
|
-
if (redditSkippedPeerDrain) {
|
|
1193
|
-
return {
|
|
1194
|
-
attempted: 0,
|
|
1195
|
-
posted: 0,
|
|
1196
|
-
exit_code: 0,
|
|
1197
|
-
summary: "reddit: skipped (peer drain active); cards stay approved for the next drain",
|
|
1198
|
-
};
|
|
1199
|
-
}
|
|
1200
1095
|
if (approvedReddit.length)
|
|
1201
1096
|
mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1202
1097
|
return {
|
|
@@ -1959,16 +1854,12 @@ tool("project_config", {
|
|
|
1959
1854
|
"products (call once per product, identified by name); fill or edit a project's fields " +
|
|
1960
1855
|
"INCREMENTALLY across several calls — pass whatever you have, it merges and tells you what's " +
|
|
1961
1856
|
"still missing.\n" +
|
|
1962
|
-
"2) Connect X/Twitter (action:'connect_x')
|
|
1963
|
-
"(the top-level setup flow asks which platform(s) — X, Reddit, or both — before this tool is " +
|
|
1964
|
-
"called for either): the autoposter posts through its OWN managed Chrome, " +
|
|
1857
|
+
"2) Connect X/Twitter (action:'connect_x'): the autoposter posts through its OWN managed Chrome, " +
|
|
1965
1858
|
"which needs your logged-in x.com session. This imports x.com/twitter.com cookies from your " +
|
|
1966
1859
|
"everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into that browser — nothing else is " +
|
|
1967
|
-
"touched.
|
|
1968
|
-
"Storage prompts may appear, then call action:'connect_x', confirm:true immediately
|
|
1969
|
-
"
|
|
1970
|
-
"action:'detect_x_sources' first and choose its recommendation instead of asking the user which " +
|
|
1971
|
-
"browser profile to import from.\n" +
|
|
1860
|
+
"touched. An explicit setup/connect request is authorization: briefly warn that macOS Safe " +
|
|
1861
|
+
"Storage prompts may appear, then call action:'connect_x', confirm:true immediately. Use " +
|
|
1862
|
+
"action:'detect_x_sources' first and choose its recommendation instead of asking the user.\n" +
|
|
1972
1863
|
"Call with status:true (or no name) to list every configured project, its remaining fields, AND " +
|
|
1973
1864
|
"whether X is connected. Use config, conversation context, profile_scan, and website research " +
|
|
1974
1865
|
"before asking for fields. Ask only if no product can be identified or an interactive login is " +
|
|
@@ -1977,14 +1868,7 @@ tool("project_config", {
|
|
|
1977
1868
|
inputSchema: {
|
|
1978
1869
|
status: z.boolean().optional(),
|
|
1979
1870
|
action: z
|
|
1980
|
-
.enum([
|
|
1981
|
-
"get",
|
|
1982
|
-
"connect_x",
|
|
1983
|
-
"detect_x_sources",
|
|
1984
|
-
"profile_scan",
|
|
1985
|
-
"connect_reddit",
|
|
1986
|
-
"detect_reddit_sources",
|
|
1987
|
-
])
|
|
1871
|
+
.enum(["get", "connect_x", "detect_x_sources", "profile_scan"])
|
|
1988
1872
|
.optional()
|
|
1989
1873
|
.describe("get = read the CURRENT SAVED VALUES of every project's editable fields (website, " +
|
|
1990
1874
|
"description, icp, voice, differentiator, search_topics, get_started_link, " +
|
|
@@ -2002,14 +1886,7 @@ tool("project_config", {
|
|
|
2002
1886
|
"replies to build a 'grounding truth' corpus. Use it to draft voice/icp/search_topics in " +
|
|
2003
1887
|
"the USER'S OWN register (their phrases, vibe, profession), then save a conservative best " +
|
|
2004
1888
|
"draft without requiring a confirmation round-trip. Returns {profile, posts, comments, " +
|
|
2005
|
-
"grounding_instructions}.
|
|
2006
|
-
"connect_reddit = call ONLY once the user has said they want Reddit connected (setup " +
|
|
2007
|
-
"completes with X-only, Reddit-only, or both — whichever they picked when asked up front; " +
|
|
2008
|
-
"if X connection fails after they chose it, offer Reddit as a fallback): import/validate " +
|
|
2009
|
-
"the user's reddit.com session in the " +
|
|
2010
|
-
"autoposter's managed reddit browser, same confirm-first shape as connect_x. " +
|
|
2011
|
-
"detect_reddit_sources = list the browsers/profiles the Reddit session can be imported " +
|
|
2012
|
-
"from (read-only, no keychain prompt)."),
|
|
1889
|
+
"grounding_instructions}."),
|
|
2013
1890
|
confirm: z
|
|
2014
1891
|
.boolean()
|
|
2015
1892
|
.optional()
|
|
@@ -2026,17 +1903,6 @@ tool("project_config", {
|
|
|
2026
1903
|
"X login window and waits for them to log in. By default (false), connect_x does NOT pop a " +
|
|
2027
1904
|
"browser window on an auto-import miss; it returns needs_login and you offer manual login as " +
|
|
2028
1905
|
"an opt-in. The login window still opens automatically if the user DENIED the keychain prompt."),
|
|
2029
|
-
reddit_source: z
|
|
2030
|
-
.string()
|
|
2031
|
-
.optional()
|
|
2032
|
-
.describe("Optional browser profile to import the Reddit session from, e.g. 'arc:Default', " +
|
|
2033
|
-
"'chrome:Profile 1'. Default: auto-detect the browser that holds a reddit.com session."),
|
|
2034
|
-
reddit_manual_login: z
|
|
2035
|
-
.boolean()
|
|
2036
|
-
.optional()
|
|
2037
|
-
.describe("Set true ONLY when the user explicitly wants to sign into Reddit by hand. Opens a focused " +
|
|
2038
|
-
"Reddit login window in the autoposter's reddit browser and waits. Same opt-in discipline " +
|
|
2039
|
-
"as x_manual_login."),
|
|
2040
1906
|
name: z
|
|
2041
1907
|
.string()
|
|
2042
1908
|
.optional()
|
|
@@ -2206,130 +2072,6 @@ tool("project_config", {
|
|
|
2206
2072
|
: "X is not connected yet. " + summarizeXAuth(r),
|
|
2207
2073
|
});
|
|
2208
2074
|
}
|
|
2209
|
-
// ---- List Reddit import sources (read-only, no keychain prompt) --------
|
|
2210
|
-
if (args.action === "detect_reddit_sources") {
|
|
2211
|
-
const r = await redditDetectSources();
|
|
2212
|
-
return jsonContent({
|
|
2213
|
-
action: "detect_reddit_sources",
|
|
2214
|
-
ok: r.ok,
|
|
2215
|
-
sources: r.sources,
|
|
2216
|
-
recommended: r.recommended,
|
|
2217
|
-
error: r.error,
|
|
2218
|
-
});
|
|
2219
|
-
}
|
|
2220
|
-
// ---- Connect Reddit: OPTIONAL platform, mirrors connect_x --------------
|
|
2221
|
-
// Preview-or-run, same confirm-first shape as connect_x. Reddit is never
|
|
2222
|
-
// required for onboarding completion; offer it only after X setup is done
|
|
2223
|
-
// and the user wants it.
|
|
2224
|
-
if (args.action === "connect_reddit") {
|
|
2225
|
-
if (args.confirm !== true) {
|
|
2226
|
-
// Cheap probe so the explanation reflects current state (no Chrome launch).
|
|
2227
|
-
const cur = await redditStatus();
|
|
2228
|
-
if (cur.connected) {
|
|
2229
|
-
return jsonContent({
|
|
2230
|
-
action: "connect_reddit",
|
|
2231
|
-
already_connected: true,
|
|
2232
|
-
state: cur.state,
|
|
2233
|
-
username: cur.username,
|
|
2234
|
-
note: "Reddit is already connected in the autoposter's reddit browser. Nothing to import.",
|
|
2235
|
-
});
|
|
2236
|
-
}
|
|
2237
|
-
return jsonContent({
|
|
2238
|
-
action: "connect_reddit",
|
|
2239
|
-
requires_confirmation: true,
|
|
2240
|
-
current_state: cur.state,
|
|
2241
|
-
what_will_happen: "To engage on Reddit for you, the autoposter uses its OWN managed Google Chrome (separate " +
|
|
2242
|
-
"from your everyday browser and from the X one). It needs your logged-in reddit.com " +
|
|
2243
|
-
"session. If you confirm, it will: (1) start that managed reddit browser if it isn't " +
|
|
2244
|
-
"running, (2) copy ONLY your reddit.com cookies (reddit_session, token_v2) from your " +
|
|
2245
|
-
"everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into it, and (3) verify you're " +
|
|
2246
|
-
"logged in via Reddit's own me.json. No other site's cookies are read, and your passwords " +
|
|
2247
|
-
"are never seen. If it can't import a valid session, a Chrome window will open for you to " +
|
|
2248
|
-
"sign in once.",
|
|
2249
|
-
keychain_prompt: "Reading the saved session requires macOS to unlock the browser's encrypted cookie store, so " +
|
|
2250
|
-
"one or more keychain prompts will appear (“... wants to use your confidential information " +
|
|
2251
|
-
"stored in '... Safe Storage' in your keychain”). This is expected. The user enters their Mac " +
|
|
2252
|
-
"login password and clicks Allow (or Always Allow to avoid repeats). If they use more than one " +
|
|
2253
|
-
"browser, the prompt can appear a few times, once per browser.",
|
|
2254
|
-
say_to_user: "Heads up: your Mac will pop up a keychain prompt asking to use your browser's Safe Storage. " +
|
|
2255
|
-
"That's just us reading your saved Reddit login, nothing else. Type your Mac login password and " +
|
|
2256
|
-
"click Allow (or Always Allow). If you use more than one browser you may see it a couple of " +
|
|
2257
|
-
"times, once per browser.",
|
|
2258
|
-
how_to_proceed: "If the user explicitly requested connecting Reddit, relay the say_to_user line as a brief " +
|
|
2259
|
-
"heads-up and immediately call project_config again with action:'connect_reddit', confirm:true; " +
|
|
2260
|
-
"do not wait for another yes/no reply. Optionally pass a reddit_source from " +
|
|
2261
|
-
"detect_reddit_sources. If the user only asked what connection would do, stop after this " +
|
|
2262
|
-
"preview. Reddit is OPTIONAL: never treat it as required for setup completion.",
|
|
2263
|
-
});
|
|
2264
|
-
}
|
|
2265
|
-
recordOnboardingAttempt("reddit_connected", {
|
|
2266
|
-
state: args.reddit_source ? "source_selected" : "auto_detect",
|
|
2267
|
-
});
|
|
2268
|
-
const r = await redditConnect(args.reddit_source, args.reddit_manual_login);
|
|
2269
|
-
let verified = false;
|
|
2270
|
-
let configNote;
|
|
2271
|
-
if (r.connected) {
|
|
2272
|
-
completeOnboardingMilestone("reddit_connected", { state: r.state });
|
|
2273
|
-
// Persist the discovered username through the server's config-write
|
|
2274
|
-
// path (accounts.reddit.username, the field account_resolver reads),
|
|
2275
|
-
// never overwriting a deliberately-set value.
|
|
2276
|
-
if (r.username) {
|
|
2277
|
-
const w = recordRedditAccount(r.username);
|
|
2278
|
-
configNote = w.detail;
|
|
2279
|
-
}
|
|
2280
|
-
// Doctor-style persistence re-check before reddit_verified: a fresh
|
|
2281
|
-
// read-only status probe confirms the session actually persisted
|
|
2282
|
-
// (live me.json or the on-disk profile cookie row) rather than
|
|
2283
|
-
// trusting the connect call's own success.
|
|
2284
|
-
const recheck = await redditStatus();
|
|
2285
|
-
if (recheck.connected) {
|
|
2286
|
-
completeOnboardingMilestone("reddit_verified", { state: recheck.state });
|
|
2287
|
-
verified = true;
|
|
2288
|
-
}
|
|
2289
|
-
else {
|
|
2290
|
-
recordOnboardingAttempt("reddit_verified", {
|
|
2291
|
-
state: recheck.state || "unverified",
|
|
2292
|
-
});
|
|
2293
|
-
}
|
|
2294
|
-
// Reddit is live: install the discovery kicker (idempotent; gated on
|
|
2295
|
-
// runtime + project readiness inside, and it never touches a
|
|
2296
|
-
// hand-built plist pointing outside the managed package).
|
|
2297
|
-
void ensureRedditKickerInstalled()
|
|
2298
|
-
.then((res) => console.error(`[reddit-kicker] post-connect install: ${res.ok ? "ok" : "skip"} (${res.detail})`))
|
|
2299
|
-
.catch((e) => console.error("[reddit-kicker] post-connect install failed:", e?.message || e));
|
|
2300
|
-
}
|
|
2301
|
-
else {
|
|
2302
|
-
blockOnboardingMilestone("reddit_connected", `reddit_${r.state || "not_connected"}`, r.error || r.note || summarizeRedditAuth(r), { state: r.state || "not_connected" });
|
|
2303
|
-
}
|
|
2304
|
-
return jsonContent({
|
|
2305
|
-
action: "connect_reddit",
|
|
2306
|
-
connected: r.connected,
|
|
2307
|
-
state: r.state,
|
|
2308
|
-
username: r.username,
|
|
2309
|
-
account_age_days: r.account_age_days,
|
|
2310
|
-
comment_karma: r.comment_karma,
|
|
2311
|
-
// Ready-to-relay expectation setter for fresh/low-karma accounts
|
|
2312
|
-
// (AutoMod gates those in most subreddits). Advisory only; never a
|
|
2313
|
-
// reason to block or undo the connect.
|
|
2314
|
-
warning: r.warning,
|
|
2315
|
-
verified,
|
|
2316
|
-
config: configNote,
|
|
2317
|
-
summary: summarizeRedditAuth(r),
|
|
2318
|
-
note: r.note,
|
|
2319
|
-
attempts: r.attempts,
|
|
2320
|
-
onboarding: onboardingSnapshot(),
|
|
2321
|
-
next_step: r.connected
|
|
2322
|
-
? "Reddit is connected. The reddit discovery job installs itself once a project is " +
|
|
2323
|
-
"configured; drafts flow into the same review cards as X and nothing posts without " +
|
|
2324
|
-
"approval." +
|
|
2325
|
-
(r.warning ? " Relay the `warning` field to the user so expectations are set." : "")
|
|
2326
|
-
: r.state === "needs_login"
|
|
2327
|
-
? "The user must finish signing in to reddit.com in the Chrome window that opened. Tell " +
|
|
2328
|
-
"them that single required action, then call project_config action:'connect_reddit', " +
|
|
2329
|
-
"confirm:true again."
|
|
2330
|
-
: "Reddit is not connected yet. " + summarizeRedditAuth(r),
|
|
2331
|
-
});
|
|
2332
|
-
}
|
|
2333
2075
|
// ---- Profile scan: grounding-truth corpus from the connected account ----
|
|
2334
2076
|
// Reuses the authenticated managed-Chrome session (so it must run AFTER a
|
|
2335
2077
|
// successful connect_x) to read the user's bio + recent posts + recent
|
|
@@ -2435,9 +2177,6 @@ tool("project_config", {
|
|
|
2435
2177
|
const projects = Array.isArray(snap.projects) ? snap.projects : [];
|
|
2436
2178
|
const rtReady = !!snap.runtime_ready;
|
|
2437
2179
|
const xConnected = !!snap.x_connected;
|
|
2438
|
-
const redditConnected = !!snap.reddit_connected;
|
|
2439
|
-
// Any-of platform completion: X or Reddit satisfies the platform leg.
|
|
2440
|
-
const anyPlatform = xConnected || redditConnected;
|
|
2441
2180
|
const configured = (snap.projects_ready || 0) > 0;
|
|
2442
2181
|
return jsonContent({
|
|
2443
2182
|
configured,
|
|
@@ -2446,9 +2185,6 @@ tool("project_config", {
|
|
|
2446
2185
|
x_connected: xConnected,
|
|
2447
2186
|
x_state: snap.x_state,
|
|
2448
2187
|
x_handle: snap.x_handle ?? null,
|
|
2449
|
-
reddit_connected: redditConnected,
|
|
2450
|
-
reddit_state: snap.reddit_state,
|
|
2451
|
-
reddit_username: snap.reddit_username ?? null,
|
|
2452
2188
|
setup_complete: !!snap.setup_complete,
|
|
2453
2189
|
mcp_version: snap.version,
|
|
2454
2190
|
latest_version: snap.latest_version,
|
|
@@ -2470,15 +2206,14 @@ tool("project_config", {
|
|
|
2470
2206
|
? "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."
|
|
2471
2207
|
: projects.length === 0
|
|
2472
2208
|
? "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." +
|
|
2473
|
-
(
|
|
2209
|
+
(xConnected ? "" : " X is not connected yet either — detect_x_sources, warn about keychain prompts, then run connect_x with confirm:true without a separate permission turn.")
|
|
2474
2210
|
: projects.every((p) => p.ready)
|
|
2475
|
-
? (
|
|
2476
|
-
? "All configured projects are ready and
|
|
2477
|
-
|
|
2478
|
-
".
|
|
2479
|
-
: "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.")
|
|
2211
|
+
? (xConnected
|
|
2212
|
+
? "All configured projects are ready and X is connected. 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."
|
|
2213
|
+
: "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
|
|
2214
|
+
"x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
|
|
2480
2215
|
: "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." +
|
|
2481
|
-
(
|
|
2216
|
+
(xConnected ? "" : " X is also not connected yet; detect sources and run connect_x with confirm:true."),
|
|
2482
2217
|
});
|
|
2483
2218
|
}
|
|
2484
2219
|
// Apply mode (incremental): merge whatever fields were supplied onto the
|
|
@@ -3079,9 +2814,9 @@ tool("posting_volume", {
|
|
|
3079
2814
|
title: "Read or set posting volume (Aggressive / Steady / Chill)",
|
|
3080
2815
|
description: "Read or set this install's posting-volume mode, the quality bar that decides how many drafts " +
|
|
3081
2816
|
"per day the twitter cycle produces. Three modes, shown to users as Aggressive (~100+ posts/day), " +
|
|
3082
|
-
"Steady (~30/day
|
|
3083
|
-
"
|
|
3084
|
-
"action:'get' returns the current mode plus per-mode estimated " +
|
|
2817
|
+
"Steady (~30/day), and Chill (~5/day, only the very best candidates); 'default' clears the " +
|
|
2818
|
+
"override so the cycle's built-in setting applies. Internally the modes are high|medium|low and " +
|
|
2819
|
+
"both spellings are accepted. action:'get' returns the current mode plus per-mode estimated " +
|
|
3085
2820
|
"posts/day computed from this install's own recent candidate pool (show those numbers with the " +
|
|
3086
2821
|
"Aggressive/Steady/Chill names when the user is choosing). Use when the user asks to post more, " +
|
|
3087
2822
|
"post less, slow down, be more aggressive, chill out, raise the quality bar, or change posting " +
|
|
@@ -3090,9 +2825,9 @@ tool("posting_volume", {
|
|
|
3090
2825
|
inputSchema: {
|
|
3091
2826
|
action: z.enum(["get", "set"]).default("get").describe("get = read mode + rates; set = change it"),
|
|
3092
2827
|
mode: z
|
|
3093
|
-
.enum(["aggressive", "steady", "chill", "high", "medium", "low"])
|
|
2828
|
+
.enum(["aggressive", "steady", "chill", "high", "medium", "low", "default"])
|
|
3094
2829
|
.optional()
|
|
3095
|
-
.describe("Required for action:'set'. aggressive=high, steady=medium, chill=low."),
|
|
2830
|
+
.describe("Required for action:'set'. aggressive=high, steady=medium, chill=low; 'default' clears the override."),
|
|
3096
2831
|
},
|
|
3097
2832
|
}, async (args) => {
|
|
3098
2833
|
const action = args.action || "get";
|
|
@@ -3100,7 +2835,7 @@ tool("posting_volume", {
|
|
|
3100
2835
|
const ALIAS = { aggressive: "high", steady: "medium", chill: "low" };
|
|
3101
2836
|
if (action === "set") {
|
|
3102
2837
|
if (!args.mode) {
|
|
3103
|
-
return jsonContent({ error: "mode is required for action:'set' (aggressive|steady|chill)" });
|
|
2838
|
+
return jsonContent({ error: "mode is required for action:'set' (aggressive|steady|chill|default)" });
|
|
3104
2839
|
}
|
|
3105
2840
|
const stored = ALIAS[String(args.mode)] || String(args.mode);
|
|
3106
2841
|
const r = await runPython("scripts/s4l_posting_mode.py", ["set", stored], {
|
|
@@ -4013,101 +3748,6 @@ async function ensureQueueKickerInstalled() {
|
|
|
4013
3748
|
return { ok: false, detail: e?.message || String(e) };
|
|
4014
3749
|
}
|
|
4015
3750
|
}
|
|
4016
|
-
// ---- Reddit discovery kicker (optional platform) ----------------------------
|
|
4017
|
-
// Mirrors ensureQueueKickerInstalled for the reddit lane: a 15-minute launchd
|
|
4018
|
-
// job that runs skill/run-reddit-search-launchd.sh (the detach wrapper; the
|
|
4019
|
-
// real cycle honors the global draft-only flag via s4l_mode.py, and its
|
|
4020
|
-
// drafting turn rides the same s4l-worker queue as X). Gates:
|
|
4021
|
-
// - runtime ready
|
|
4022
|
-
// - reddit connected (connected OR connected_idle; the profile persists)
|
|
4023
|
-
// - a managed project is configured and ready
|
|
4024
|
-
// It is NEVER an onboarding requirement: installs that never connect reddit
|
|
4025
|
-
// simply never get this plist.
|
|
4026
|
-
//
|
|
4027
|
-
// OPERATOR-MAC SAFETY: a hand-built com.m13v.social-reddit-search plist
|
|
4028
|
-
// pointing at ~/social-autoposter (StartCalendarInterval, custom env) exists
|
|
4029
|
-
// on the dev box. If an existing plist's program path is not the managed
|
|
4030
|
-
// package's wrapper, we log and leave it completely alone: no rewrite, no
|
|
4031
|
-
// reload, no unload.
|
|
4032
|
-
async function ensureRedditKickerInstalled() {
|
|
4033
|
-
try {
|
|
4034
|
-
if (process.platform !== "darwin")
|
|
4035
|
-
return { ok: false, detail: "not macOS" };
|
|
4036
|
-
if (!runtimeReady())
|
|
4037
|
-
return { ok: false, detail: "runtime not ready" };
|
|
4038
|
-
const productReady = listManagedProjectStatus().some((p) => p.ready);
|
|
4039
|
-
if (!productReady)
|
|
4040
|
-
return { ok: false, detail: "no ready project yet" };
|
|
4041
|
-
// Reddit session gate. status is read-only (never launches Chrome): a live
|
|
4042
|
-
// me.json validation when the harness is up, the on-disk profile cookie
|
|
4043
|
-
// check (connected_idle) when it isn't.
|
|
4044
|
-
const rs = await redditStatus();
|
|
4045
|
-
if (!rs.connected) {
|
|
4046
|
-
return { ok: false, detail: `reddit not connected (${rs.state})` };
|
|
4047
|
-
}
|
|
4048
|
-
const managedWrapper = path.join(repoDir(), "skill", "run-reddit-search-launchd.sh");
|
|
4049
|
-
// Foreign-plist guard BEFORE any write: if a plist already exists and its
|
|
4050
|
-
// ProgramArguments do not point at the managed package's wrapper, it is a
|
|
4051
|
-
// hand-built / operator arrangement. Leave it untouched.
|
|
4052
|
-
let cur = null;
|
|
4053
|
-
try {
|
|
4054
|
-
cur = fs.readFileSync(REDDIT_SEARCH_PLIST, "utf-8");
|
|
4055
|
-
}
|
|
4056
|
-
catch {
|
|
4057
|
-
cur = null;
|
|
4058
|
-
}
|
|
4059
|
-
if (cur !== null && !cur.includes(`<string>${managedWrapper}</string>`)) {
|
|
4060
|
-
const msg = `existing ${REDDIT_SEARCH_LABEL} plist points outside the managed package; leaving it untouched`;
|
|
4061
|
-
console.error(`[reddit-kicker] ${msg}`);
|
|
4062
|
-
return { ok: true, detail: `skip: ${msg}` };
|
|
4063
|
-
}
|
|
4064
|
-
const logDir = path.join(repoDir(), "skill", "logs");
|
|
4065
|
-
try {
|
|
4066
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
4067
|
-
}
|
|
4068
|
-
catch {
|
|
4069
|
-
/* best-effort */
|
|
4070
|
-
}
|
|
4071
|
-
const xml = plistXml({
|
|
4072
|
-
label: REDDIT_SEARCH_LABEL,
|
|
4073
|
-
programArgs: ["bash", managedWrapper],
|
|
4074
|
-
intervalSecs: REDDIT_SEARCH_INTERVAL_SECS,
|
|
4075
|
-
runAtLoad: false, // never fire a discovery cycle the instant Claude launches
|
|
4076
|
-
stdoutLog: path.join(logDir, "launchd-reddit-search-stdout.log"),
|
|
4077
|
-
stderrLog: path.join(logDir, "launchd-reddit-search-stderr.log"),
|
|
4078
|
-
// S4L_REPO_DIR + S4L_PYTHON are baked by plistXml. State dir + CDP url
|
|
4079
|
-
// pin the wrapper's children to the managed state and the harness port.
|
|
4080
|
-
extraEnv: {
|
|
4081
|
-
S4L_STATE_DIR: s4lStateDir(),
|
|
4082
|
-
REDDIT_CDP_URL: REDDIT_CDP_URL_DEFAULT,
|
|
4083
|
-
},
|
|
4084
|
-
// The wrapper double-forks the real cycle into its own session already,
|
|
4085
|
-
// but keep launchd away from the harness Chrome all the same.
|
|
4086
|
-
abandonProcessGroup: true,
|
|
4087
|
-
});
|
|
4088
|
-
const uid = process.getuid ? process.getuid() : 0;
|
|
4089
|
-
if (cur === xml) {
|
|
4090
|
-
const res = await loadPlist(REDDIT_SEARCH_LABEL, REDDIT_SEARCH_PLIST, uid);
|
|
4091
|
-
return { ok: true, detail: `current (load rc=${res.code})` };
|
|
4092
|
-
}
|
|
4093
|
-
if (cur !== null) {
|
|
4094
|
-
await unloadPlist(REDDIT_SEARCH_LABEL, REDDIT_SEARCH_PLIST, uid);
|
|
4095
|
-
}
|
|
4096
|
-
fs.mkdirSync(path.dirname(REDDIT_SEARCH_PLIST), { recursive: true });
|
|
4097
|
-
fs.writeFileSync(REDDIT_SEARCH_PLIST, xml, "utf-8");
|
|
4098
|
-
const res = await loadPlist(REDDIT_SEARCH_LABEL, REDDIT_SEARCH_PLIST, uid);
|
|
4099
|
-
// No first-run kickstart on purpose: reddit discovery is a background
|
|
4100
|
-
// add-on, and the connect flow just finished using the harness browser.
|
|
4101
|
-
// The first StartInterval tick (≤15 min) is soon enough.
|
|
4102
|
-
return {
|
|
4103
|
-
ok: true,
|
|
4104
|
-
detail: cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`,
|
|
4105
|
-
};
|
|
4106
|
-
}
|
|
4107
|
-
catch (e) {
|
|
4108
|
-
return { ok: false, detail: e?.message || String(e) };
|
|
4109
|
-
}
|
|
4110
|
-
}
|
|
4111
3751
|
// ---- launchd reaper: kill leaked agent-mode claude worker sessions ----------
|
|
4112
3752
|
// Independent guardrail (NOT gated on a project being ready): the leak happens
|
|
4113
3753
|
// whenever the scheduled-task workers fire, and a no-leak run is a cheap no-op.
|
|
@@ -4595,13 +4235,6 @@ async function buildSnapshot() {
|
|
|
4595
4235
|
completeOnboardingMilestone("runtime_ready");
|
|
4596
4236
|
if (snap.x_connected)
|
|
4597
4237
|
completeOnboardingMilestone("x_connected", { state: snap.x_state || "connected" });
|
|
4598
|
-
// Reddit (optional platform) heal: a live connected reading on a LATER poll
|
|
4599
|
-
// is itself persistence evidence, so it may also complete reddit_verified
|
|
4600
|
-
// for an install whose post-connect verify probe transiently failed.
|
|
4601
|
-
if (snap.reddit_connected) {
|
|
4602
|
-
completeOnboardingMilestone("reddit_connected", { state: snap.reddit_state || "connected" });
|
|
4603
|
-
completeOnboardingMilestone("reddit_verified", { source: "live_snapshot" });
|
|
4604
|
-
}
|
|
4605
4238
|
if ((snap.projects_ready || 0) > 0)
|
|
4606
4239
|
completeOnboardingMilestone("project_ready", { missing_count: 0 });
|
|
4607
4240
|
if (snap.schedule_state === "ok")
|
|
@@ -5645,12 +5278,6 @@ async function main() {
|
|
|
5645
5278
|
// disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
|
|
5646
5279
|
// the version first materialized at install). Synchronous + best-effort.
|
|
5647
5280
|
ensurePipelineCurrent();
|
|
5648
|
-
// Config's canonical home is the state dir (2026-07-13): migrate a legacy
|
|
5649
|
-
// in-repo config.json up on first boot after update, keep the legacy path
|
|
5650
|
-
// working via symlink, and self-heal a missing/wrong link on every boot.
|
|
5651
|
-
// Must run AFTER ensurePipelineCurrent() so repoDir() points at the final
|
|
5652
|
-
// materialized repo.
|
|
5653
|
-
console.error(`[social-autoposter-mcp] config home: ${ensureConfigInStateDir()}`);
|
|
5654
5281
|
// Deterministically provision the owned runtime on boot: whenever it isn't
|
|
5655
5282
|
// ready (a fresh install, or one interrupted mid-way because a step failed or
|
|
5656
5283
|
// Claude/the host died mid-install) kick the full install in the background
|
|
@@ -5706,17 +5333,6 @@ async function main() {
|
|
|
5706
5333
|
else {
|
|
5707
5334
|
console.error("[queue-worker] launchd kicker: skip (paused)");
|
|
5708
5335
|
}
|
|
5709
|
-
// Reddit discovery kicker (optional platform): installs only when reddit is
|
|
5710
|
-
// connected AND a project is ready; a box that never connected reddit is a
|
|
5711
|
-
// cheap no-op skip. Paused-gated like the X kicker (it produces drafts).
|
|
5712
|
-
if (!isPaused()) {
|
|
5713
|
-
void ensureRedditKickerInstalled()
|
|
5714
|
-
.then((r) => console.error(`[reddit-kicker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5715
|
-
.catch((e) => console.error("[reddit-kicker] kicker install failed:", e?.message || e));
|
|
5716
|
-
}
|
|
5717
|
-
else {
|
|
5718
|
-
console.error("[reddit-kicker] launchd kicker: skip (paused)");
|
|
5719
|
-
}
|
|
5720
5336
|
// Self-healing reaper for the agent-mode session leak the queue autopilot
|
|
5721
5337
|
// produces (finished `claude` worker sessions Desktop never tears down). A
|
|
5722
5338
|
// standalone guardrail; install unconditionally so it caps memory even on a
|
|
@@ -5893,22 +5509,17 @@ async function main() {
|
|
|
5893
5509
|
void sendStateSnapshot("startup");
|
|
5894
5510
|
const ss = setInterval(() => void sendStateSnapshot("interval"), 15 * 60_000);
|
|
5895
5511
|
ss.unref();
|
|
5896
|
-
//
|
|
5897
|
-
//
|
|
5898
|
-
//
|
|
5899
|
-
// the persona_corpus.txt exemplar section
|
|
5900
|
-
//
|
|
5901
|
-
//
|
|
5902
|
-
//
|
|
5903
|
-
//
|
|
5904
|
-
//
|
|
5905
|
-
//
|
|
5906
|
-
//
|
|
5907
|
-
// examples_scanner_version make it a no-op until either goes stale), and
|
|
5908
|
-
// when something DOES need it, it WAITS politely on the twitter-browser
|
|
5909
|
-
// lock, polling while holding nothing, until cycles/DM runs free the
|
|
5910
|
-
// browser, up to 12h before deferring to the next boot. Delayed so
|
|
5911
|
-
// boot-time work (runtime provision, kicker install) settles first.
|
|
5512
|
+
// One-shot voice-exemplar catch-up for installs onboarded BEFORE the
|
|
5513
|
+
// exemplar feature: if the persona has no voice.examples_scanned_at, rescan
|
|
5514
|
+
// the connected X profile and store the top-performing replies as
|
|
5515
|
+
// voice.examples + the persona_corpus.txt exemplar section. Additive only
|
|
5516
|
+
// (regenerates just its own marked corpus section; respects hand-written
|
|
5517
|
+
// examples) and self-limiting (no cooldown by design: success stamps
|
|
5518
|
+
// examples_scanned_at which makes later boots a no-op, and until then it
|
|
5519
|
+
// WAITS politely on the twitter-browser lock, polling while holding
|
|
5520
|
+
// nothing, until cycles/DM runs free the browser, up to 12h before
|
|
5521
|
+
// deferring to the next boot). Delayed so boot-time work (runtime
|
|
5522
|
+
// provision, kicker install) settles first.
|
|
5912
5523
|
const backfill = setTimeout(() => {
|
|
5913
5524
|
if (isPaused())
|
|
5914
5525
|
return;
|