@m13v/s4l 1.7.5-rc.28 → 1.7.5-rc.3

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.
Files changed (56) hide show
  1. package/mcp/dist/index.js +79 -582
  2. package/mcp/dist/panel.html +3 -2
  3. package/mcp/dist/runtime.js +12 -116
  4. package/mcp/dist/setup.js +5 -142
  5. package/mcp/dist/telemetry.js +3 -13
  6. package/mcp/dist/version.json +2 -2
  7. package/mcp/manifest.json +2 -2
  8. package/mcp/menubar/s4l_card.py +40 -331
  9. package/mcp/menubar/s4l_menubar.py +147 -173
  10. package/mcp/menubar/s4l_state.py +190 -97
  11. package/mcp/package.json +1 -1
  12. package/mcp/shared/onboarding-ledger.cjs +4 -63
  13. package/package.json +1 -2
  14. package/scripts/active_experiments.py +7 -24
  15. package/scripts/autopilot_stall_watch.py +6 -3
  16. package/scripts/claude_job.py +0 -21
  17. package/scripts/counterparty_history.py +6 -44
  18. package/scripts/engage_github.py +3 -7
  19. package/scripts/engage_reddit.py +3 -9
  20. package/scripts/engagement_styles.py +47 -32
  21. package/scripts/learned_preferences.py +14 -37
  22. package/scripts/memory_snapshot.py +0 -45
  23. package/scripts/merge_review_queue.py +3 -89
  24. package/scripts/post_reddit.py +46 -400
  25. package/scripts/reddit_browser.py +63 -62
  26. package/scripts/reddit_browser_fetch.py +64 -151
  27. package/scripts/release-mcpb.sh +2 -83
  28. package/scripts/reset-test-machine.sh +7 -13
  29. package/scripts/scan_twitter_thread_followups.py +1 -7
  30. package/scripts/scan_x_profile.py +8 -24
  31. package/scripts/schedule_state.py +1 -1
  32. package/scripts/score_twitter_candidates.py +5 -19
  33. package/scripts/snapshot.py +4 -96
  34. package/scripts/stats.py +13 -37
  35. package/scripts/twitter_browser.py +340 -129
  36. package/scripts/twitter_scan.py +10 -75
  37. package/scripts/watchdog_hung_runs.py +9 -15
  38. package/skill/lib/browser-launch.sh +1 -6
  39. package/skill/lib/linkedin-backend.sh +161 -61
  40. package/skill/lib/reddit-backend.sh +125 -39
  41. package/skill/lib/twitter-backend.sh +273 -51
  42. package/skill/lock.sh +0 -57
  43. package/skill/run-reddit-search-launchd.sh +1 -4
  44. package/skill/run-reddit-search.sh +2 -51
  45. package/skill/run-twitter-cycle.sh +96 -267
  46. package/mcp/dist/redditAuth.js +0 -115
  47. package/mcp/menubar/s4l_card_canvas.py +0 -1023
  48. package/scripts/browser_mutex.py +0 -316
  49. package/scripts/config.py +0 -118
  50. package/scripts/engagement_signal_feedback.py +0 -256
  51. package/scripts/preview_expiry_card.py +0 -50
  52. package/scripts/setup_reddit_auth.py +0 -925
  53. package/scripts/test_no_silent_fallbacks.py +0 -281
  54. package/scripts/trigger_promo_lane_test.sh +0 -91
  55. package/scripts/twitter_prompt_sandbox.py +0 -144
  56. package/skill/lib/harness-common.sh +0 -344
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, ensureConfigInStateDir, normalizeStringList, recordRedditAccount, } from "./setup.js";
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,9 +313,7 @@ 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, 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 " +
316
+ "project is fully configured with seeded search topics, X is connected with its real handle, AND " +
331
317
  "the two draft-autopilot scheduled tasks have been created via queue_setup and are firing " +
332
318
  "(see SCHEDULE THE AUTOPILOT below). The owned runtime (private Python + Chromium) provisions " +
333
319
  "ITSELF automatically on boot — you do NOT install it. Just poll runtime action:'status' until it " +
@@ -343,8 +329,7 @@ const server = new McpServer({
343
329
  "or pause for an unavoidable interactive login or OS prompt. Never auto-POST during setup. " +
344
330
  "DO schedule the draft autopilot as the final step (once running it only queues drafts for " +
345
331
  "approval, it never posts on its own). When setup reaches done (" +
346
- "runtime ready, a project configured with seeded topics, at least one platform connected, and the " +
347
- "two scheduled tasks " +
332
+ "runtime ready, a project configured with seeded topics, X connected, and the two scheduled tasks " +
348
333
  "created and firing), call the `dashboard` tool so the user " +
349
334
  "sees the finished setup rendered, then give " +
350
335
  "the completion summary.\n\n" +
@@ -366,16 +351,6 @@ const server = new McpServer({
366
351
  "old 'social-autoposter-autopilot' task — it is deprecated. Do NOT ask the user to review drafts " +
367
352
  "or pause for input mid-onboarding; once the tasks are created and firing, setup is done. If the " +
368
353
  "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" +
379
354
  "BE PROACTIVE ABOUT MARKETING MOMENTS. Whenever the user ships, finishes, merges, or releases " +
380
355
  "something worth talking about in this session (a new feature, a launch, a long-awaited fix, a " +
381
356
  "milestone), don't wait to be asked: point it out to the user — the draft autopilot runs " +
@@ -852,7 +827,7 @@ function readConfiguredTwitterHandle() {
852
827
  if (env)
853
828
  return env;
854
829
  try {
855
- const cfg = JSON.parse(fs.readFileSync(configPath(), "utf-8"));
830
+ const cfg = JSON.parse(fs.readFileSync(path.join(repoDir(), "config.json"), "utf-8"));
856
831
  const h = cfg?.accounts?.twitter?.handle;
857
832
  const s = (typeof h === "string" ? h : "").trim().replace(/^@/, "");
858
833
  return s || null;
@@ -910,57 +885,6 @@ function expiredStampOverridable(c) {
910
885
  c.posted !== true &&
911
886
  c.discard_reason === "backend_status_expired");
912
887
  }
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
- }
964
888
  async function postApproved(batchId, plan) {
965
889
  // Drain serialization (2026-07-06 incident). Every call drains the WHOLE
966
890
  // approved backlog, so overlapping drains are pure waste and actively harmful:
@@ -1027,181 +951,6 @@ async function postApproved(batchId, plan) {
1027
951
  }
1028
952
  if (approved.length === 0)
1029
953
  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
- }
1205
954
  // PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
1206
955
  // EVERY reply with no_account_configured and the whole batch skips — invisibly.
1207
956
  // If onboarding never persisted it, self-heal from the live session; if even that
@@ -1253,7 +1002,7 @@ async function postApproved(batchId, plan) {
1253
1002
  };
1254
1003
  }
1255
1004
  const approvedBatch = `${batchId}_approved`;
1256
- writePlan(approvedBatch, { ...plan, candidates: approvedTwitter });
1005
+ writePlan(approvedBatch, { ...plan, candidates: approved });
1257
1006
  // S4L_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
1258
1007
  // never get the active-campaign suffix (e.g. " written with ai") appended.
1259
1008
  // twitter_browser.py's reply handler reads this env (inherited through
@@ -1266,7 +1015,7 @@ async function postApproved(batchId, plan) {
1266
1015
  const failure = {
1267
1016
  posted: 0,
1268
1017
  skipped: 0,
1269
- failed: approvedTwitter.length,
1018
+ failed: approved.length,
1270
1019
  failure_reasons: "browser_bootstrap_failed",
1271
1020
  skip_reasons: "",
1272
1021
  };
@@ -1282,7 +1031,7 @@ async function postApproved(batchId, plan) {
1282
1031
  // (Karol 2026-07-09: 0/131 posted, exit=-1). 60s/card headroom covers
1283
1032
  // slow candidates; the 2h cap bounds a hung poster (the browser-lock
1284
1033
  // expiry and per-reply subprocess timeouts still fire underneath).
1285
- timeoutMs: Math.min(7_200_000, Math.max(900_000, approvedTwitter.length * 60_000)),
1034
+ timeoutMs: Math.min(7_200_000, Math.max(900_000, approved.length * 60_000)),
1286
1035
  env: ({
1287
1036
  S4L_SKIP_CAMPAIGN_SUFFIX: "1",
1288
1037
  // Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
@@ -1349,7 +1098,7 @@ async function postApproved(batchId, plan) {
1349
1098
  const postLogDir = path.join(repoDir(), "skill", "logs");
1350
1099
  fs.mkdirSync(postLogDir, { recursive: true });
1351
1100
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1352
- fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${approvedTwitter.length} exit=${res.code} ` +
1101
+ fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${approved.length} exit=${res.code} ` +
1353
1102
  `shell_lock=${heldShellLock}\n\n=== stdout ===\n${res.stdout}\n\n=== stderr ===\n${res.stderr}\n`);
1354
1103
  }
1355
1104
  catch {
@@ -1372,7 +1121,7 @@ async function postApproved(batchId, plan) {
1372
1121
  const realPosted = summObj && typeof summObj.posted === "number"
1373
1122
  ? summObj.posted
1374
1123
  : res.code === 0 && !summObj
1375
- ? approvedTwitter.length
1124
+ ? approved.length
1376
1125
  : 0;
1377
1126
  // Mark candidates according to the poster's per-candidate outcome. This keeps
1378
1127
  // the review queue honest: posted drafts disappear as posted, terminal skips
@@ -1393,14 +1142,14 @@ async function postApproved(batchId, plan) {
1393
1142
  .filter((r) => r.candidate_id && ["posted", "skipped", "failed"].includes(r.outcome))
1394
1143
  : parsePostCandidateResults(res.stdout);
1395
1144
  const approvedById = new Map();
1396
- approvedTwitter.forEach((c) => {
1145
+ approved.forEach((c) => {
1397
1146
  if (c.candidate_id !== undefined && c.candidate_id !== null)
1398
1147
  approvedById.set(String(c.candidate_id), c);
1399
1148
  });
1400
1149
  let touchedPlan = false;
1401
1150
  if (resultRows.length) {
1402
1151
  resultRows.forEach((r, idx) => {
1403
- const c = approvedById.get(r.candidate_id) || approvedTwitter[idx];
1152
+ const c = approvedById.get(r.candidate_id) || approved[idx];
1404
1153
  if (!c)
1405
1154
  return;
1406
1155
  if (r.outcome === "posted") {
@@ -1420,14 +1169,59 @@ async function postApproved(batchId, plan) {
1420
1169
  else if (realPosted > 0 || (res.code === 0 && !summObj)) {
1421
1170
  // Legacy fallback for older poster output without parseable per-candidate
1422
1171
  // lines. Mark only when we have no finer-grained signal.
1423
- for (const c of approvedTwitter)
1172
+ for (const c of approved)
1424
1173
  c.posted = true;
1425
1174
  touchedPlan = true;
1426
1175
  }
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);
1176
+ if (touchedPlan) {
1177
+ try {
1178
+ // Merge our stamps into a FRESH read of the store instead of rewriting the
1179
+ // whole plan from the copy we took minutes ago. The old whole-file write
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
+ }
1431
1225
  }
1432
1226
  // Post failures are HANDLED in the pipeline (it returns a count, never throws),
1433
1227
  // so they never reach Sentry on their own. Capture an explicit event whenever
@@ -1446,10 +1240,10 @@ async function postApproved(batchId, plan) {
1446
1240
  // actionable error on its own.
1447
1241
  const hasRealFailure = res.code !== 0 || Boolean(summObj?.failure_reasons);
1448
1242
  if (hasRealFailure) {
1449
- captureError(new Error(`post_drafts: ${realPosted}/${approvedTwitter.length} posted (exit=${res.code})`), {
1243
+ captureError(new Error(`post_drafts: ${realPosted}/${approved.length} posted (exit=${res.code})`), {
1450
1244
  component: "post",
1451
1245
  exit_code: String(res.code),
1452
- attempted: String(approvedTwitter.length),
1246
+ attempted: String(approved.length),
1453
1247
  posted: String(realPosted),
1454
1248
  failure_reasons: String(summObj?.failure_reasons || ""),
1455
1249
  skip_reasons: String(summObj?.skip_reasons || ""),
@@ -1460,11 +1254,9 @@ async function postApproved(batchId, plan) {
1460
1254
  void flushLogs();
1461
1255
  return {
1462
1256
  attempted: approved.length,
1463
- posted: realPosted + redditPosted,
1257
+ posted: realPosted,
1464
1258
  exit_code: res.code,
1465
- summary: approvedReddit.length > 0
1466
- ? { twitter: summary, reddit: { posted: redditPosted, failed: redditFailed } }
1467
- : summary,
1259
+ summary,
1468
1260
  stderr_tail: res.stderr.split("\n").slice(-8).join("\n"),
1469
1261
  };
1470
1262
  }
@@ -1969,14 +1761,7 @@ tool("project_config", {
1969
1761
  inputSchema: {
1970
1762
  status: z.boolean().optional(),
1971
1763
  action: z
1972
- .enum([
1973
- "get",
1974
- "connect_x",
1975
- "detect_x_sources",
1976
- "profile_scan",
1977
- "connect_reddit",
1978
- "detect_reddit_sources",
1979
- ])
1764
+ .enum(["get", "connect_x", "detect_x_sources", "profile_scan"])
1980
1765
  .optional()
1981
1766
  .describe("get = read the CURRENT SAVED VALUES of every project's editable fields (website, " +
1982
1767
  "description, icp, voice, differentiator, search_topics, get_started_link, " +
@@ -1994,13 +1779,7 @@ tool("project_config", {
1994
1779
  "replies to build a 'grounding truth' corpus. Use it to draft voice/icp/search_topics in " +
1995
1780
  "the USER'S OWN register (their phrases, vibe, profession), then save a conservative best " +
1996
1781
  "draft without requiring a confirmation round-trip. Returns {profile, posts, comments, " +
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)."),
1782
+ "grounding_instructions}."),
2004
1783
  confirm: z
2005
1784
  .boolean()
2006
1785
  .optional()
@@ -2017,17 +1796,6 @@ tool("project_config", {
2017
1796
  "X login window and waits for them to log in. By default (false), connect_x does NOT pop a " +
2018
1797
  "browser window on an auto-import miss; it returns needs_login and you offer manual login as " +
2019
1798
  "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."),
2031
1799
  name: z
2032
1800
  .string()
2033
1801
  .optional()
@@ -2197,130 +1965,6 @@ tool("project_config", {
2197
1965
  : "X is not connected yet. " + summarizeXAuth(r),
2198
1966
  });
2199
1967
  }
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
- }
2324
1968
  // ---- Profile scan: grounding-truth corpus from the connected account ----
2325
1969
  // Reuses the authenticated managed-Chrome session (so it must run AFTER a
2326
1970
  // successful connect_x) to read the user's bio + recent posts + recent
@@ -2426,9 +2070,6 @@ tool("project_config", {
2426
2070
  const projects = Array.isArray(snap.projects) ? snap.projects : [];
2427
2071
  const rtReady = !!snap.runtime_ready;
2428
2072
  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;
2432
2073
  const configured = (snap.projects_ready || 0) > 0;
2433
2074
  return jsonContent({
2434
2075
  configured,
@@ -2437,9 +2078,6 @@ tool("project_config", {
2437
2078
  x_connected: xConnected,
2438
2079
  x_state: snap.x_state,
2439
2080
  x_handle: snap.x_handle ?? null,
2440
- reddit_connected: redditConnected,
2441
- reddit_state: snap.reddit_state,
2442
- reddit_username: snap.reddit_username ?? null,
2443
2081
  setup_complete: !!snap.setup_complete,
2444
2082
  mcp_version: snap.version,
2445
2083
  latest_version: snap.latest_version,
@@ -2461,15 +2099,14 @@ tool("project_config", {
2461
2099
  ? "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."
2462
2100
  : projects.length === 0
2463
2101
  ? "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." +
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.")
2102
+ (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.")
2465
2103
  : projects.every((p) => p.ready)
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.")
2104
+ ? (xConnected
2105
+ ? "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."
2106
+ : "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
2107
+ "x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
2471
2108
  : "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." +
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."),
2109
+ (xConnected ? "" : " X is also not connected yet; detect sources and run connect_x with confirm:true."),
2473
2110
  });
2474
2111
  }
2475
2112
  // Apply mode (incremental): merge whatever fields were supplied onto the
@@ -2695,27 +2332,6 @@ tool("post_drafts", {
2695
2332
  }
2696
2333
  c.approved = false;
2697
2334
  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
- }
2719
2335
  });
2720
2336
  // Apply edits first; an edited draft is always posted.
2721
2337
  const approve = new Set();
@@ -3070,9 +2686,9 @@ tool("posting_volume", {
3070
2686
  title: "Read or set posting volume (Aggressive / Steady / Chill)",
3071
2687
  description: "Read or set this install's posting-volume mode, the quality bar that decides how many drafts " +
3072
2688
  "per day the twitter cycle produces. Three modes, shown to users as Aggressive (~100+ posts/day), " +
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 " +
2689
+ "Steady (~30/day), and Chill (~5/day, only the very best candidates); 'default' clears the " +
2690
+ "override so the cycle's built-in setting applies. Internally the modes are high|medium|low and " +
2691
+ "both spellings are accepted. action:'get' returns the current mode plus per-mode estimated " +
3076
2692
  "posts/day computed from this install's own recent candidate pool (show those numbers with the " +
3077
2693
  "Aggressive/Steady/Chill names when the user is choosing). Use when the user asks to post more, " +
3078
2694
  "post less, slow down, be more aggressive, chill out, raise the quality bar, or change posting " +
@@ -3081,9 +2697,9 @@ tool("posting_volume", {
3081
2697
  inputSchema: {
3082
2698
  action: z.enum(["get", "set"]).default("get").describe("get = read mode + rates; set = change it"),
3083
2699
  mode: z
3084
- .enum(["aggressive", "steady", "chill", "high", "medium", "low"])
2700
+ .enum(["aggressive", "steady", "chill", "high", "medium", "low", "default"])
3085
2701
  .optional()
3086
- .describe("Required for action:'set'. aggressive=high, steady=medium, chill=low."),
2702
+ .describe("Required for action:'set'. aggressive=high, steady=medium, chill=low; 'default' clears the override."),
3087
2703
  },
3088
2704
  }, async (args) => {
3089
2705
  const action = args.action || "get";
@@ -3091,7 +2707,7 @@ tool("posting_volume", {
3091
2707
  const ALIAS = { aggressive: "high", steady: "medium", chill: "low" };
3092
2708
  if (action === "set") {
3093
2709
  if (!args.mode) {
3094
- return jsonContent({ error: "mode is required for action:'set' (aggressive|steady|chill)" });
2710
+ return jsonContent({ error: "mode is required for action:'set' (aggressive|steady|chill|default)" });
3095
2711
  }
3096
2712
  const stored = ALIAS[String(args.mode)] || String(args.mode);
3097
2713
  const r = await runPython("scripts/s4l_posting_mode.py", ["set", stored], {
@@ -4004,101 +3620,6 @@ async function ensureQueueKickerInstalled() {
4004
3620
  return { ok: false, detail: e?.message || String(e) };
4005
3621
  }
4006
3622
  }
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
- }
4102
3623
  // ---- launchd reaper: kill leaked agent-mode claude worker sessions ----------
4103
3624
  // Independent guardrail (NOT gated on a project being ready): the leak happens
4104
3625
  // whenever the scheduled-task workers fire, and a no-leak run is a cheap no-op.
@@ -4586,13 +4107,6 @@ async function buildSnapshot() {
4586
4107
  completeOnboardingMilestone("runtime_ready");
4587
4108
  if (snap.x_connected)
4588
4109
  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
- }
4596
4110
  if ((snap.projects_ready || 0) > 0)
4597
4111
  completeOnboardingMilestone("project_ready", { missing_count: 0 });
4598
4112
  if (snap.schedule_state === "ok")
@@ -5636,12 +5150,6 @@ async function main() {
5636
5150
  // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
5637
5151
  // the version first materialized at install). Synchronous + best-effort.
5638
5152
  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()}`);
5645
5153
  // Deterministically provision the owned runtime on boot: whenever it isn't
5646
5154
  // ready (a fresh install, or one interrupted mid-way because a step failed or
5647
5155
  // Claude/the host died mid-install) kick the full install in the background
@@ -5697,17 +5205,6 @@ async function main() {
5697
5205
  else {
5698
5206
  console.error("[queue-worker] launchd kicker: skip (paused)");
5699
5207
  }
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
- }
5711
5208
  // Self-healing reaper for the agent-mode session leak the queue autopilot
5712
5209
  // produces (finished `claude` worker sessions Desktop never tears down). A
5713
5210
  // standalone guardrail; install unconditionally so it caps memory even on a