@m13v/s4l 1.7.6-rc.3 → 1.7.6-rc.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/mcp/dist/index.js +418 -123
  2. package/mcp/dist/panel.html +31 -17
  3. package/mcp/dist/setup.js +27 -7
  4. package/mcp/dist/telemetry.js +2 -1
  5. package/mcp/dist/version.json +2 -2
  6. package/mcp/manifest.json +9 -1
  7. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  8. package/mcp/menubar/s4l_card.py +63 -4
  9. package/mcp/menubar/s4l_card_canvas.py +62 -11
  10. package/mcp/menubar/s4l_menubar.py +406 -76
  11. package/mcp/menubar/s4l_state.py +54 -130
  12. package/mcp/package.json +1 -1
  13. package/package.json +1 -1
  14. package/scripts/account_resolver.py +58 -14
  15. package/scripts/amplitude_24h_signups.py +7 -4
  16. package/scripts/amplitude_signups.py +7 -4
  17. package/scripts/autopilot_stall_watch.py +8 -1
  18. package/scripts/browser_lifecycle.py +90 -0
  19. package/scripts/cdp_ready_check.py +81 -50
  20. package/scripts/dm_conversation.py +15 -12
  21. package/scripts/draft_prompt_core.py +104 -17
  22. package/scripts/engage_github.py +11 -5
  23. package/scripts/engage_reddit.py +12 -5
  24. package/scripts/engagement_signal_feedback.py +1 -4
  25. package/scripts/engagement_styles.py +135 -98
  26. package/scripts/feedback_digest.py +54 -11
  27. package/scripts/find_threads.py +9 -7
  28. package/scripts/github_tools.py +7 -4
  29. package/scripts/learned_preferences.py +112 -78
  30. package/scripts/li_discovery.py +4 -1
  31. package/scripts/live_status.py +151 -0
  32. package/scripts/merge_review_queue.py +197 -129
  33. package/scripts/pick_project.py +7 -4
  34. package/scripts/pick_thread_target.py +7 -4
  35. package/scripts/pick_twitter_thread_target.py +7 -4
  36. package/scripts/platform_strike_events.py +54 -5
  37. package/scripts/post_github.py +17 -5
  38. package/scripts/post_reddit.py +27 -7
  39. package/scripts/preview_reddit_card.py +38 -0
  40. package/scripts/project_stats.py +7 -4
  41. package/scripts/reap_stale_claude_sessions.py +7 -6
  42. package/scripts/reddit_ban_check.py +220 -0
  43. package/scripts/reddit_browser.py +56 -19
  44. package/scripts/reddit_chat_sync.py +10 -11
  45. package/scripts/reddit_tools.py +4 -2
  46. package/scripts/release-mcpb.sh +47 -7
  47. package/scripts/run_moltbook_cycle.py +9 -5
  48. package/scripts/scan_dm_candidates.py +15 -9
  49. package/scripts/scan_github_replies.py +9 -7
  50. package/scripts/scan_moltbook_replies.py +7 -6
  51. package/scripts/scan_reddit_replies.py +11 -7
  52. package/scripts/scan_twitter_mentions_browser.py +7 -6
  53. package/scripts/scan_twitter_thread_followups.py +7 -6
  54. package/scripts/schedule_state.py +8 -4
  55. package/scripts/score_twitter_candidates.py +4 -2
  56. package/scripts/setup_twitter_auth.py +20 -96
  57. package/scripts/snapshot.py +19 -0
  58. package/scripts/stats.py +12 -10
  59. package/scripts/store_patch.py +111 -0
  60. package/scripts/strike_alert.py +161 -12
  61. package/scripts/test_cross_language_parity.py +185 -0
  62. package/scripts/test_no_silent_fallbacks.py +84 -0
  63. package/scripts/test_stall_watch_batch_progression.py +99 -0
  64. package/scripts/top_performers.py +24 -0
  65. package/scripts/twitter_browser.py +22 -52
  66. package/skill/archive-old-logs.sh +24 -0
  67. package/skill/lib/reddit-backend.sh +1 -0
  68. package/skill/run-draft-and-publish.sh +9 -0
  69. package/skill/run-twitter-cycle.sh +146 -66
package/mcp/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import { screencast, bringBrowserToFront } from "./screencast.js";
19
19
  import os from "node:os";
20
20
  import path from "node:path";
21
21
  import fs from "node:fs";
22
- import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
22
+ import { repoDir, runPython, run, readPlan, writePlan, planPath, TMP_DIR, } from "./repo.js";
23
23
  import { applySetup, resolveProject, hasReadyProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, ensureConfigInStateDir, normalizeStringList, recordRedditAccount, } from "./setup.js";
24
24
  import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
25
25
  import { redditStatus, redditConnect, redditDetectSources, summarizeRedditAuth, } from "./redditAuth.js";
@@ -760,7 +760,9 @@ function renderDraftsTable(plan) {
760
760
  // Number by FULL-array index (matches post_drafts + the menu bar), then drop
761
761
  // already-finished entries so the cards only show what's still pending.
762
762
  .map((c, i) => ({ c, n: i + 1 }))
763
- .filter((e) => e.c.posted !== true && e.c.terminal !== true && e.c.approved !== true)
763
+ // awaiting_review is the ONLY state the review surfaces present (same rule
764
+ // as the menubar cards and the review-request.json count).
765
+ .filter((e) => candidateState(e.c) === "awaiting_review")
764
766
  // The queue is append-only; newest drafts have the highest stable index.
765
767
  // Show those first so review starts with likely-live tweets instead of stale
766
768
  // low-number drafts that have been sitting around for hours.
@@ -846,40 +848,28 @@ function parsePostCandidateResults(stdout) {
846
848
  }
847
849
  return [...byId.values()];
848
850
  }
849
- // Resolve the configured posting handle the SAME way account_resolver.py does:
850
- // AUTOPOSTER_TWITTER_HANDLE env first, then config.json accounts.twitter.handle.
851
- // Returns the bare handle (no @) or null. The post preflight uses it so a missing
852
- // handle fails ONCE, loudly, instead of as N silent per-reply no_account_configured
853
- // skips (twitter_browser.py refuses to post with no handle — no impersonation).
854
- function readConfiguredTwitterHandle() {
851
+ // Resolve the posting @handle through the ONE resolver (scripts/account_resolver.py:
852
+ // env AUTOPOSTER_TWITTER_HANDLE -> config.json accounts.twitter.handle -> the durable
853
+ // connect-time cookie mirror). Deferring to it, instead of re-reading config.json here,
854
+ // keeps a single resolution path shared with the poster (twitter_browser.our_handle),
855
+ // and keeps the handle in a single durable home (the cookie mirror) rather than a
856
+ // second copy in config.json. Returns the bare handle (no @) or null; the preflight
857
+ // refuses loudly on null so a missing handle fails ONCE, not as N silent per-reply
858
+ // no_account_configured skips (and never as a hardcoded impersonation fallback).
859
+ async function resolvePostingHandle() {
855
860
  const env = (process.env.AUTOPOSTER_TWITTER_HANDLE || "").trim().replace(/^@/, "");
856
861
  if (env)
857
862
  return env;
858
863
  try {
859
- const cfg = JSON.parse(fs.readFileSync(configPath(), "utf-8"));
860
- const h = cfg?.accounts?.twitter?.handle;
861
- const s = (typeof h === "string" ? h : "").trim().replace(/^@/, "");
862
- return s || null;
863
- }
864
- catch {
865
- return null;
866
- }
867
- }
868
- // Self-heal a missing handle: read the live logged-in @handle from the managed
869
- // Chrome and persist it to config.json accounts.twitter.handle. This is ground
870
- // truth (the poster posts through that exact session), NOT a guess — so it's safe
871
- // where a hardcoded fallback would not be. Closes the onboarding gap where
872
- // connect_x's best-effort handle capture silently no-op'd and left posting dead.
873
- // Best-effort; never throws — the caller re-checks and refuses loudly if still unset.
874
- async function ensurePostingHandle() {
875
- try {
876
- await runPython("scripts/setup_twitter_auth.py", ["resolve-handle"], {
877
- timeoutMs: 60_000,
864
+ const r = await runPython("scripts/account_resolver.py", ["twitter"], {
865
+ timeoutMs: 30_000,
878
866
  env: ({ S4L_REPO_DIR: repoDir(), PATH: pipelinePath() }),
879
867
  });
868
+ const h = (r.stdout || "").trim().replace(/^@/, "");
869
+ return h || null;
880
870
  }
881
871
  catch {
882
- /* best effort */
872
+ return null;
883
873
  }
884
874
  }
885
875
  async function ensureTwitterBrowserForPost() {
@@ -909,12 +899,68 @@ async function ensureTwitterBrowserForPost() {
909
899
  // thread still exists. Without this override, a card approved while (or just
910
900
  // before) the sync stamped it is refused as already-decided and the approval
911
901
  // silently no-ops (2 of 3 approvals lost on 2026-07-10).
902
+ // Give-up bound for approved cards whose post attempts keep failing
903
+ // transiently (browser lock contention, timeouts). 5 attempts spans several
904
+ // drain cycles — plenty for genuine transients to clear — while guaranteeing
905
+ // no card can retry forever (2026-07-17 zombie-card incident).
906
+ const MAX_POST_ATTEMPTS = 5;
912
907
  function expiredStampOverridable(c) {
913
908
  return (c.terminal === true &&
914
909
  c.posted !== true &&
915
910
  c.discard_reason === "backend_status_expired");
916
911
  }
917
- function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
912
+ // Prompt-sandbox replay cards (run-twitter-cycle.sh S4L_SANDBOX_CANDIDATES_FILE)
913
+ // carry experiments.sandbox=true; older sandbox rows predate that stamp but all
914
+ // use the synthetic >=900,000,000 id range twitter_prompt_sandbox.py assigns.
915
+ function isSandboxCandidate(c) {
916
+ const exps = c.experiments;
917
+ if (exps && typeof exps === "object" && exps.sandbox)
918
+ return true;
919
+ const id = Number(c.candidate_id);
920
+ return Number.isFinite(id) && id >= 900_000_000;
921
+ }
922
+ function candidateState(c) {
923
+ if (c.posted === true)
924
+ return "posted";
925
+ if (c.terminal === true)
926
+ return "terminal";
927
+ if (c.post_failed)
928
+ return "post_failed";
929
+ if (c.approved === true)
930
+ return "approved";
931
+ return "awaiting_review";
932
+ }
933
+ // Write field patches into the review-queue store UNDER ITS LOCK by shelling
934
+ // to scripts/store_patch.py, which takes the same fcntl.flock the menubar's
935
+ // _store_update and merge_review_queue.py hold around their read-modify-write.
936
+ // Node has no native flock, and this process writing the store directly was
937
+ // the last unlocked writer (the race that erased posted stamps on 2026-07-17).
938
+ // Returns false on any failure so callers can fall back to the legacy write.
939
+ async function patchReviewStore(patches) {
940
+ if (!patches.length)
941
+ return true;
942
+ const tmp = path.join(TMP_DIR, `s4l-store-patches-${process.pid}-${Date.now()}.json`);
943
+ try {
944
+ fs.writeFileSync(tmp, JSON.stringify({ patches }), "utf-8");
945
+ const res = await runPython("scripts/store_patch.py", [tmp], {
946
+ timeoutMs: 30_000,
947
+ env: { S4L_REPO_DIR: repoDir(), PATH: pipelinePath() },
948
+ });
949
+ return res.code === 0;
950
+ }
951
+ catch {
952
+ return false;
953
+ }
954
+ finally {
955
+ try {
956
+ fs.unlinkSync(tmp);
957
+ }
958
+ catch {
959
+ /* best effort */
960
+ }
961
+ }
962
+ }
963
+ async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
918
964
  // Merge posted/terminal stamps into a FRESH read of the store instead of
919
965
  // rewriting the whole plan from the copy taken minutes ago. The old
920
966
  // whole-file write was last-writer-wins: while a batch posted, the menubar
@@ -926,28 +972,106 @@ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
926
972
  // overwrites a fresh `posted=true`. Fallback: candidates without a
927
973
  // candidate_id can't be matched into the fresh copy, so keep the legacy
928
974
  // whole-plan write for those older plans.
975
+ //
976
+ // Review-queue store: go through the LOCKED patch path (store_patch.py)
977
+ // first. The fresh-read merge below closes most of the race window but not
978
+ // all of it — a menubar decision landing between our readPlan and writePlan
979
+ // still gets erased. The locked path holds the store's flock for the whole
980
+ // read-mutate-replace, applies to every sibling row sharing a candidate_id,
981
+ // and enforces the same posted-sticky rules. Legacy path stays as the
982
+ // fallback and for per-batch /tmp plans (single writer, no lock needed).
983
+ try {
984
+ const mergeableForPatch = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
985
+ if (batchId === REVIEW_QUEUE_ID && mergeableForPatch) {
986
+ const patches = stamped.map((c) => {
987
+ const set = {};
988
+ const unset = [];
989
+ if (c.posted === true) {
990
+ set.posted = true;
991
+ set.terminal = false;
992
+ if (c.our_url)
993
+ set.our_url = c.our_url;
994
+ // Clear a stale failure stamp too: the menubar's per-card call can
995
+ // see posted=0 (the batch drain posted it under a different call)
996
+ // and stamp post_failed on a card that IS live (seen on 565462,
997
+ // 2026-07-17). posted is the settled truth; the residue just adds a
998
+ // false "didn't post" signal to dashboards/notifications.
999
+ unset.push("discard_reason", "post_failed", "post_error");
1000
+ }
1001
+ else if (c.terminal === true) {
1002
+ set.terminal = true;
1003
+ set.terminal_reason = c.terminal_reason ?? null;
1004
+ // See the zombie-card note in the legacy branch below: a terminal
1005
+ // from a real post attempt must clear the overridable expiry stamp.
1006
+ unset.push("discard_reason");
1007
+ }
1008
+ if (typeof c.post_attempts === "number")
1009
+ set.post_attempts = c.post_attempts;
1010
+ return { candidate_id: c.candidate_id, set, unset };
1011
+ });
1012
+ if (await patchReviewStore(patches))
1013
+ return;
1014
+ console.error("[post] store_patch.py failed; falling back to unlocked stamp merge");
1015
+ }
1016
+ }
1017
+ catch {
1018
+ /* fall through to the legacy write */
1019
+ }
929
1020
  try {
930
1021
  const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
931
1022
  const fresh = mergeable ? readPlan(batchId) : null;
932
1023
  if (fresh && Array.isArray(fresh.candidates)) {
1024
+ // candidate_id is NOT unique in the review-queue store: sandbox reruns
1025
+ // and re-merged drafts append sibling rows with the same id. Stamping
1026
+ // only one sibling (the old Map single-slot) left the others matching
1027
+ // the drain's approved && !posted && !terminal filter, so the backlog
1028
+ // re-drained the same candidate every heartbeat forever (2026-07-17
1029
+ // incident: 23-card loop at 60s cadence). Stamp EVERY row with the id.
933
1030
  const freshById = new Map();
934
1031
  fresh.candidates.forEach((c) => {
935
- if (c.candidate_id !== undefined && c.candidate_id !== null)
936
- freshById.set(String(c.candidate_id), c);
1032
+ if (c.candidate_id !== undefined && c.candidate_id !== null) {
1033
+ const key = String(c.candidate_id);
1034
+ const list = freshById.get(key);
1035
+ if (list)
1036
+ list.push(c);
1037
+ else
1038
+ freshById.set(key, [c]);
1039
+ }
937
1040
  });
938
1041
  for (const c of stamped) {
939
- const f = freshById.get(String(c.candidate_id));
940
- if (!f)
941
- continue;
942
- if (c.posted === true) {
943
- f.posted = true;
944
- f.terminal = false;
945
- if (c.our_url)
946
- f.our_url = c.our_url;
947
- }
948
- else if (c.terminal === true && f.posted !== true) {
949
- f.terminal = true;
950
- f.terminal_reason = c.terminal_reason;
1042
+ for (const f of freshById.get(String(c.candidate_id)) ?? []) {
1043
+ if (c.posted === true) {
1044
+ f.posted = true;
1045
+ f.terminal = false;
1046
+ if (c.our_url)
1047
+ f.our_url = c.our_url;
1048
+ // A post outcome closes the card's history: the pre-approval
1049
+ // freshness stamp must not survive it. Same for a stale
1050
+ // post_failed from a peer call's posted=0 misattribution (see
1051
+ // the locked-patch branch above).
1052
+ delete f.discard_reason;
1053
+ delete f.post_failed;
1054
+ delete f.post_error;
1055
+ }
1056
+ else if (c.terminal === true && f.posted !== true) {
1057
+ f.terminal = true;
1058
+ f.terminal_reason = c.terminal_reason;
1059
+ // CRITICAL (2026-07-17 Nhat zombie-card incident, 438 retries over
1060
+ // 5 days): a stale discard_reason="backend_status_expired" left on
1061
+ // the fresh copy makes expiredStampOverridable() treat THIS
1062
+ // post-outcome terminal as overridable, so every later drain
1063
+ // resurrects the card, re-posts, dedup-skips, and re-stamps —
1064
+ // forever. A terminal that came from an actual post attempt is
1065
+ // final; delete the expiry stamp so the override can never fire
1066
+ // on it again. (The drain's own `delete c.discard_reason` happens
1067
+ // on an in-memory copy that is never the write source; this line
1068
+ // is the one that persists.)
1069
+ delete f.discard_reason;
1070
+ }
1071
+ // Carry the retry counter so the give-up bound survives across
1072
+ // drains (each drain reads the store fresh).
1073
+ if (typeof c.post_attempts === "number" && c.post_attempts > (f.post_attempts || 0))
1074
+ f.post_attempts = c.post_attempts;
951
1075
  }
952
1076
  }
953
1077
  writePlan(batchId, fresh);
@@ -1041,9 +1165,29 @@ async function postApproved(batchId, plan) {
1041
1165
  // An approved card whose only blocker is an overridable backend-expiry stamp is
1042
1166
  // included: approval outranks the freshness gate (see expiredStampOverridable),
1043
1167
  // and the stamp is cleared so every downstream terminal check agrees it's live.
1044
- const approved = (plan.candidates || []).filter((c) => c.approved === true &&
1045
- c.posted !== true &&
1046
- (c.terminal !== true || expiredStampOverridable(c)));
1168
+ // Drain-eligible, in canonical state terms (candidateState — keep in lockstep
1169
+ // with s4l_state.candidate_state). DELIBERATE deviations from a plain
1170
+ // state === "approved" check, each load-bearing:
1171
+ // - post_failed cards DO drain again (the transient-retry path, bounded by
1172
+ // MAX_POST_ATTEMPTS at result time). The menubar's restart-resume path
1173
+ // (store_pending_posts) skips them by design; this drain is where they
1174
+ // get their bounded retries.
1175
+ // - an approved card whose ONLY terminal stamp is the freshness gate's
1176
+ // backend_status_expired is resurrected: the human approval outranks the
1177
+ // expiry (2 of 3 approvals lost on 2026-07-10 without this).
1178
+ // - sandbox replays never drain (twitter_post_plan.py post_one()
1179
+ // hard-refuses them, so draining one is pure churn and can loop forever
1180
+ // if its terminal stamp loses a store-write race).
1181
+ const approved = (plan.candidates || []).filter((c) => {
1182
+ if (isSandboxCandidate(c))
1183
+ return false;
1184
+ if (c.approved !== true)
1185
+ return false;
1186
+ const s = candidateState(c);
1187
+ return (s === "approved" ||
1188
+ s === "post_failed" ||
1189
+ (s === "terminal" && expiredStampOverridable(c)));
1190
+ });
1047
1191
  for (const c of approved) {
1048
1192
  if (c.terminal === true) {
1049
1193
  c.terminal = false;
@@ -1241,7 +1385,7 @@ async function postApproved(batchId, plan) {
1241
1385
  };
1242
1386
  }
1243
1387
  if (approvedReddit.length)
1244
- mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1388
+ await mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1245
1389
  return {
1246
1390
  attempted: approvedReddit.length,
1247
1391
  posted: redditPosted,
@@ -1254,9 +1398,8 @@ async function postApproved(batchId, plan) {
1254
1398
  // If onboarding never persisted it, self-heal from the live session; if even that
1255
1399
  // can't determine it, refuse here with a clear reason rather than launching a
1256
1400
  // poster that silently burns the whole batch.
1257
- if (!readConfiguredTwitterHandle())
1258
- await ensurePostingHandle();
1259
- if (!readConfiguredTwitterHandle()) {
1401
+ const postingHandle = await resolvePostingHandle();
1402
+ if (!postingHandle) {
1260
1403
  postingActive = false;
1261
1404
  stopPostingFlagHeartbeat();
1262
1405
  return {
@@ -1264,9 +1407,9 @@ async function postApproved(batchId, plan) {
1264
1407
  exit_code: 0,
1265
1408
  posted: 0,
1266
1409
  summary: "no_account_configured",
1267
- error: "X is connected but no posting @handle is configured, so every reply would be refused " +
1268
- "(no_account_configured). Re-run project_config action:'connect_x' to capture the handle, " +
1269
- "or set accounts.twitter.handle in config.json.",
1410
+ error: "X is connected but no posting @handle could be resolved (env, config, or the " +
1411
+ "connect-time cookie mirror), so every reply would be refused (no_account_configured). " +
1412
+ "Re-run project_config action:'connect_x' to re-capture the handle.",
1270
1413
  };
1271
1414
  }
1272
1415
  // Mark posting active so the draft-cycle scan DEFERS launching any scan for the
@@ -1482,6 +1625,21 @@ async function postApproved(batchId, plan) {
1482
1625
  // here (found 2026-07-16) silently and permanently discarded 7 real
1483
1626
  // approved drafts on nothing worse than lock contention — Reddit's
1484
1627
  // equivalent transient failures self-healed on the very next drain.
1628
+ //
1629
+ // BOUNDED (2026-07-17): sticky is right, sticky-forever is not — an
1630
+ // unbounded retry is a zombie generator (one card retried 438 times
1631
+ // over 5 days on the Nhat install). Count the transient failures and
1632
+ // give up loudly after MAX_POST_ATTEMPTS; the terminal_reason keeps
1633
+ // the last failure visible so the give-up is diagnosable, and the
1634
+ // on-disk post-events trail records it for forensics.
1635
+ const attempts = (typeof c.post_attempts === "number" ? c.post_attempts : 0) + 1;
1636
+ c.post_attempts = attempts;
1637
+ if (attempts >= MAX_POST_ATTEMPTS) {
1638
+ c.terminal = true;
1639
+ c.terminal_reason = `gave_up_after_${attempts}_failed_attempts:${r.reason || "failed"}`;
1640
+ console.error(`[post] giving up on candidate ${r.candidate_id} after ${attempts} failed attempts (last: ${r.reason || "failed"})`);
1641
+ logPostEvent(`retry_budget_exhausted candidate=${r.candidate_id} attempts=${attempts} last=${r.reason || "failed"}`);
1642
+ }
1485
1643
  touchedPlan = true;
1486
1644
  }
1487
1645
  });
@@ -1496,7 +1654,7 @@ async function postApproved(batchId, plan) {
1496
1654
  // Reddit stamps (set in the reddit drain above) merge alongside the twitter
1497
1655
  // ones: `approved` here spans both platforms.
1498
1656
  if (touchedPlan || redditPosted || redditFailed) {
1499
- mergeApprovedStampsIntoStore(batchId, plan, approved);
1657
+ await mergeApprovedStampsIntoStore(batchId, plan, approved);
1500
1658
  }
1501
1659
  // Post failures are HANDLED in the pipeline (it returns a count, never throws),
1502
1660
  // so they never reach Sentry on their own. Capture an explicit event whenever
@@ -2333,6 +2491,7 @@ tool("project_config", {
2333
2491
  const r = await redditConnect(args.reddit_source, args.reddit_manual_login);
2334
2492
  let verified = false;
2335
2493
  let configNote;
2494
+ let redditKicker;
2336
2495
  if (r.connected) {
2337
2496
  completeOnboardingMilestone("reddit_connected", { state: r.state });
2338
2497
  // Persist the discovered username through the server's config-write
@@ -2357,11 +2516,18 @@ tool("project_config", {
2357
2516
  });
2358
2517
  }
2359
2518
  // Reddit is live: install the discovery kicker (idempotent; gated on
2360
- // runtime + project readiness inside, and it never touches a
2361
- // hand-built plist pointing outside the managed package).
2362
- void ensureRedditKickerInstalled()
2363
- .then((res) => console.error(`[reddit-kicker] post-connect install: ${res.ok ? "ok" : "skip"} (${res.detail})`))
2364
- .catch((e) => console.error("[reddit-kicker] post-connect install failed:", e?.message || e));
2519
+ // runtime + draftable-lane readiness inside, and it never touches a
2520
+ // hand-built plist pointing outside the managed package). Await it and
2521
+ // surface the result on the response so a skip (e.g. no ready project AND
2522
+ // no active persona) is VISIBLE instead of failing silently to stderr.
2523
+ try {
2524
+ redditKicker = await ensureRedditKickerInstalled();
2525
+ console.error(`[reddit-kicker] post-connect install: ${redditKicker.ok ? "ok" : "skip"} (${redditKicker.detail})`);
2526
+ }
2527
+ catch (e) {
2528
+ redditKicker = { ok: false, detail: e?.message || String(e) };
2529
+ console.error("[reddit-kicker] post-connect install failed:", e?.message || e);
2530
+ }
2365
2531
  }
2366
2532
  else {
2367
2533
  blockOnboardingMilestone("reddit_connected", `reddit_${r.state || "not_connected"}`, r.error || r.note || summarizeRedditAuth(r), { state: r.state || "not_connected" });
@@ -2382,11 +2548,20 @@ tool("project_config", {
2382
2548
  summary: summarizeRedditAuth(r),
2383
2549
  note: r.note,
2384
2550
  attempts: r.attempts,
2551
+ // The launchd discovery kicker's install result (or skip reason), so the
2552
+ // agent can SEE whether reddit drafting is actually scheduled rather than
2553
+ // assuming "connected" means "producing drafts".
2554
+ discovery_job: redditKicker,
2385
2555
  onboarding: onboardingSnapshot(),
2386
2556
  next_step: r.connected
2387
- ? "Reddit is connected. The reddit discovery job installs itself once a project is " +
2388
- "configured; drafts flow into the same review cards as X and nothing posts without " +
2389
- "approval." +
2557
+ ? (redditKicker?.ok
2558
+ ? "Reddit is connected and the discovery job is scheduled (runs every ~15 min); " +
2559
+ "drafts flow into the same review cards as X and nothing posts without approval."
2560
+ : "Reddit is connected, but the discovery job is NOT scheduled yet (" +
2561
+ (redditKicker?.detail || "unknown") + "). It installs automatically once a product " +
2562
+ "project is ready OR personal_brand mode is on with a ready persona; until then no " +
2563
+ "reddit drafts will appear. Drafts, once flowing, land in the same review cards as X " +
2564
+ "and nothing posts without approval.") +
2390
2565
  (r.warning ? " Relay the `warning` field to the user so expectations are set." : "")
2391
2566
  : r.state === "needs_login"
2392
2567
  ? "The user must finish signing in to reddit.com in the Chrome window that opened. Tell " +
@@ -2741,6 +2916,16 @@ tool("post_drafts", {
2741
2916
  const total = candidates.length;
2742
2917
  const warnings = [];
2743
2918
  const inRange = (n) => n >= 1 && n <= total;
2919
+ // Review-queue store: snapshot every row now so the write below can be a
2920
+ // field-level DIFF applied under the store lock (store_patch.py) instead
2921
+ // of an unlocked whole-file replace. This function holds its in-memory
2922
+ // plan across user think-time; a whole-file write here erased any menubar
2923
+ // decision or merge that landed in between (the 2026-07-17 truth-loss
2924
+ // family). Non-store batches keep the plain write: single writer.
2925
+ const isStoreBatch = batch_id === REVIEW_QUEUE_ID;
2926
+ const rowsBefore = isStoreBatch
2927
+ ? candidates.map((c) => JSON.stringify(c))
2928
+ : [];
2744
2929
  // ---- Rejections: durable + final --------------------------------------
2745
2930
  // A rejected draft is marked terminal so it NEVER re-appears for review and is
2746
2931
  // never posted. A reject overrides any earlier approve on the same card.
@@ -2751,7 +2936,7 @@ tool("post_drafts", {
2751
2936
  return;
2752
2937
  }
2753
2938
  const c = candidates[n - 1];
2754
- if (c.posted === true) {
2939
+ if (candidateState(c) === "posted") {
2755
2940
  warnings.push(`#${n} already posted; not rejecting`);
2756
2941
  return;
2757
2942
  }
@@ -2877,7 +3062,40 @@ tool("post_drafts", {
2877
3062
  if (c)
2878
3063
  c.approved = true;
2879
3064
  });
2880
- writePlan(batch_id, plan);
3065
+ // Persist the decision mutations. Store batch: diff each row against its
3066
+ // snapshot and apply only the changed fields under the store lock, so a
3067
+ // concurrent menubar decision or merge is never erased. Anything else
3068
+ // (per-batch /tmp plans): plain write, single writer.
3069
+ let storeWriteDone = false;
3070
+ if (isStoreBatch) {
3071
+ const patches = [];
3072
+ candidates.forEach((c, i) => {
3073
+ const beforeRaw = rowsBefore[i];
3074
+ const afterRaw = JSON.stringify(c);
3075
+ if (beforeRaw === afterRaw)
3076
+ return;
3077
+ const before = JSON.parse(beforeRaw ?? "{}");
3078
+ const after = JSON.parse(afterRaw);
3079
+ const set = {};
3080
+ const unset = [];
3081
+ for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
3082
+ if (!(k in after) || after[k] === undefined) {
3083
+ if (k in before)
3084
+ unset.push(k);
3085
+ }
3086
+ else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
3087
+ set[k] = after[k];
3088
+ }
3089
+ }
3090
+ if (Object.keys(set).length || unset.length)
3091
+ patches.push({ candidate_id: c.candidate_id ?? null, n: i + 1, set, unset });
3092
+ });
3093
+ storeWriteDone = await patchReviewStore(patches);
3094
+ if (!storeWriteDone)
3095
+ console.error("[post_drafts] store_patch.py failed; falling back to unlocked plan write");
3096
+ }
3097
+ if (!storeWriteDone)
3098
+ writePlan(batch_id, plan);
2881
3099
  if (approve.size === 0) {
2882
3100
  return jsonContent({
2883
3101
  batch_id,
@@ -3149,6 +3367,70 @@ tool("restart_menubar", {
3149
3367
  menubar_running: running,
3150
3368
  });
3151
3369
  });
3370
+ // ---- scan_now: fire one scanner cycle immediately --------------------------
3371
+ // The scanner (the twitter scan+draft cycle) is what drives the whole pipeline;
3372
+ // it runs on the launchd kicker's fixed StartInterval. This nudges launchd to
3373
+ // fire it RIGHT NOW via `launchctl kickstart` — the EXACT same mechanism the
3374
+ // first-run onboarding kick uses (ensureQueueKickerInstalled): the job runs
3375
+ // THROUGH launchd with its full baked env + the run-*-singleton.sh lock, so it
3376
+ // is NOT a bare `bash run-twitter-cycle.sh` (which would leave an empty-plan
3377
+ // artifact). launchd keeps a single instance, so if a cycle is already running
3378
+ // this is a harmless no-op; while paused the kicker is unloaded, so we report
3379
+ // that rather than kick nothing.
3380
+ //
3381
+ // This is deliberately NOT the removed run_draft_cycle / draft_cycle tool: it
3382
+ // spawns no warm in-chat Claude draft session, it only asks launchd to run the
3383
+ // already-scheduled cycle one interval early. Do NOT re-introduce a draft_cycle.
3384
+ tool("scan_now", {
3385
+ title: "Run the scanner now",
3386
+ description: "Fire ONE scanner cycle immediately instead of waiting for the next scheduled run. The scanner " +
3387
+ "(the twitter scan+draft cycle) is what discovers threads and kicks off drafting, so this is the " +
3388
+ "'run now' control behind the menu bar + dashboard button. It nudges the existing launchd job to " +
3389
+ "run one interval early; it does NOT change the schedule. If a cycle is already running it's a " +
3390
+ "no-op, and if S4L is paused it reports that (nothing runs while paused). Use when the user asks " +
3391
+ "to scan now, run the scanner, run now, or check for new threads immediately.",
3392
+ inputSchema: {},
3393
+ }, async () => {
3394
+ if (isPaused()) {
3395
+ return jsonContent({
3396
+ ok: false,
3397
+ paused: true,
3398
+ detail: "S4L is paused — resume it first, then the scanner can run.",
3399
+ });
3400
+ }
3401
+ const uid = process.getuid ? process.getuid() : 0;
3402
+ const kick = await run("launchctl", ["kickstart", `gui/${uid}/${TWITTER_AUTOPILOT_LABEL}`], { timeoutMs: 15_000 });
3403
+ const ok = kick.code === 0;
3404
+ return jsonContent({
3405
+ ok,
3406
+ detail: ok
3407
+ ? "Scanner cycle kicked off — new drafts land in a few minutes."
3408
+ : `Could not start the scanner (launchctl rc=${kick.code}).`,
3409
+ });
3410
+ });
3411
+ // ---- scan_status: live pipeline status + next-scanner-run countdown --------
3412
+ // A cheap, pure READ the dashboard widget polls (~5s) to keep its scanner status
3413
+ // pill + countdown live between heavier snapshot refreshes. Returns the SAME
3414
+ // fields the menu bar renders (scripts/live_status.py — activity_state,
3415
+ // next_run_secs, …), plus `paused`, so the two surfaces can't drift. No side
3416
+ // effects: safe to poll and to replay over the loopback /tool/ endpoint.
3417
+ tool("scan_status", {
3418
+ title: "Live scanner status + next-run countdown",
3419
+ description: "Read-only: what the pipeline is doing right now (scanning/drafting/posting/idle) and how many " +
3420
+ "seconds until the next scanner run. Backs the dashboard's live status pill + countdown. Pure " +
3421
+ "read; safe to poll frequently.",
3422
+ inputSchema: {},
3423
+ }, async () => {
3424
+ let live = {};
3425
+ try {
3426
+ const res = await runPython("scripts/live_status.py", [], { timeoutMs: 10_000 });
3427
+ live = JSON.parse((res.stdout || "").trim().split("\n").slice(-1)[0] || "{}");
3428
+ }
3429
+ catch {
3430
+ live = {};
3431
+ }
3432
+ return jsonContent({ ...live, paused: isPaused() });
3433
+ });
3152
3434
  // ---- posting_volume: per-install posting-volume mode (virality bar) --------
3153
3435
  // Server-side throttle (2026-07-13): installations.posting_mode maps to a
3154
3436
  // virality-bar percentile on the API (high~0.90, medium~0.97, low~0.995) and
@@ -3468,8 +3750,8 @@ async function autopilotLoaded() {
3468
3750
  // fires every minute, claims ONE job, runs the pipeline's own prompt as its
3469
3751
  // Claude turn, writes the result back, and stops.
3470
3752
  // ===========================================================================
3471
- const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
3472
- // v9 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
3753
+ const QUEUE_WORKER_PROMPT_VERSION = 9; // v9 (2026-07-17): poll window widened 240s -> 900s (see QUEUE_WORKER_POLL_SECONDS); version bump forces the prompt refresh that carries the new --wait-seconds onto existing installs. v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
3754
+ // v10 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
3473
3755
  // sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
3474
3756
  // within one continuous worker session) instead of drafting inline. Validated
3475
3757
  // via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
@@ -3478,19 +3760,26 @@ const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_jo
3478
3760
  // notification) or the host kills the whole parent+child chain in 1-3 min.
3479
3761
  // Never live-fire tested against a real production job. Full design, what's
3480
3762
  // validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
3481
- // Bump this constant to 9 only once that plan is actually implemented.
3763
+ // Bump this constant to 10 only once that plan is actually implemented.
3482
3764
  const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
3483
3765
  // How long ONE `next --wait-seconds` call polls before giving up and exiting.
3484
- // 240s (4 min): comfortably inside the 900s single-Bash-call survival verified
3485
- // live on 2026-07-06, and covers a meaningful chunk of the ~8min average
3486
- // real job inter-arrival gap measured on the box, while still keeping each
3487
- // worker session bounded. The cron's `* * * * *` cadence remains the outer
3488
- // safety net for whatever the poll window doesn't catch.
3766
+ // 900s (15 min, per Matthew 2026-07-17, up from 240s): sits AT the single-
3767
+ // Bash-call survival ceiling verified live on 2026-07-06 (the host's ~90s
3768
+ // inactivity kill fires only on model silence, and one in-flight tool call
3769
+ // survived a full 900s probe). This covers the ~8min average real job
3770
+ // inter-arrival gap outright, so most jobs are claimed by an already-polling
3771
+ // session instead of paying a fresh spin-up, and MCP boot side effects
3772
+ // (backfill checks, backlog drains) run 1/15min instead of 1/5min. Watch
3773
+ // point: 900s has zero margin below the verified ceiling — if workers start
3774
+ // dying mid-poll with no reaper kill recorded, the host clipped the call;
3775
+ // back off to 600s. The cron's `* * * * *` cadence remains the outer safety
3776
+ // net for whatever the poll window doesn't catch.
3489
3777
  // COUPLING: scripts/reap_stale_claude_sessions.py's S4L_REAPER_CLAIM_GRACE_SEC
3490
3778
  // default MUST stay >= this value + margin — a claimless session inside this
3491
3779
  // poll window is legitimately still working, not a husk, and a too-tight
3492
3780
  // claim_grace would SIGTERM it mid-poll before it ever gets to claim.
3493
- const QUEUE_WORKER_POLL_SECONDS = 240;
3781
+ // (Bumped to 1020s alongside this change.)
3782
+ const QUEUE_WORKER_POLL_SECONDS = 900;
3494
3783
  // One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
3495
3784
  const QUEUE_WORKERS = [
3496
3785
  { taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
@@ -3978,26 +4267,40 @@ function kickerEnv() {
3978
4267
  // skill/run-twitter-cycle.sh (single source of truth, no env dependency).
3979
4268
  };
3980
4269
  }
4270
+ // Shared draftability gate for BOTH autopilot kickers (X queue + Reddit search).
4271
+ // A launchd discovery/draft kicker only makes sense to schedule when SOMETHING is
4272
+ // draftable, and the definition of "draftable" is identical across platforms — so
4273
+ // it lives here as the single source of truth. Two paths qualify:
4274
+ // (a) a managed product project is ready (promotion lane), OR
4275
+ // (b) personal_brand mode is on AND the persona is ready (self-promo lane).
4276
+ // Path (b) is easy to miss: the persona is deliberately excluded from the
4277
+ // managed-products scope (see ensurePersonaProject), so a personal-brand-only
4278
+ // install has an empty managedProjects() and a product-only check is always
4279
+ // false. That gap silently sank the X kicker (fixed 2026-06-30) and then the
4280
+ // Reddit kicker (fixed 2026-07-16, which reused the product-only check instead of
4281
+ // this persona-aware one). Keeping both kickers on this ONE helper means the
4282
+ // personal-brand path can never regress on one platform but not the other.
4283
+ function draftableLaneStatus() {
4284
+ const productReady = listManagedProjectStatus().some((p) => p.ready);
4285
+ if (productReady)
4286
+ return { draftable: true, detail: "ready project" };
4287
+ const personaActive = currentFlags().personal_brand && personaReady();
4288
+ if (personaActive) {
4289
+ return { draftable: true, detail: "active persona (personal_brand)" };
4290
+ }
4291
+ return { draftable: false, detail: "no ready project or active persona yet" };
4292
+ }
3981
4293
  async function ensureQueueKickerInstalled() {
3982
4294
  try {
3983
4295
  if (process.platform !== "darwin")
3984
4296
  return { ok: false, detail: "not macOS" };
3985
4297
  if (!runtimeReady())
3986
4298
  return { ok: false, detail: "runtime not ready" };
3987
- // Gate: install the kicker when SOMETHING is draftable. Two paths qualify:
3988
- // (a) a managed product project is ready (promotion lane), OR
3989
- // (b) personal_brand mode is on AND the persona is ready (self-promo lane).
3990
- // Path (b) was the 2026-06-30 gap: a personal-brand-only setup has no managed
3991
- // project, so the old `anyReady` check was always false and the kicker never
3992
- // installed (no drafts, no first-run kick). The persona is excluded from
3993
- // managed scope by design, so check it explicitly.
3994
- const productReady = listManagedProjectStatus().some((p) => p.ready);
3995
- const personaActive = currentFlags().personal_brand && personaReady();
3996
- if (!productReady && !personaActive) {
3997
- return {
3998
- ok: false,
3999
- detail: "no ready project or active persona yet",
4000
- };
4299
+ // Gate: install the kicker only when SOMETHING is draftable (product lane OR
4300
+ // active persona). See draftableLaneStatus — shared with the Reddit kicker.
4301
+ const lane = draftableLaneStatus();
4302
+ if (!lane.draftable) {
4303
+ return { ok: false, detail: lane.detail };
4001
4304
  }
4002
4305
  // Additional gate: X must be connected and verified before autopilot starts.
4003
4306
  // This prevents wasted cycles against a logged-out x.com if X connection didn't
@@ -4115,9 +4418,14 @@ async function ensureRedditKickerInstalled() {
4115
4418
  return { ok: false, detail: "not macOS" };
4116
4419
  if (!runtimeReady())
4117
4420
  return { ok: false, detail: "runtime not ready" };
4118
- const productReady = listManagedProjectStatus().some((p) => p.ready);
4119
- if (!productReady)
4120
- return { ok: false, detail: "no ready project yet" };
4421
+ // Gate: install the kicker only when SOMETHING is draftable. Mirror the X
4422
+ // queue kicker EXACTLY via the shared draftableLaneStatus — a personal-brand-
4423
+ // only install has no managed product, so the old product-only check here made
4424
+ // this kicker permanently uninstallable (no reddit drafts) even with a ready
4425
+ // persona (2026-07-16 bug). Both kickers now share the one persona-aware gate.
4426
+ const lane = draftableLaneStatus();
4427
+ if (!lane.draftable)
4428
+ return { ok: false, detail: lane.detail };
4121
4429
  // Reddit session gate. status is read-only (never launches Chrome): a live
4122
4430
  // me.json validation when the harness is up, the on-disk profile cookie
4123
4431
  // check (connected_idle) when it isn't.
@@ -5684,30 +5992,18 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
5684
5992
  },
5685
5993
  ],
5686
5994
  }));
5687
- // Post any cards the user APPROVED that never landed — e.g. a restart killed the
5688
- // batch mid-way. "Proceed to post the already-approved items." postApproved is
5689
- // idempotent (it filters posted/terminal), so this only drains the genuine
5690
- // backlog and never double-posts. Best-effort; never throws.
5691
- async function drainApprovedBacklog() {
5692
- try {
5693
- const plan = readPlan(REVIEW_QUEUE_ID);
5694
- const cands = plan?.candidates || [];
5695
- const backlog = cands.filter((c) => c.approved === true &&
5696
- c.posted !== true &&
5697
- (c.terminal !== true || expiredStampOverridable(c)));
5698
- if (!backlog.length)
5699
- return;
5700
- console.error(`[post] draining ${backlog.length} approved-but-unposted card(s) left from before`);
5701
- await postApproved(REVIEW_QUEUE_ID, plan);
5702
- }
5703
- catch (e) {
5704
- console.error("[post] drainApprovedBacklog error:", e?.message || e);
5705
- // Same reasoning as the other postApproved call site: don't let an
5706
- // escaped exception leave the cross-instance posting flag stuck true.
5707
- postingActive = false;
5708
- stopPostingFlagHeartbeat();
5709
- }
5710
- }
5995
+ // REMOVED (2026-07-17): drainApprovedBacklog. It ran 30s after EVERY MCP boot,
5996
+ // which was sane when boots meant "user launched Claude Desktop" — but each
5997
+ // queue-worker session boots its own MCP server, so the drain had silently
5998
+ // become a ~5-minute cron running across up to 4 concurrent MCP instances.
5999
+ // Combined with universal posting preemption, every drain wakeup SIGKILLed
6000
+ // whatever held the twitter-browser lock (profile scans included), and any
6001
+ // stamp bug turned into an infinite retry loop (438 retries over 5 days on
6002
+ // one Nhat card). Backlog recovery is now owned by ONE long-lived process:
6003
+ // the menubar's _resume_approved_queue, which runs on loopback-reachable and
6004
+ // periodically thereafter (mcp/menubar/s4l_menubar.py). Do NOT re-add a
6005
+ // boot-time drain here; if the menubar is dead, ensureMenubar() below revives
6006
+ // it and its resume covers the backlog.
5711
6007
  async function main() {
5712
6008
  initSentry();
5713
6009
  // Detect a self-update (old_version -> new_version) as the very first thing
@@ -5791,8 +6087,11 @@ async function main() {
5791
6087
  console.error("[queue-worker] launchd kicker: skip (paused)");
5792
6088
  }
5793
6089
  // Reddit discovery kicker (optional platform): installs only when reddit is
5794
- // connected AND a project is ready; a box that never connected reddit is a
5795
- // cheap no-op skip. Paused-gated like the X kicker (it produces drafts).
6090
+ // connected AND a draftable lane exists (product ready OR active persona, via
6091
+ // the shared draftableLaneStatus). A box that never connected reddit is a cheap
6092
+ // no-op skip. This boot-time call also SELF-HEALS installs broken by the old
6093
+ // product-only gate: once the gate passes, the plist lands on the next boot
6094
+ // (worker sessions boot ~1/min). Paused-gated like the X kicker (produces drafts).
5796
6095
  if (!isPaused()) {
5797
6096
  void ensureRedditKickerInstalled()
5798
6097
  .then((r) => console.error(`[reddit-kicker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
@@ -5883,13 +6182,9 @@ async function main() {
5883
6182
  void startLocalPanel()
5884
6183
  .then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
5885
6184
  .catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
5886
- // Resume posting any approved-but-unposted cards a prior run/restart left behind.
5887
- // Delayed so the runtime + harness Chrome have settled; never blocks boot.
5888
- {
5889
- const t = setTimeout(() => void drainApprovedBacklog(), 30_000);
5890
- if (typeof t.unref === "function")
5891
- t.unref();
5892
- }
6185
+ // NOTE (2026-07-17): the boot-time drainApprovedBacklog() call that lived
6186
+ // here is gone — backlog recovery is owned by the menubar's periodic
6187
+ // _resume_approved_queue (single drainer; see the removal note above).
5893
6188
  // Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
5894
6189
  // and cheap when already present, so existing installs pick it up on the next
5895
6190
  // Claude restart without re-provisioning. Best-effort: never blocks boot.