@m13v/s4l 1.7.6-rc.8 → 1.7.6

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 (85) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +33 -9
  3. package/mcp/dist/index.js +290 -177
  4. package/mcp/dist/panel.html +31 -17
  5. package/mcp/dist/setup.js +27 -7
  6. package/mcp/dist/telemetry.js +15 -6
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +10 -2
  9. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  10. package/mcp/menubar/s4l_card.py +63 -4
  11. package/mcp/menubar/s4l_card_canvas.py +62 -11
  12. package/mcp/menubar/s4l_menubar.py +380 -95
  13. package/mcp/menubar/s4l_state.py +119 -41
  14. package/mcp/package.json +1 -1
  15. package/package.json +1 -1
  16. package/scripts/account_resolver.py +58 -14
  17. package/scripts/amplitude_24h_signups.py +7 -4
  18. package/scripts/amplitude_signups.py +7 -4
  19. package/scripts/autopilot_stall_watch.py +8 -1
  20. package/scripts/browser_lifecycle.py +90 -0
  21. package/scripts/cdp_ready_check.py +81 -50
  22. package/scripts/claude_job.py +70 -10
  23. package/scripts/dm_conversation.py +15 -12
  24. package/scripts/draft_prompt_core.py +132 -64
  25. package/scripts/engage_github.py +11 -5
  26. package/scripts/engage_reddit.py +12 -5
  27. package/scripts/engagement_signal_feedback.py +1 -4
  28. package/scripts/engagement_styles.py +144 -105
  29. package/scripts/feedback_digest.py +54 -11
  30. package/scripts/find_threads.py +9 -7
  31. package/scripts/github_tools.py +23 -10
  32. package/scripts/learned_preferences.py +112 -78
  33. package/scripts/li_discovery.py +4 -1
  34. package/scripts/link_tail.py +1 -1
  35. package/scripts/live_status.py +151 -0
  36. package/scripts/merge_review_queue.py +15 -2
  37. package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
  38. package/scripts/pick_project.py +7 -4
  39. package/scripts/pick_thread_target.py +64 -13
  40. package/scripts/pick_twitter_thread_target.py +7 -4
  41. package/scripts/platform_strike_events.py +139 -7
  42. package/scripts/post_github.py +376 -35
  43. package/scripts/post_reddit.py +202 -192
  44. package/scripts/preflight.sh +23 -3
  45. package/scripts/preview_reddit_card.py +38 -0
  46. package/scripts/project_stats.py +7 -4
  47. package/scripts/reddit_ban_check.py +324 -0
  48. package/scripts/reddit_browser.py +56 -19
  49. package/scripts/reddit_chat_sync.py +10 -11
  50. package/scripts/reddit_tools.py +20 -4
  51. package/scripts/release-mcpb.sh +47 -7
  52. package/scripts/run_moltbook_cycle.py +9 -5
  53. package/scripts/s4l_ctl.sh +2 -2
  54. package/scripts/s4l_posting_mode.py +67 -6
  55. package/scripts/scan_dm_candidates.py +15 -9
  56. package/scripts/scan_github_replies.py +9 -7
  57. package/scripts/scan_moltbook_replies.py +7 -6
  58. package/scripts/scan_reddit_replies.py +11 -7
  59. package/scripts/scan_twitter_mentions_browser.py +7 -6
  60. package/scripts/scan_twitter_thread_followups.py +7 -6
  61. package/scripts/schedule_state.py +8 -4
  62. package/scripts/score_twitter_candidates.py +8 -3
  63. package/scripts/setup_twitter_auth.py +20 -96
  64. package/scripts/snapshot.py +19 -0
  65. package/scripts/stats.py +12 -10
  66. package/scripts/strike_alert.py +165 -12
  67. package/scripts/sub_moderation_history.py +129 -0
  68. package/scripts/subreddit_bans_client.py +157 -0
  69. package/scripts/test_cross_language_parity.py +185 -0
  70. package/scripts/test_no_silent_fallbacks.py +84 -0
  71. package/scripts/test_percard_posting.py +3 -3
  72. package/scripts/test_stall_watch_batch_progression.py +99 -0
  73. package/scripts/top_performers.py +24 -0
  74. package/scripts/twitter_browser.py +22 -52
  75. package/scripts/twitter_gen_links.py +3 -3
  76. package/scripts/twitter_post_plan.py +4 -4
  77. package/scripts/virality.py +151 -0
  78. package/setup/SKILL.md +1 -1
  79. package/skill/engage-dm-replies.sh +11 -0
  80. package/skill/lib/reddit-backend.sh +1 -0
  81. package/skill/run-draft-and-publish.sh +16 -0
  82. package/skill/run-reddit-search-launchd.sh +1 -0
  83. package/skill/run-reddit-search.sh +10 -1
  84. package/skill/run-reddit-threads.sh +12 -0
  85. package/skill/run-twitter-cycle.sh +104 -88
package/mcp/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // via the host create_scheduled_task. The autopilot then drafts
7
7
  // on its own (launchd kicker + queue worker); there is no manual
8
8
  // "draft now" tool.
9
- // post_drafts - post the drafts the user chose by number from a batch.
9
+ // approve_drafts - post the drafts the user chose by number from a batch.
10
10
  // get_stats - read-only post + engagement stats.
11
11
  //
12
12
  // THIN wrapper. The pipeline brain (scan, score, drafting prompts, posting)
@@ -37,7 +37,7 @@ const PANEL_URI = "ui://social-autoposter/panel.html";
37
37
  const PRODUCT_LINK_URI = "ui://social-autoposter/product-link.html";
38
38
  // Stable id for the accumulating draft review queue. Each draft cycle appends its
39
39
  // drafts here (dedup by tweet URL) so the menu-bar cards PILE UP across a
40
- // continuous autopilot instead of each run overwriting the last; post_drafts posts
40
+ // continuous autopilot instead of each run overwriting the last; approve_drafts posts
41
41
  // the approved subset and marks them posted (filtered out of the cards thereafter).
42
42
  const REVIEW_QUEUE_ID = "review-queue";
43
43
  // ---- Queue-backed drafting (2026-06-23) -----------------------------------
@@ -397,12 +397,12 @@ const server = new McpServer({
397
397
  "(configure the product project) -> `queue_setup` + " +
398
398
  "`create_scheduled_task` (set up the draft autopilot once) -> the autopilot then runs on its own " +
399
399
  "(scans, drafts via the queue + worker, and merges into the approval cards; nothing posts) -> the " +
400
- "user approves in the menu bar -> `post_drafts` (post the approved ones) -> `get_stats` (see " +
400
+ "user approves in the menu bar -> `approve_drafts` (post the approved ones) -> `get_stats` (see " +
401
401
  "performance). Run `project_config` first; the other tools refuse until a " +
402
402
  "project is fully configured. To change anything about a project later, call `project_config` " +
403
403
  "again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
404
404
  "RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
405
- "(`post_drafts`, `get_stats`, `project_config`), end your turn by " +
405
+ "(`approve_drafts`, `get_stats`, `project_config`), end your turn by " +
406
406
  "calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
407
407
  "`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.\n\n" +
408
408
  "REPORT FIELD DIAGNOSES. Whenever an S4L setup, healing, or troubleshooting flow FAILS, or " +
@@ -421,7 +421,7 @@ const baseRegisterTool = server.registerTool.bind(server);
421
421
  // show a spinner + label while they run (either invocation path). draft_cycle is
422
422
  // NOT here — it writes finer scanning/drafting phases itself (see produceDrafts).
423
423
  const TOOL_ACTIVITY = {
424
- post_drafts: "posting",
424
+ approve_drafts: "posting",
425
425
  get_stats: "stats…",
426
426
  };
427
427
  function toolActivityLabel(name, args) {
@@ -748,7 +748,7 @@ async function produceDrafts(project, onProgress) {
748
748
  }
749
749
  // Render every draft in a batch as a numbered, human-readable table. This IS the
750
750
  // review surface now: the model relays this table to the user and asks which
751
- // numbers to post / edit, then posts the chosen ones via the `post_drafts` tool.
751
+ // numbers to post / edit, then posts the chosen ones via the `approve_drafts` tool.
752
752
  //
753
753
  // We used to gather approvals through MCP elicitation (a checkbox form), but the
754
754
  // desktop "Code tab" host doesn't advertise the `elicitation` capability (only
@@ -757,10 +757,12 @@ async function produceDrafts(project, onProgress) {
757
757
  function renderDraftsTable(plan) {
758
758
  const candidates = plan.candidates || [];
759
759
  return candidates
760
- // Number by FULL-array index (matches post_drafts + the menu bar), then drop
760
+ // Number by FULL-array index (matches approve_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.
@@ -771,7 +773,7 @@ function renderDraftsTable(plan) {
771
773
  const reply = c.reply_text ?? "(empty)";
772
774
  // The literal tail URL is NOT known yet: at post time a short link is minted
773
775
  // from this target (e.g. fazm.ai/cc -> s4l.ai/r/<code>). Approved drafts
774
- // always carry the link (post_drafts forces TWITTER_TAIL_LINK_RATE=1.0), so
776
+ // always carry the link (approve_drafts forces TWITTER_TAIL_LINK_RATE=1.0), so
775
777
  // this is the target that WILL be appended. Show the TARGET only; never
776
778
  // pre-mint the real /r/ code (that would waste pool codes / split clicks).
777
779
  const link = c.link_url
@@ -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() {
@@ -929,6 +919,17 @@ function isSandboxCandidate(c) {
929
919
  const id = Number(c.candidate_id);
930
920
  return Number.isFinite(id) && id >= 900_000_000;
931
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
+ }
932
933
  // Write field patches into the review-queue store UNDER ITS LOCK by shelling
933
934
  // to scripts/store_patch.py, which takes the same fcntl.flock the menubar's
934
935
  // _store_update and merge_review_queue.py hold around their read-modify-write.
@@ -990,7 +991,12 @@ async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
990
991
  set.terminal = false;
991
992
  if (c.our_url)
992
993
  set.our_url = c.our_url;
993
- unset.push("discard_reason");
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");
994
1000
  }
995
1001
  else if (c.terminal === true) {
996
1002
  set.terminal = true;
@@ -1040,8 +1046,12 @@ async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
1040
1046
  if (c.our_url)
1041
1047
  f.our_url = c.our_url;
1042
1048
  // A post outcome closes the card's history: the pre-approval
1043
- // freshness stamp must not survive it.
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).
1044
1052
  delete f.discard_reason;
1053
+ delete f.post_failed;
1054
+ delete f.post_error;
1045
1055
  }
1046
1056
  else if (c.terminal === true && f.posted !== true) {
1047
1057
  f.terminal = true;
@@ -1116,7 +1126,7 @@ async function postApproved(batchId, plan) {
1116
1126
  attempted: 0,
1117
1127
  exit_code: 0,
1118
1128
  summary: "another posting drain has been running for 8+ minutes; approved cards stay " +
1119
- "queued in the review store — re-run post_drafts once it finishes",
1129
+ "queued in the review store — re-run approve_drafts once it finishes",
1120
1130
  };
1121
1131
  }
1122
1132
  if (waited) {
@@ -1155,14 +1165,29 @@ async function postApproved(batchId, plan) {
1155
1165
  // An approved card whose only blocker is an overridable backend-expiry stamp is
1156
1166
  // included: approval outranks the freshness gate (see expiredStampOverridable),
1157
1167
  // and the stamp is cleared so every downstream terminal check agrees it's live.
1158
- const approved = (plan.candidates || []).filter((c) => c.approved === true &&
1159
- c.posted !== true &&
1160
- (c.terminal !== true || expiredStampOverridable(c)) &&
1161
- // Prompt-sandbox replays can never post (twitter_post_plan.py post_one()
1162
- // hard-refuses them), so draining one is pure churn: it burns a browser
1163
- // lock turn and, if its terminal stamp later loses a store-write race,
1164
- // loops forever. Exclude them here regardless of stamp state.
1165
- !isSandboxCandidate(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
+ });
1166
1191
  for (const c of approved) {
1167
1192
  if (c.terminal === true) {
1168
1193
  c.terminal = false;
@@ -1230,7 +1255,7 @@ async function postApproved(batchId, plan) {
1230
1255
  redditFailed++;
1231
1256
  continue;
1232
1257
  }
1233
- // The card's reply_text is the CANONICAL text (post_drafts edits write
1258
+ // The card's reply_text is the CANONICAL text (approve_drafts edits write
1234
1259
  // there); the embedded decision still carries the draft-time original.
1235
1260
  // Posting dec.text verbatim would silently discard a human edit.
1236
1261
  const decPost = {
@@ -1304,7 +1329,7 @@ async function postApproved(batchId, plan) {
1304
1329
  }
1305
1330
  else {
1306
1331
  // Leave the approval sticky (approved && !posted && !terminal) so the
1307
- // next post_drafts call retries, mirroring twitter's failed-drain
1332
+ // next approve_drafts call retries, mirroring twitter's failed-drain
1308
1333
  // semantics; only stamp terminal on a conclusive CDP refusal. This
1309
1334
  // list MUST stay a superset of post_reddit.py's own
1310
1335
  // _PERMANENT_CDP_ERRORS (account_blocked_in_sub, no_permalink) —
@@ -1373,9 +1398,8 @@ async function postApproved(batchId, plan) {
1373
1398
  // If onboarding never persisted it, self-heal from the live session; if even that
1374
1399
  // can't determine it, refuse here with a clear reason rather than launching a
1375
1400
  // poster that silently burns the whole batch.
1376
- if (!readConfiguredTwitterHandle())
1377
- await ensurePostingHandle();
1378
- if (!readConfiguredTwitterHandle()) {
1401
+ const postingHandle = await resolvePostingHandle();
1402
+ if (!postingHandle) {
1379
1403
  postingActive = false;
1380
1404
  stopPostingFlagHeartbeat();
1381
1405
  return {
@@ -1383,9 +1407,9 @@ async function postApproved(batchId, plan) {
1383
1407
  exit_code: 0,
1384
1408
  posted: 0,
1385
1409
  summary: "no_account_configured",
1386
- error: "X is connected but no posting @handle is configured, so every reply would be refused " +
1387
- "(no_account_configured). Re-run project_config action:'connect_x' to capture the handle, " +
1388
- "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.",
1389
1413
  };
1390
1414
  }
1391
1415
  // Mark posting active so the draft-cycle scan DEFERS launching any scan for the
@@ -1409,7 +1433,7 @@ async function postApproved(batchId, plan) {
1409
1433
  // we can write our own pid) — a pathological edge case, not the normal path.
1410
1434
  // Bail out rather than proceed without ever confirming we hold it. Approved
1411
1435
  // cards stay sticky (approved && !posted && !terminal), so the very next
1412
- // post_drafts call — the next approval, or this same tool retried — picks
1436
+ // approve_drafts call — the next approval, or this same tool retried — picks
1413
1437
  // them straight back up; nothing is lost or re-queued.
1414
1438
  postingActive = false;
1415
1439
  stopPostingFlagHeartbeat();
@@ -1417,7 +1441,7 @@ async function postApproved(batchId, plan) {
1417
1441
  attempted: 0,
1418
1442
  exit_code: 0,
1419
1443
  summary: "couldn't pin down the twitter-browser lock after repeated attempts; approved cards " +
1420
- "stay queued — re-run post_drafts to retry",
1444
+ "stay queued — re-run approve_drafts to retry",
1421
1445
  };
1422
1446
  }
1423
1447
  const approvedBatch = `${batchId}_approved`;
@@ -1473,9 +1497,9 @@ async function postApproved(batchId, plan) {
1473
1497
  // are now no-ops for the normal path — every approved candidate
1474
1498
  // already carries tail_link_variant by the time it reaches this MCP
1475
1499
  // tool. They're left in place as a defense-in-depth fallback for the
1476
- // rare case a candidate reaches post_drafts unstamped (e.g. a plan
1500
+ // rare case a candidate reaches approve_drafts unstamped (e.g. a plan
1477
1501
  // already in flight from before this change): S4L_SKIP_LINK_TAIL=1
1478
- // still guarantees post_drafts (a synchronous call the user is
1502
+ // still guarantees approve_drafts (a synchronous call the user is
1479
1503
  // waiting on) never makes a blocking Claude/queue call at post time,
1480
1504
  // no matter what.
1481
1505
  TWITTER_TAIL_LINK_RATE: "1.0",
@@ -1517,7 +1541,7 @@ async function postApproved(batchId, plan) {
1517
1541
  const postLogDir = path.join(repoDir(), "skill", "logs");
1518
1542
  fs.mkdirSync(postLogDir, { recursive: true });
1519
1543
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1520
- fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${approvedTwitter.length} exit=${res.code} ` +
1544
+ fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# approve_drafts batch=${batchId} approved=${approvedTwitter.length} exit=${res.code} ` +
1521
1545
  `shell_lock=${heldShellLock}\n\n=== stdout ===\n${res.stdout}\n\n=== stderr ===\n${res.stderr}\n`);
1522
1546
  }
1523
1547
  catch {
@@ -1596,7 +1620,7 @@ async function postApproved(batchId, plan) {
1596
1620
  // ... waited 45s, giving up."). None of those mean the draft is
1597
1621
  // doomed, only that this attempt didn't get a turn with the shared
1598
1622
  // browser. Leave the card sticky (approved && !posted && !terminal)
1599
- // so the next post_drafts call retries it, mirroring Reddit's
1623
+ // so the next approve_drafts call retries it, mirroring Reddit's
1600
1624
  // _TRANSIENT_CDP_ERRORS handling. Unconditionally marking terminal
1601
1625
  // here (found 2026-07-16) silently and permanently discarded 7 real
1602
1626
  // approved drafts on nothing worse than lock contention — Reddit's
@@ -1649,7 +1673,7 @@ async function postApproved(batchId, plan) {
1649
1673
  // actionable error on its own.
1650
1674
  const hasRealFailure = res.code !== 0 || Boolean(summObj?.failure_reasons);
1651
1675
  if (hasRealFailure) {
1652
- captureError(new Error(`post_drafts: ${realPosted}/${approvedTwitter.length} posted (exit=${res.code})`), {
1676
+ captureError(new Error(`approve_drafts: ${realPosted}/${approvedTwitter.length} posted (exit=${res.code})`), {
1653
1677
  component: "post",
1654
1678
  exit_code: String(res.code),
1655
1679
  attempted: String(approvedTwitter.length),
@@ -2467,6 +2491,7 @@ tool("project_config", {
2467
2491
  const r = await redditConnect(args.reddit_source, args.reddit_manual_login);
2468
2492
  let verified = false;
2469
2493
  let configNote;
2494
+ let redditKicker;
2470
2495
  if (r.connected) {
2471
2496
  completeOnboardingMilestone("reddit_connected", { state: r.state });
2472
2497
  // Persist the discovered username through the server's config-write
@@ -2491,11 +2516,18 @@ tool("project_config", {
2491
2516
  });
2492
2517
  }
2493
2518
  // Reddit is live: install the discovery kicker (idempotent; gated on
2494
- // runtime + project readiness inside, and it never touches a
2495
- // hand-built plist pointing outside the managed package).
2496
- void ensureRedditKickerInstalled()
2497
- .then((res) => console.error(`[reddit-kicker] post-connect install: ${res.ok ? "ok" : "skip"} (${res.detail})`))
2498
- .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
+ }
2499
2531
  }
2500
2532
  else {
2501
2533
  blockOnboardingMilestone("reddit_connected", `reddit_${r.state || "not_connected"}`, r.error || r.note || summarizeRedditAuth(r), { state: r.state || "not_connected" });
@@ -2516,11 +2548,20 @@ tool("project_config", {
2516
2548
  summary: summarizeRedditAuth(r),
2517
2549
  note: r.note,
2518
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,
2519
2555
  onboarding: onboardingSnapshot(),
2520
2556
  next_step: r.connected
2521
- ? "Reddit is connected. The reddit discovery job installs itself once a project is " +
2522
- "configured; drafts flow into the same review cards as X and nothing posts without " +
2523
- "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.") +
2524
2565
  (r.warning ? " Relay the `warning` field to the user so expectations are set." : "")
2525
2566
  : r.state === "needs_login"
2526
2567
  ? "The user must finish signing in to reddit.com in the Chrome window that opened. Tell " +
@@ -2822,11 +2863,11 @@ tool("project_config", {
2822
2863
  return textContent(`Setup failed: ${e.message}`);
2823
2864
  }
2824
2865
  });
2825
- // ---- post_drafts: post the user's chosen drafts from a batch ---------------
2866
+ // ---- approve_drafts: post the user's chosen drafts from a batch ---------------
2826
2867
  // Second half of the manual loop. The user reviewed the menu-bar cards a draft
2827
2868
  // cycle produced and said which numbers to post / edit; this posts exactly those.
2828
2869
  // Editing a draft implies posting it. Indices are 1-based, matching the table.
2829
- tool("post_drafts", {
2870
+ tool("approve_drafts", {
2830
2871
  title: "Post chosen drafts",
2831
2872
  description: "Post the drafts the user approved from a draft cycle. Pass the batch_id from the " +
2832
2873
  "approval cards and the user's decision by NUMBER (1-based, matching the table): `post` is " +
@@ -2895,7 +2936,7 @@ tool("post_drafts", {
2895
2936
  return;
2896
2937
  }
2897
2938
  const c = candidates[n - 1];
2898
- if (c.posted === true) {
2939
+ if (candidateState(c) === "posted") {
2899
2940
  warnings.push(`#${n} already posted; not rejecting`);
2900
2941
  return;
2901
2942
  }
@@ -3013,7 +3054,7 @@ tool("post_drafts", {
3013
3054
  }
3014
3055
  // STICKY approve: record the approval DURABLY and never clear another card's
3015
3056
  // prior approval. The old `c.approved = approve.has(i+1)` reset every card on
3016
- // each call, so a later post_drafts for a different card dropped a
3057
+ // each call, so a later approve_drafts for a different card dropped a
3017
3058
  // restart-interrupted approved card back into "pending". postApproved filters
3018
3059
  // posted/terminal, so the approved set only ever drains what's genuinely left.
3019
3060
  approve.forEach((n) => {
@@ -3051,7 +3092,7 @@ tool("post_drafts", {
3051
3092
  });
3052
3093
  storeWriteDone = await patchReviewStore(patches);
3053
3094
  if (!storeWriteDone)
3054
- console.error("[post_drafts] store_patch.py failed; falling back to unlocked plan write");
3095
+ console.error("[approve_drafts] store_patch.py failed; falling back to unlocked plan write");
3055
3096
  }
3056
3097
  if (!storeWriteDone)
3057
3098
  writePlan(batch_id, plan);
@@ -3326,6 +3367,71 @@ tool("restart_menubar", {
3326
3367
  menubar_running: running,
3327
3368
  });
3328
3369
  });
3370
+ // ---- run_drafting: run the drafting pipeline immediately -------------------
3371
+ // The drafting pipeline (the twitter cycle) is what the user triggers: it SCANS
3372
+ // for threads first, then hands off to the drafter, so from the user's view this
3373
+ // is "run drafting now". It runs on the launchd kicker's fixed StartInterval;
3374
+ // this nudges launchd to fire it RIGHT NOW via `launchctl kickstart` — the EXACT
3375
+ // same mechanism the first-run onboarding kick uses (ensureQueueKickerInstalled):
3376
+ // the job runs THROUGH launchd with its full baked env + the run-*-singleton.sh
3377
+ // lock, so it is NOT a bare `bash run-twitter-cycle.sh` (which would leave an
3378
+ // empty-plan artifact). launchd keeps a single instance, so if a run is already
3379
+ // underway this is a harmless no-op; while paused the kicker is unloaded, so we
3380
+ // report that rather than kick nothing.
3381
+ //
3382
+ // This is deliberately NOT the removed run_draft_cycle / draft_cycle tool: it
3383
+ // spawns no warm in-chat Claude draft session, it only asks launchd to run the
3384
+ // already-scheduled cycle one interval early. Do NOT re-introduce a draft_cycle.
3385
+ tool("run_drafting", {
3386
+ title: "Run the drafting pipeline now",
3387
+ description: "Run the drafting pipeline ONE time immediately instead of waiting for the next scheduled run. " +
3388
+ "The pipeline scans for threads and then drafts replies, so this is the 'run now' control behind " +
3389
+ "the menu bar + dashboard button. It nudges the existing launchd job to run one interval early; " +
3390
+ "it does NOT change the schedule. If a run is already underway it's a no-op, and if S4L is paused " +
3391
+ "it reports that (nothing runs while paused). Use when the user asks to run drafting, draft now, " +
3392
+ "run the pipeline, run now, or check for new threads immediately.",
3393
+ inputSchema: {},
3394
+ }, async () => {
3395
+ if (isPaused()) {
3396
+ return jsonContent({
3397
+ ok: false,
3398
+ paused: true,
3399
+ detail: "S4L is paused — resume it first, then drafting can run.",
3400
+ });
3401
+ }
3402
+ const uid = process.getuid ? process.getuid() : 0;
3403
+ const kick = await run("launchctl", ["kickstart", `gui/${uid}/${TWITTER_AUTOPILOT_LABEL}`], { timeoutMs: 15_000 });
3404
+ const ok = kick.code === 0;
3405
+ return jsonContent({
3406
+ ok,
3407
+ detail: ok
3408
+ ? "Drafting started — new drafts land in a few minutes."
3409
+ : `Could not start drafting (launchctl rc=${kick.code}).`,
3410
+ });
3411
+ });
3412
+ // ---- drafting_status: live pipeline status + next-run countdown ------------
3413
+ // A cheap, pure READ the dashboard widget polls (~5s) to keep its drafting status
3414
+ // pill + countdown live between heavier snapshot refreshes. Returns the SAME
3415
+ // fields the menu bar renders (scripts/live_status.py — activity_state,
3416
+ // next_run_secs, …), plus `paused`, so the two surfaces can't drift. No side
3417
+ // effects: safe to poll and to replay over the loopback /tool/ endpoint.
3418
+ tool("drafting_status", {
3419
+ title: "Live drafting-pipeline status + next-run countdown",
3420
+ description: "Read-only: what the drafting pipeline is doing right now (scanning/drafting/posting/idle) and " +
3421
+ "how many seconds until the next scheduled run. Backs the dashboard's live status pill + " +
3422
+ "countdown. Pure read; safe to poll frequently.",
3423
+ inputSchema: {},
3424
+ }, async () => {
3425
+ let live = {};
3426
+ try {
3427
+ const res = await runPython("scripts/live_status.py", [], { timeoutMs: 10_000 });
3428
+ live = JSON.parse((res.stdout || "").trim().split("\n").slice(-1)[0] || "{}");
3429
+ }
3430
+ catch {
3431
+ live = {};
3432
+ }
3433
+ return jsonContent({ ...live, paused: isPaused() });
3434
+ });
3329
3435
  // ---- posting_volume: per-install posting-volume mode (virality bar) --------
3330
3436
  // Server-side throttle (2026-07-13): installations.posting_mode maps to a
3331
3437
  // virality-bar percentile on the API (high~0.90, medium~0.97, low~0.995) and
@@ -3946,12 +4052,12 @@ function queueWorkerAllowedTools() {
3946
4052
  "Grep",
3947
4053
  // This server's tools, both namespaces (manifest name + protocol name).
3948
4054
  "mcp__social-autoposter__queue_setup",
3949
- "mcp__social-autoposter__post_drafts",
4055
+ "mcp__social-autoposter__approve_drafts",
3950
4056
  "mcp__social-autoposter__project_config",
3951
4057
  "mcp__social-autoposter__get_stats",
3952
4058
  "mcp__social-autoposter__dashboard",
3953
4059
  "mcp__S4L__queue_setup",
3954
- "mcp__S4L__post_drafts",
4060
+ "mcp__S4L__approve_drafts",
3955
4061
  "mcp__S4L__project_config",
3956
4062
  "mcp__S4L__get_stats",
3957
4063
  "mcp__S4L__dashboard",
@@ -4157,31 +4263,45 @@ function kickerEnv() {
4157
4263
  // The persona lane exports =0 per cycle via s4l_mode.py env (link-free
4158
4264
  // organic replies); promotion cycles keep the script's own default so
4159
4265
  // draft-only-OFF posts carry the project link per the A/B gate, and card posts
4160
- // still force =1.0 inside post_drafts.
4266
+ // still force =1.0 inside approve_drafts.
4161
4267
  // Virality bar percentile is NOT set here: it is hardcoded to 0.90 in
4162
4268
  // skill/run-twitter-cycle.sh (single source of truth, no env dependency).
4163
4269
  };
4164
4270
  }
4271
+ // Shared draftability gate for BOTH autopilot kickers (X queue + Reddit search).
4272
+ // A launchd discovery/draft kicker only makes sense to schedule when SOMETHING is
4273
+ // draftable, and the definition of "draftable" is identical across platforms — so
4274
+ // it lives here as the single source of truth. Two paths qualify:
4275
+ // (a) a managed product project is ready (promotion lane), OR
4276
+ // (b) personal_brand mode is on AND the persona is ready (self-promo lane).
4277
+ // Path (b) is easy to miss: the persona is deliberately excluded from the
4278
+ // managed-products scope (see ensurePersonaProject), so a personal-brand-only
4279
+ // install has an empty managedProjects() and a product-only check is always
4280
+ // false. That gap silently sank the X kicker (fixed 2026-06-30) and then the
4281
+ // Reddit kicker (fixed 2026-07-16, which reused the product-only check instead of
4282
+ // this persona-aware one). Keeping both kickers on this ONE helper means the
4283
+ // personal-brand path can never regress on one platform but not the other.
4284
+ function draftableLaneStatus() {
4285
+ const productReady = listManagedProjectStatus().some((p) => p.ready);
4286
+ if (productReady)
4287
+ return { draftable: true, detail: "ready project" };
4288
+ const personaActive = currentFlags().personal_brand && personaReady();
4289
+ if (personaActive) {
4290
+ return { draftable: true, detail: "active persona (personal_brand)" };
4291
+ }
4292
+ return { draftable: false, detail: "no ready project or active persona yet" };
4293
+ }
4165
4294
  async function ensureQueueKickerInstalled() {
4166
4295
  try {
4167
4296
  if (process.platform !== "darwin")
4168
4297
  return { ok: false, detail: "not macOS" };
4169
4298
  if (!runtimeReady())
4170
4299
  return { ok: false, detail: "runtime not ready" };
4171
- // Gate: install the kicker when SOMETHING is draftable. Two paths qualify:
4172
- // (a) a managed product project is ready (promotion lane), OR
4173
- // (b) personal_brand mode is on AND the persona is ready (self-promo lane).
4174
- // Path (b) was the 2026-06-30 gap: a personal-brand-only setup has no managed
4175
- // project, so the old `anyReady` check was always false and the kicker never
4176
- // installed (no drafts, no first-run kick). The persona is excluded from
4177
- // managed scope by design, so check it explicitly.
4178
- const productReady = listManagedProjectStatus().some((p) => p.ready);
4179
- const personaActive = currentFlags().personal_brand && personaReady();
4180
- if (!productReady && !personaActive) {
4181
- return {
4182
- ok: false,
4183
- detail: "no ready project or active persona yet",
4184
- };
4300
+ // Gate: install the kicker only when SOMETHING is draftable (product lane OR
4301
+ // active persona). See draftableLaneStatus — shared with the Reddit kicker.
4302
+ const lane = draftableLaneStatus();
4303
+ if (!lane.draftable) {
4304
+ return { ok: false, detail: lane.detail };
4185
4305
  }
4186
4306
  // Additional gate: X must be connected and verified before autopilot starts.
4187
4307
  // This prevents wasted cycles against a logged-out x.com if X connection didn't
@@ -4299,9 +4419,14 @@ async function ensureRedditKickerInstalled() {
4299
4419
  return { ok: false, detail: "not macOS" };
4300
4420
  if (!runtimeReady())
4301
4421
  return { ok: false, detail: "runtime not ready" };
4302
- const productReady = listManagedProjectStatus().some((p) => p.ready);
4303
- if (!productReady)
4304
- return { ok: false, detail: "no ready project yet" };
4422
+ // Gate: install the kicker only when SOMETHING is draftable. Mirror the X
4423
+ // queue kicker EXACTLY via the shared draftableLaneStatus — a personal-brand-
4424
+ // only install has no managed product, so the old product-only check here made
4425
+ // this kicker permanently uninstallable (no reddit drafts) even with a ready
4426
+ // persona (2026-07-16 bug). Both kickers now share the one persona-aware gate.
4427
+ const lane = draftableLaneStatus();
4428
+ if (!lane.draftable)
4429
+ return { ok: false, detail: lane.detail };
4305
4430
  // Reddit session gate. status is read-only (never launches Chrome): a live
4306
4431
  // me.json validation when the harness is up, the on-disk profile cookie
4307
4432
  // check (connected_idle) when it isn't.
@@ -4590,15 +4715,15 @@ async function ensureMemorySnapshotInstalled() {
4590
4715
  }
4591
4716
  // ---- pause/resume: stop drafting/posting without touching Claude Desktop ---
4592
4717
  // Unlike Quit (which kills + relaunches Claude Desktop and deletes the draft
4593
- // schedule), Pause only unloads the launchd jobs that actually DO work — the
4594
- // kicker that scans/drafts/posts (TWITTER_AUTOPILOT_LABEL) plus its reaper,
4595
- // stall-watch, and memory-snapshot support daemons — while leaving Claude
4596
- // Desktop, the tray, and the Claude-native s4l-worker scheduled task alone.
4597
- // The scheduled task still fires on its own cadence; claude_job.py's `next`
4598
- // checks the same flag file and reports "no work" instantly instead of
4718
+ // schedule), Pause only unloads the launchd jobs that actually DO work — every
4719
+ // pipeline kicker (X cycle, Reddit discovery) plus the reaper, stall-watch, and
4720
+ // memory-snapshot support daemons — while leaving Claude Desktop, the tray, and
4721
+ // the Claude-native s4l-worker scheduled task alone. The scheduled task still
4722
+ // fires on its own cadence; claude_job.py's `next` checks the same flag file
4723
+ // (at entry AND on every poll iteration) and reports "no work" instead of
4599
4724
  // draining the queue, so nothing drafts or posts while paused even if a
4600
4725
  // worker session wakes up mid-pause. A flag file (nothing is deleted) is what
4601
- // makes Resume a plain reinstall of the same 4 daemons — see resumeS4L.
4726
+ // makes Resume a plain reinstall of the same daemons — see resumeS4L.
4602
4727
  function pauseFlagPath() {
4603
4728
  return path.join(s4lStateDir(), "paused.flag");
4604
4729
  }
@@ -4610,15 +4735,38 @@ function isPaused() {
4610
4735
  return false;
4611
4736
  }
4612
4737
  }
4738
+ // THE single registry of pausable pipeline daemons. Pause unloads exactly this
4739
+ // set, Resume reinstalls exactly this set (each ensure re-applies its own
4740
+ // install gates: draftable lane, reddit connected, etc.), boot skips exactly
4741
+ // this set while paused, and the label list is mirrored to
4742
+ // <state>/pause-targets.json so the menubar's Python pause button
4743
+ // (mcp/menubar/s4l_state.py) acts on the same set without a second hardcoded
4744
+ // list. Add a new pipeline daemon HERE and every pause surface picks it up.
4745
+ // Feedback-digest is deliberately absent: it only distills past review
4746
+ // decisions, not drafting/posting, so it keeps running while paused.
4613
4747
  const PAUSE_TARGETS = [
4614
- { label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST },
4615
- { label: REAPER_LABEL, plist: REAPER_PLIST },
4616
- { label: STALL_WATCH_LABEL, plist: STALL_WATCH_PLIST },
4617
- { label: MEMORY_SNAPSHOT_LABEL, plist: MEMORY_SNAPSHOT_PLIST },
4748
+ { tag: "queue-worker", label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST, ensure: ensureQueueKickerInstalled },
4749
+ { tag: "reddit-kicker", label: REDDIT_SEARCH_LABEL, plist: REDDIT_SEARCH_PLIST, ensure: ensureRedditKickerInstalled },
4750
+ { tag: "claude-reaper", label: REAPER_LABEL, plist: REAPER_PLIST, ensure: ensureClaudeReaperInstalled },
4751
+ { tag: "stall-watch", label: STALL_WATCH_LABEL, plist: STALL_WATCH_PLIST, ensure: ensureStallWatchInstalled },
4752
+ { tag: "memory-snapshot", label: MEMORY_SNAPSHOT_LABEL, plist: MEMORY_SNAPSHOT_PLIST, ensure: ensureMemorySnapshotInstalled },
4618
4753
  ];
4754
+ // Mirror the pausable label set into the state dir for the menubar's Python
4755
+ // pause/resume (which can't import this module). Written at boot and on every
4756
+ // pause/resume so the two surfaces can't drift.
4757
+ function writePauseTargetsManifest() {
4758
+ try {
4759
+ fs.mkdirSync(s4lStateDir(), { recursive: true });
4760
+ fs.writeFileSync(path.join(s4lStateDir(), "pause-targets.json"), JSON.stringify({ labels: PAUSE_TARGETS.map((t) => t.label) }) + "\n", "utf-8");
4761
+ }
4762
+ catch {
4763
+ /* best-effort */
4764
+ }
4765
+ }
4619
4766
  async function pauseS4L() {
4620
4767
  if (isPaused())
4621
4768
  return { ok: true, detail: "already paused" };
4769
+ writePauseTargetsManifest();
4622
4770
  try {
4623
4771
  fs.mkdirSync(path.dirname(pauseFlagPath()), { recursive: true });
4624
4772
  fs.writeFileSync(pauseFlagPath(), `paused at ${new Date().toISOString()}\n`, "utf-8");
@@ -4646,17 +4794,18 @@ async function resumeS4L() {
4646
4794
  catch {
4647
4795
  /* best-effort */
4648
4796
  }
4649
- const kicker = await ensureQueueKickerInstalled();
4650
- const reaper = await ensureClaudeReaperInstalled();
4651
- const stall = await ensureStallWatchInstalled();
4652
- const mem = await ensureMemorySnapshotInstalled();
4653
- const detail = [
4654
- `kicker: ${kicker.ok ? "ok" : "skip"} (${kicker.detail})`,
4655
- `reaper: ${reaper.ok ? "ok" : "skip"} (${reaper.detail})`,
4656
- `stall-watch: ${stall.ok ? "ok" : "skip"} (${stall.detail})`,
4657
- `memory-snapshot: ${mem.ok ? "ok" : "skip"} (${mem.detail})`,
4658
- ].join("; ");
4659
- return { ok: true, detail };
4797
+ writePauseTargetsManifest();
4798
+ const results = [];
4799
+ for (const t of PAUSE_TARGETS) {
4800
+ try {
4801
+ const r = await t.ensure();
4802
+ results.push(`${t.tag}: ${r.ok ? "ok" : "skip"} (${r.detail})`);
4803
+ }
4804
+ catch (e) {
4805
+ results.push(`${t.tag}: ${e?.message || e}`);
4806
+ }
4807
+ }
4808
+ return { ok: true, detail: results.join("; ") };
4660
4809
  }
4661
4810
  // Install/refresh the on-screen overlay watcher launchd job. Promotes the
4662
4811
  // harness status overlay from a best-effort, fired-from-other-tools nicety to a
@@ -5344,7 +5493,7 @@ function persistStatusSummary(snap) {
5344
5493
  // chat-table review path is unchanged and still works; this just ALSO lets the
5345
5494
  // corner cards drive review (both surfaces de-dup via the plan's `posted` flag).
5346
5495
  // The menu bar reads review-request.json, presents the cards, posts via the
5347
- // loopback post_drafts tool, then clears the file. Best-effort: a write failure
5496
+ // loopback approve_drafts tool, then clears the file. Best-effort: a write failure
5348
5497
  // just means no pop-ups this batch (chat review still works).
5349
5498
  function writeReviewRequest(req) {
5350
5499
  try {
@@ -5528,7 +5677,7 @@ function sigkillAllScans() {
5528
5677
  }
5529
5678
  // ---- Lock grace-hold: hold the /tmp lock CONTINUOUSLY across per-card posts ----
5530
5679
  // The plist pipeline acquires the browser lock ONCE and holds it through the whole
5531
- // posting phase. The MCP posts per approved card (separate post_drafts calls), and
5680
+ // posting phase. The MCP posts per approved card (separate approve_drafts calls), and
5532
5681
  // the old code acquired+released the lock PER CARD — leaving a release window
5533
5682
  // BETWEEN every card that a parked scan stale-reclaimed (the hijack). Instead we
5534
5683
  // keep the lock and only release it after SHELL_LOCK_GRACE_MS of no posting, so the
@@ -5683,7 +5832,7 @@ appTool("dashboard", {
5683
5832
  "connection, autopilot state, and 7-day stats, with buttons to set up the schedule, connect X, " +
5684
5833
  "and refresh. Use when the user asks to see the dashboard, panel, " +
5685
5834
  "status, or controls. ALSO call this at the end of any state-changing or results-producing " +
5686
- "action (post_drafts, get_stats, project_config) so the user sees the " +
5835
+ "action (approve_drafts, get_stats, project_config) so the user sees the " +
5687
5836
  "updated dashboard. Hosts without UI support get the same data as text.",
5688
5837
  inputSchema: {},
5689
5838
  // fallback_url is set only when the host can't render the ui:// resource and
@@ -5697,7 +5846,7 @@ appTool("dashboard", {
5697
5846
  (snap.update_available && snap.latest_version ? ` (update to ${snap.latest_version})` : "") +
5698
5847
  ` — projects ${snap.projects_ready}/${snap.projects_total} ready, ` +
5699
5848
  `X ${snap.x_connected ? "connected" : "not connected"}, ` +
5700
- `autopilot ${snap.autopilot_on ? "on" : "off"}.`;
5849
+ `autopilot ${snap.paused ? "PAUSED" : snap.autopilot_on ? "on" : "off"}.`;
5701
5850
  const base = {
5702
5851
  content: [{ type: "text", text: human }],
5703
5852
  structuredContent: { snapshot: JSON.stringify(snap) },
@@ -5949,70 +6098,34 @@ async function main() {
5949
6098
  catch (e) {
5950
6099
  console.error(`[queue-worker] could not create worker folder: ${e?.message || e}`);
5951
6100
  }
5952
- // The 4 pipeline daemons (kicker, reaper, stall-watch, memory-snapshot) are
5953
- // skipped at boot while paused.flag is present, so a Claude Desktop restart
6101
+ // Pausable pipeline daemons — one loop over the SAME registry pause/resume
6102
+ // use (PAUSE_TARGETS: X kicker, reddit kicker, reaper, stall-watch,
6103
+ // memory-snapshot), so boot can never install a daemon Pause doesn't cover.
6104
+ // Each is skipped while paused.flag is present, so a Claude Desktop restart
5954
6105
  // during a Pause doesn't silently un-pause the pipeline — see pauseS4L/
5955
- // resumeS4L. Feedback-digest is NOT gated: it only distills past review
5956
- // decisions, not drafting/posting, so it's harmless to keep running.
5957
- if (!isPaused()) {
5958
- void ensureQueueKickerInstalled()
5959
- .then((r) => console.error(`[queue-worker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
5960
- .catch((e) => console.error("[queue-worker] kicker install failed:", e?.message || e));
5961
- }
5962
- else {
5963
- console.error("[queue-worker] launchd kicker: skip (paused)");
5964
- }
5965
- // Reddit discovery kicker (optional platform): installs only when reddit is
5966
- // connected AND a project is ready; a box that never connected reddit is a
5967
- // cheap no-op skip. Paused-gated like the X kicker (it produces drafts).
5968
- if (!isPaused()) {
5969
- void ensureRedditKickerInstalled()
5970
- .then((r) => console.error(`[reddit-kicker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
5971
- .catch((e) => console.error("[reddit-kicker] kicker install failed:", e?.message || e));
5972
- }
5973
- else {
5974
- console.error("[reddit-kicker] launchd kicker: skip (paused)");
5975
- }
5976
- // Self-healing reaper for the agent-mode session leak the queue autopilot
5977
- // produces (finished `claude` worker sessions Desktop never tears down). A
5978
- // standalone guardrail; install unconditionally so it caps memory even on a
5979
- // box whose project isn't ready yet. Best-effort; must never block boot.
5980
- if (!isPaused()) {
5981
- void ensureClaudeReaperInstalled()
5982
- .then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
5983
- .catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
5984
- }
5985
- else {
5986
- console.error("[claude-reaper] launchd reaper: skip (paused)");
6106
+ // resumeS4L. Each ensure keeps its own install gates (draftable lane, reddit
6107
+ // connected, etc.); a box that never connected reddit is a cheap no-op skip.
6108
+ // The boot-time call also SELF-HEALS installs broken by older gates: once a
6109
+ // gate passes, the plist lands on the next boot. All best-effort; never block
6110
+ // boot.
6111
+ writePauseTargetsManifest();
6112
+ for (const t of PAUSE_TARGETS) {
6113
+ if (isPaused()) {
6114
+ console.error(`[${t.tag}] launchd ${t.label}: skip (paused)`);
6115
+ continue;
6116
+ }
6117
+ void t.ensure()
6118
+ .then((r) => console.error(`[${t.tag}] launchd: ${r.ok ? "ok" : "skip"} (${r.detail})`))
6119
+ .catch((e) => console.error(`[${t.tag}] launchd install failed:`, e?.message || e));
5987
6120
  }
5988
6121
  // Feedback digest: hourly distillation of the user's card approve/reject
5989
6122
  // decisions into learned_preferences (see scripts/feedback_digest.py).
5990
- // Best-effort; a box with no review events runs a no-op.
6123
+ // Best-effort; a box with no review events runs a no-op. NOT pause-gated (and
6124
+ // not in PAUSE_TARGETS): it only distills past review decisions, not
6125
+ // drafting/posting, so it's harmless to keep running.
5991
6126
  void ensureFeedbackDigestInstalled()
5992
6127
  .then((r) => console.error(`[feedback-digest] launchd digest: ${r.ok ? "ok" : "skip"} (${r.detail})`))
5993
6128
  .catch((e) => console.error("[feedback-digest] digest install failed:", e?.message || e));
5994
- // Autopilot stall watchdog: fleet-side Sentry alert when the draft routines stop
5995
- // draining (most often an account switch orphaning them). The menu bar shows the
5996
- // user the Re-arm action; this is the part we see. Best-effort; never blocks boot.
5997
- if (!isPaused()) {
5998
- void ensureStallWatchInstalled()
5999
- .then((r) => console.error(`[stall-watch] launchd watchdog: ${r.ok ? "ok" : "skip"} (${r.detail})`))
6000
- .catch((e) => console.error("[stall-watch] watchdog install failed:", e?.message || e));
6001
- }
6002
- else {
6003
- console.error("[stall-watch] launchd watchdog: skip (paused)");
6004
- }
6005
- // Periodic host-resource sampler (memory/process snapshot -> local JSONL). Gives
6006
- // us per-box resource history to diagnose RAM blowups (e.g. the agent-mode
6007
- // session leak). Best-effort; never blocks boot. Disable with S4L_MEMORY_SNAPSHOT=0.
6008
- if (!isPaused()) {
6009
- void ensureMemorySnapshotInstalled()
6010
- .then((r) => console.error(`[memory-snapshot] launchd sampler: ${r.ok ? "ok" : "skip"} (${r.detail})`))
6011
- .catch((e) => console.error("[memory-snapshot] sampler install failed:", e?.message || e));
6012
- }
6013
- else {
6014
- console.error("[memory-snapshot] launchd sampler: skip (paused)");
6015
- }
6016
6129
  // On-screen overlay watcher supervisor. The harness status overlay only renders
6017
6130
  // while the watcher process is alive, and that watcher had no supervisor — when
6018
6131
  // it died nothing respawned it and the overlay silently vanished. Install it as