@m13v/s4l 1.7.5 → 1.7.6-rc.10

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 (42) hide show
  1. package/mcp/dist/index.js +228 -60
  2. package/mcp/dist/telemetry.js +2 -1
  3. package/mcp/dist/version.json +2 -2
  4. package/mcp/manifest.json +1 -1
  5. package/mcp/menubar/s4l_card.py +81 -14
  6. package/mcp/menubar/s4l_card_canvas.py +73 -3
  7. package/mcp/menubar/s4l_menubar.py +150 -68
  8. package/mcp/menubar/s4l_state.py +17 -122
  9. package/mcp/package.json +1 -1
  10. package/package.json +1 -1
  11. package/scripts/_compute_allowlist.py +58 -0
  12. package/scripts/_db_update.py +20 -0
  13. package/scripts/_filt.py +9 -0
  14. package/scripts/_li_notif_match.py +76 -0
  15. package/scripts/_li_notif_orchestrate.py +126 -0
  16. package/scripts/_process_li_notifs.py +91 -0
  17. package/scripts/_run_icp_precheck.py +57 -0
  18. package/scripts/cdp_ready_check.py +81 -50
  19. package/scripts/claude_job.py +14 -9
  20. package/scripts/draft_prompt_core.py +823 -0
  21. package/scripts/engagement_styles.py +88 -98
  22. package/scripts/log_draft.py +38 -3
  23. package/scripts/merge_review_queue.py +184 -129
  24. package/scripts/post_reddit.py +104 -136
  25. package/scripts/reap_stale_claude_sessions.py +7 -6
  26. package/scripts/reddit_tools.py +9 -0
  27. package/scripts/store_patch.py +111 -0
  28. package/scripts/test_draft_prompt_core.py +255 -0
  29. package/scripts/top_performers.py +24 -0
  30. package/skill/archive-old-logs.sh +24 -0
  31. package/skill/audit.sh +12 -23
  32. package/skill/dm-outreach-twitter.sh +3 -1
  33. package/skill/engage-dm-replies.sh +5 -2
  34. package/skill/engage-linkedin.sh +1 -1
  35. package/skill/engage-twitter.sh +4 -2
  36. package/skill/lib/harness-common.sh +26 -0
  37. package/skill/lib/reddit-backend.sh +1 -0
  38. package/skill/refresh-twitter-following.sh +3 -1
  39. package/skill/run-twitter-cycle.sh +203 -456
  40. package/skill/run-twitter-threads.sh +3 -1
  41. package/skill/scan-twitter-followups.sh +3 -1
  42. package/skill/stats.sh +9 -1
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";
@@ -909,12 +909,57 @@ async function ensureTwitterBrowserForPost() {
909
909
  // thread still exists. Without this override, a card approved while (or just
910
910
  // before) the sync stamped it is refused as already-decided and the approval
911
911
  // silently no-ops (2 of 3 approvals lost on 2026-07-10).
912
+ // Give-up bound for approved cards whose post attempts keep failing
913
+ // transiently (browser lock contention, timeouts). 5 attempts spans several
914
+ // drain cycles — plenty for genuine transients to clear — while guaranteeing
915
+ // no card can retry forever (2026-07-17 zombie-card incident).
916
+ const MAX_POST_ATTEMPTS = 5;
912
917
  function expiredStampOverridable(c) {
913
918
  return (c.terminal === true &&
914
919
  c.posted !== true &&
915
920
  c.discard_reason === "backend_status_expired");
916
921
  }
917
- function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
922
+ // Prompt-sandbox replay cards (run-twitter-cycle.sh S4L_SANDBOX_CANDIDATES_FILE)
923
+ // carry experiments.sandbox=true; older sandbox rows predate that stamp but all
924
+ // use the synthetic >=900,000,000 id range twitter_prompt_sandbox.py assigns.
925
+ function isSandboxCandidate(c) {
926
+ const exps = c.experiments;
927
+ if (exps && typeof exps === "object" && exps.sandbox)
928
+ return true;
929
+ const id = Number(c.candidate_id);
930
+ return Number.isFinite(id) && id >= 900_000_000;
931
+ }
932
+ // Write field patches into the review-queue store UNDER ITS LOCK by shelling
933
+ // to scripts/store_patch.py, which takes the same fcntl.flock the menubar's
934
+ // _store_update and merge_review_queue.py hold around their read-modify-write.
935
+ // Node has no native flock, and this process writing the store directly was
936
+ // the last unlocked writer (the race that erased posted stamps on 2026-07-17).
937
+ // Returns false on any failure so callers can fall back to the legacy write.
938
+ async function patchReviewStore(patches) {
939
+ if (!patches.length)
940
+ return true;
941
+ const tmp = path.join(TMP_DIR, `s4l-store-patches-${process.pid}-${Date.now()}.json`);
942
+ try {
943
+ fs.writeFileSync(tmp, JSON.stringify({ patches }), "utf-8");
944
+ const res = await runPython("scripts/store_patch.py", [tmp], {
945
+ timeoutMs: 30_000,
946
+ env: { S4L_REPO_DIR: repoDir(), PATH: pipelinePath() },
947
+ });
948
+ return res.code === 0;
949
+ }
950
+ catch {
951
+ return false;
952
+ }
953
+ finally {
954
+ try {
955
+ fs.unlinkSync(tmp);
956
+ }
957
+ catch {
958
+ /* best effort */
959
+ }
960
+ }
961
+ }
962
+ async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
918
963
  // Merge posted/terminal stamps into a FRESH read of the store instead of
919
964
  // rewriting the whole plan from the copy taken minutes ago. The old
920
965
  // whole-file write was last-writer-wins: while a batch posted, the menubar
@@ -926,28 +971,97 @@ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
926
971
  // overwrites a fresh `posted=true`. Fallback: candidates without a
927
972
  // candidate_id can't be matched into the fresh copy, so keep the legacy
928
973
  // whole-plan write for those older plans.
974
+ //
975
+ // Review-queue store: go through the LOCKED patch path (store_patch.py)
976
+ // first. The fresh-read merge below closes most of the race window but not
977
+ // all of it — a menubar decision landing between our readPlan and writePlan
978
+ // still gets erased. The locked path holds the store's flock for the whole
979
+ // read-mutate-replace, applies to every sibling row sharing a candidate_id,
980
+ // and enforces the same posted-sticky rules. Legacy path stays as the
981
+ // fallback and for per-batch /tmp plans (single writer, no lock needed).
982
+ try {
983
+ const mergeableForPatch = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
984
+ if (batchId === REVIEW_QUEUE_ID && mergeableForPatch) {
985
+ const patches = stamped.map((c) => {
986
+ const set = {};
987
+ const unset = [];
988
+ if (c.posted === true) {
989
+ set.posted = true;
990
+ set.terminal = false;
991
+ if (c.our_url)
992
+ set.our_url = c.our_url;
993
+ unset.push("discard_reason");
994
+ }
995
+ else if (c.terminal === true) {
996
+ set.terminal = true;
997
+ set.terminal_reason = c.terminal_reason ?? null;
998
+ // See the zombie-card note in the legacy branch below: a terminal
999
+ // from a real post attempt must clear the overridable expiry stamp.
1000
+ unset.push("discard_reason");
1001
+ }
1002
+ if (typeof c.post_attempts === "number")
1003
+ set.post_attempts = c.post_attempts;
1004
+ return { candidate_id: c.candidate_id, set, unset };
1005
+ });
1006
+ if (await patchReviewStore(patches))
1007
+ return;
1008
+ console.error("[post] store_patch.py failed; falling back to unlocked stamp merge");
1009
+ }
1010
+ }
1011
+ catch {
1012
+ /* fall through to the legacy write */
1013
+ }
929
1014
  try {
930
1015
  const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
931
1016
  const fresh = mergeable ? readPlan(batchId) : null;
932
1017
  if (fresh && Array.isArray(fresh.candidates)) {
1018
+ // candidate_id is NOT unique in the review-queue store: sandbox reruns
1019
+ // and re-merged drafts append sibling rows with the same id. Stamping
1020
+ // only one sibling (the old Map single-slot) left the others matching
1021
+ // the drain's approved && !posted && !terminal filter, so the backlog
1022
+ // re-drained the same candidate every heartbeat forever (2026-07-17
1023
+ // incident: 23-card loop at 60s cadence). Stamp EVERY row with the id.
933
1024
  const freshById = new Map();
934
1025
  fresh.candidates.forEach((c) => {
935
- if (c.candidate_id !== undefined && c.candidate_id !== null)
936
- freshById.set(String(c.candidate_id), c);
1026
+ if (c.candidate_id !== undefined && c.candidate_id !== null) {
1027
+ const key = String(c.candidate_id);
1028
+ const list = freshById.get(key);
1029
+ if (list)
1030
+ list.push(c);
1031
+ else
1032
+ freshById.set(key, [c]);
1033
+ }
937
1034
  });
938
1035
  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;
1036
+ for (const f of freshById.get(String(c.candidate_id)) ?? []) {
1037
+ if (c.posted === true) {
1038
+ f.posted = true;
1039
+ f.terminal = false;
1040
+ if (c.our_url)
1041
+ f.our_url = c.our_url;
1042
+ // A post outcome closes the card's history: the pre-approval
1043
+ // freshness stamp must not survive it.
1044
+ delete f.discard_reason;
1045
+ }
1046
+ else if (c.terminal === true && f.posted !== true) {
1047
+ f.terminal = true;
1048
+ f.terminal_reason = c.terminal_reason;
1049
+ // CRITICAL (2026-07-17 Nhat zombie-card incident, 438 retries over
1050
+ // 5 days): a stale discard_reason="backend_status_expired" left on
1051
+ // the fresh copy makes expiredStampOverridable() treat THIS
1052
+ // post-outcome terminal as overridable, so every later drain
1053
+ // resurrects the card, re-posts, dedup-skips, and re-stamps —
1054
+ // forever. A terminal that came from an actual post attempt is
1055
+ // final; delete the expiry stamp so the override can never fire
1056
+ // on it again. (The drain's own `delete c.discard_reason` happens
1057
+ // on an in-memory copy that is never the write source; this line
1058
+ // is the one that persists.)
1059
+ delete f.discard_reason;
1060
+ }
1061
+ // Carry the retry counter so the give-up bound survives across
1062
+ // drains (each drain reads the store fresh).
1063
+ if (typeof c.post_attempts === "number" && c.post_attempts > (f.post_attempts || 0))
1064
+ f.post_attempts = c.post_attempts;
951
1065
  }
952
1066
  }
953
1067
  writePlan(batchId, fresh);
@@ -1043,7 +1157,12 @@ async function postApproved(batchId, plan) {
1043
1157
  // and the stamp is cleared so every downstream terminal check agrees it's live.
1044
1158
  const approved = (plan.candidates || []).filter((c) => c.approved === true &&
1045
1159
  c.posted !== true &&
1046
- (c.terminal !== true || expiredStampOverridable(c)));
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));
1047
1166
  for (const c of approved) {
1048
1167
  if (c.terminal === true) {
1049
1168
  c.terminal = false;
@@ -1241,7 +1360,7 @@ async function postApproved(batchId, plan) {
1241
1360
  };
1242
1361
  }
1243
1362
  if (approvedReddit.length)
1244
- mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1363
+ await mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1245
1364
  return {
1246
1365
  attempted: approvedReddit.length,
1247
1366
  posted: redditPosted,
@@ -1482,6 +1601,21 @@ async function postApproved(batchId, plan) {
1482
1601
  // here (found 2026-07-16) silently and permanently discarded 7 real
1483
1602
  // approved drafts on nothing worse than lock contention — Reddit's
1484
1603
  // equivalent transient failures self-healed on the very next drain.
1604
+ //
1605
+ // BOUNDED (2026-07-17): sticky is right, sticky-forever is not — an
1606
+ // unbounded retry is a zombie generator (one card retried 438 times
1607
+ // over 5 days on the Nhat install). Count the transient failures and
1608
+ // give up loudly after MAX_POST_ATTEMPTS; the terminal_reason keeps
1609
+ // the last failure visible so the give-up is diagnosable, and the
1610
+ // on-disk post-events trail records it for forensics.
1611
+ const attempts = (typeof c.post_attempts === "number" ? c.post_attempts : 0) + 1;
1612
+ c.post_attempts = attempts;
1613
+ if (attempts >= MAX_POST_ATTEMPTS) {
1614
+ c.terminal = true;
1615
+ c.terminal_reason = `gave_up_after_${attempts}_failed_attempts:${r.reason || "failed"}`;
1616
+ console.error(`[post] giving up on candidate ${r.candidate_id} after ${attempts} failed attempts (last: ${r.reason || "failed"})`);
1617
+ logPostEvent(`retry_budget_exhausted candidate=${r.candidate_id} attempts=${attempts} last=${r.reason || "failed"}`);
1618
+ }
1485
1619
  touchedPlan = true;
1486
1620
  }
1487
1621
  });
@@ -1496,7 +1630,7 @@ async function postApproved(batchId, plan) {
1496
1630
  // Reddit stamps (set in the reddit drain above) merge alongside the twitter
1497
1631
  // ones: `approved` here spans both platforms.
1498
1632
  if (touchedPlan || redditPosted || redditFailed) {
1499
- mergeApprovedStampsIntoStore(batchId, plan, approved);
1633
+ await mergeApprovedStampsIntoStore(batchId, plan, approved);
1500
1634
  }
1501
1635
  // Post failures are HANDLED in the pipeline (it returns a count, never throws),
1502
1636
  // so they never reach Sentry on their own. Capture an explicit event whenever
@@ -2741,6 +2875,16 @@ tool("post_drafts", {
2741
2875
  const total = candidates.length;
2742
2876
  const warnings = [];
2743
2877
  const inRange = (n) => n >= 1 && n <= total;
2878
+ // Review-queue store: snapshot every row now so the write below can be a
2879
+ // field-level DIFF applied under the store lock (store_patch.py) instead
2880
+ // of an unlocked whole-file replace. This function holds its in-memory
2881
+ // plan across user think-time; a whole-file write here erased any menubar
2882
+ // decision or merge that landed in between (the 2026-07-17 truth-loss
2883
+ // family). Non-store batches keep the plain write: single writer.
2884
+ const isStoreBatch = batch_id === REVIEW_QUEUE_ID;
2885
+ const rowsBefore = isStoreBatch
2886
+ ? candidates.map((c) => JSON.stringify(c))
2887
+ : [];
2744
2888
  // ---- Rejections: durable + final --------------------------------------
2745
2889
  // A rejected draft is marked terminal so it NEVER re-appears for review and is
2746
2890
  // never posted. A reject overrides any earlier approve on the same card.
@@ -2877,7 +3021,40 @@ tool("post_drafts", {
2877
3021
  if (c)
2878
3022
  c.approved = true;
2879
3023
  });
2880
- writePlan(batch_id, plan);
3024
+ // Persist the decision mutations. Store batch: diff each row against its
3025
+ // snapshot and apply only the changed fields under the store lock, so a
3026
+ // concurrent menubar decision or merge is never erased. Anything else
3027
+ // (per-batch /tmp plans): plain write, single writer.
3028
+ let storeWriteDone = false;
3029
+ if (isStoreBatch) {
3030
+ const patches = [];
3031
+ candidates.forEach((c, i) => {
3032
+ const beforeRaw = rowsBefore[i];
3033
+ const afterRaw = JSON.stringify(c);
3034
+ if (beforeRaw === afterRaw)
3035
+ return;
3036
+ const before = JSON.parse(beforeRaw ?? "{}");
3037
+ const after = JSON.parse(afterRaw);
3038
+ const set = {};
3039
+ const unset = [];
3040
+ for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
3041
+ if (!(k in after) || after[k] === undefined) {
3042
+ if (k in before)
3043
+ unset.push(k);
3044
+ }
3045
+ else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
3046
+ set[k] = after[k];
3047
+ }
3048
+ }
3049
+ if (Object.keys(set).length || unset.length)
3050
+ patches.push({ candidate_id: c.candidate_id ?? null, n: i + 1, set, unset });
3051
+ });
3052
+ storeWriteDone = await patchReviewStore(patches);
3053
+ if (!storeWriteDone)
3054
+ console.error("[post_drafts] store_patch.py failed; falling back to unlocked plan write");
3055
+ }
3056
+ if (!storeWriteDone)
3057
+ writePlan(batch_id, plan);
2881
3058
  if (approve.size === 0) {
2882
3059
  return jsonContent({
2883
3060
  batch_id,
@@ -3468,8 +3645,8 @@ async function autopilotLoaded() {
3468
3645
  // fires every minute, claims ONE job, runs the pipeline's own prompt as its
3469
3646
  // Claude turn, writes the result back, and stops.
3470
3647
  // ===========================================================================
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
3648
+ 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.
3649
+ // v10 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
3473
3650
  // sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
3474
3651
  // within one continuous worker session) instead of drafting inline. Validated
3475
3652
  // via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
@@ -3478,19 +3655,26 @@ const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_jo
3478
3655
  // notification) or the host kills the whole parent+child chain in 1-3 min.
3479
3656
  // Never live-fire tested against a real production job. Full design, what's
3480
3657
  // 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.
3658
+ // Bump this constant to 10 only once that plan is actually implemented.
3482
3659
  const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
3483
3660
  // 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.
3661
+ // 900s (15 min, per Matthew 2026-07-17, up from 240s): sits AT the single-
3662
+ // Bash-call survival ceiling verified live on 2026-07-06 (the host's ~90s
3663
+ // inactivity kill fires only on model silence, and one in-flight tool call
3664
+ // survived a full 900s probe). This covers the ~8min average real job
3665
+ // inter-arrival gap outright, so most jobs are claimed by an already-polling
3666
+ // session instead of paying a fresh spin-up, and MCP boot side effects
3667
+ // (backfill checks, backlog drains) run 1/15min instead of 1/5min. Watch
3668
+ // point: 900s has zero margin below the verified ceiling — if workers start
3669
+ // dying mid-poll with no reaper kill recorded, the host clipped the call;
3670
+ // back off to 600s. The cron's `* * * * *` cadence remains the outer safety
3671
+ // net for whatever the poll window doesn't catch.
3489
3672
  // COUPLING: scripts/reap_stale_claude_sessions.py's S4L_REAPER_CLAIM_GRACE_SEC
3490
3673
  // default MUST stay >= this value + margin — a claimless session inside this
3491
3674
  // poll window is legitimately still working, not a husk, and a too-tight
3492
3675
  // claim_grace would SIGTERM it mid-poll before it ever gets to claim.
3493
- const QUEUE_WORKER_POLL_SECONDS = 240;
3676
+ // (Bumped to 1020s alongside this change.)
3677
+ const QUEUE_WORKER_POLL_SECONDS = 900;
3494
3678
  // One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
3495
3679
  const QUEUE_WORKERS = [
3496
3680
  { taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
@@ -5684,30 +5868,18 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
5684
5868
  },
5685
5869
  ],
5686
5870
  }));
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
- }
5871
+ // REMOVED (2026-07-17): drainApprovedBacklog. It ran 30s after EVERY MCP boot,
5872
+ // which was sane when boots meant "user launched Claude Desktop" but each
5873
+ // queue-worker session boots its own MCP server, so the drain had silently
5874
+ // become a ~5-minute cron running across up to 4 concurrent MCP instances.
5875
+ // Combined with universal posting preemption, every drain wakeup SIGKILLed
5876
+ // whatever held the twitter-browser lock (profile scans included), and any
5877
+ // stamp bug turned into an infinite retry loop (438 retries over 5 days on
5878
+ // one Nhat card). Backlog recovery is now owned by ONE long-lived process:
5879
+ // the menubar's _resume_approved_queue, which runs on loopback-reachable and
5880
+ // periodically thereafter (mcp/menubar/s4l_menubar.py). Do NOT re-add a
5881
+ // boot-time drain here; if the menubar is dead, ensureMenubar() below revives
5882
+ // it and its resume covers the backlog.
5711
5883
  async function main() {
5712
5884
  initSentry();
5713
5885
  // Detect a self-update (old_version -> new_version) as the very first thing
@@ -5883,13 +6055,9 @@ async function main() {
5883
6055
  void startLocalPanel()
5884
6056
  .then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
5885
6057
  .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
- }
6058
+ // NOTE (2026-07-17): the boot-time drainApprovedBacklog() call that lived
6059
+ // here is gone backlog recovery is owned by the menubar's periodic
6060
+ // _resume_approved_queue (single drainer; see the removal note above).
5893
6061
  // Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
5894
6062
  // and cheap when already present, so existing installs pick it up on the next
5895
6063
  // Claude restart without re-provisioning. Best-effort: never blocks boot.
@@ -301,7 +301,8 @@ function collectStateSnapshot() {
301
301
  ["install_progress", "install-progress.json", 64_000],
302
302
  ["onboarding_progress", "onboarding-progress.json", 256_000],
303
303
  ["review_queue", "review-queue.json", 256_000],
304
- ["approved_queue", "approved-queue.json", 256_000],
304
+ // approved-queue.json removed 2026-07-17: the ledger is gone; the review
305
+ // store is the only local decision record.
305
306
  ];
306
307
  for (const [key, file, cap] of stateFiles) {
307
308
  const val = readJsonCapped(path.join(stateDir, file), cap);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.5",
3
- "installedAt": "2026-07-17T00:02:11.441Z"
2
+ "version": "1.7.6-rc.10",
3
+ "installedAt": "2026-07-17T18:19:18.200Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.5",
5
+ "version": "1.7.6-rc.10",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
@@ -801,6 +801,31 @@ def _label(frame, text, *, size=12, bold=False, muted=False, truncates=False):
801
801
  return f
802
802
 
803
803
 
804
+ class _TileScroll(NSScrollView):
805
+ """Scroll box that yields the wheel to an enclosing canvas grid. Inside
806
+ the canvas (s4l_card_canvas.py) most of a tile's area is one of these
807
+ boxes, and AppKit routes the whole wheel gesture (momentum included) to
808
+ the innermost scroll view under the pointer -- so the canvas simply
809
+ would not scroll whenever the pointer sat over a thread quote or draft
810
+ editor (2026-07-16 user report). Forward the event to the outer scroll
811
+ view instead, UNLESS this box's own text view holds the caret (the
812
+ reviewer clicked into it, so inner scrolling is what they want). In the
813
+ corner card there is no enclosing scroll view and behavior is unchanged."""
814
+
815
+ def scrollWheel_(self, event):
816
+ try:
817
+ outer = self.enclosingScrollView()
818
+ if outer is not None:
819
+ win = self.window()
820
+ fr = win.firstResponder() if win is not None else None
821
+ if fr is None or fr is not self.documentView():
822
+ outer.scrollWheel_(event)
823
+ return
824
+ except Exception:
825
+ pass
826
+ objc.super(_TileScroll, self).scrollWheel_(event)
827
+
828
+
804
829
  def _editable_scroll(frame, text=""):
805
830
  """Rounded-rect scrollable text editor (hairline border, solid text
806
831
  background over the frosted panel; falls back to the old square bezel when
@@ -809,7 +834,7 @@ def _editable_scroll(frame, text=""):
809
834
  outer frame the text runs underneath the scroller. The scroller itself
810
835
  auto-hides so it only appears when the text overflows.
811
836
  Returns (scroll, textview)."""
812
- scroll = NSScrollView.alloc().initWithFrame_(frame)
837
+ scroll = _TileScroll.alloc().initWithFrame_(frame)
813
838
  scroll.setHasVerticalScroller_(True)
814
839
  scroll.setAutohidesScrollers_(True)
815
840
  if _round_rect(scroll):
@@ -902,6 +927,7 @@ class _ReviewController(NSObject):
902
927
  self._eye_btn = None
903
928
  self._details_btn = None
904
929
  self._stats_popover = None
930
+ self._pending_hover_kind = None
905
931
  self._age_expiry_label = None
906
932
  self._age_expiry_timer = None
907
933
  # Per-card telemetry, reset when a NEW card renders (not on the
@@ -1378,6 +1404,7 @@ class _ReviewController(NSObject):
1378
1404
  _label(NSMakeRect(M, H - 70, 78, 18), "Replying to", size=12, bold=True, muted=True)
1379
1405
  )
1380
1406
  right_x = W - M
1407
+ self._cancel_pending_hover()
1381
1408
  self._close_stats_popover()
1382
1409
  self._stop_age_expiry_timer()
1383
1410
  self._eye_btn = None
@@ -1511,7 +1538,7 @@ class _ReviewController(NSObject):
1511
1538
  # direction); the box scrolls instead of shrinking the text to fit,
1512
1539
  # and the link sits at the START so it's always visible regardless
1513
1540
  # of scroll position or thread length.
1514
- thread_scroll = NSScrollView.alloc().initWithFrame_(
1541
+ thread_scroll = _TileScroll.alloc().initWithFrame_(
1515
1542
  NSMakeRect(M, H - 150, W - 2 * M, 74)
1516
1543
  )
1517
1544
  thread_scroll.setHasVerticalScroller_(True)
@@ -1847,10 +1874,12 @@ class _ReviewController(NSObject):
1847
1874
  pop.setBehavior_(NSPopoverBehaviorApplicationDefined)
1848
1875
  pop.setContentViewController_(vc)
1849
1876
  pop.setContentSize_((pw, ph))
1850
- try:
1851
- NSApp.activateIgnoringOtherApps_(True)
1852
- except Exception:
1853
- pass
1877
+ # No activateIgnoringOtherApps_ here (removed 2026-07-16): with
1878
+ # ApplicationDefined behavior the popover shows fine from an inactive
1879
+ # accessory app, and yanking app activation on a HOVER (these popovers
1880
+ # open from mouseEntered_) stole focus from whatever the user was
1881
+ # doing -- worst on the canvas, where scrolling sweeps anchors under
1882
+ # the pointer.
1854
1883
  # Anchor to the eye's frame in the (non-flipped) content view, where
1855
1884
  # NSRectEdge 1 = NSMinYEdge is unambiguously the BOTTOM edge, so the
1856
1885
  # popover reliably opens below the icon. Anchoring to the button's own
@@ -1948,13 +1977,18 @@ class _ReviewController(NSObject):
1948
1977
  if kind == "draft":
1949
1978
  self._draft_hover_open[slot] = time.time()
1950
1979
  return
1951
- _log(f"{kind} eye hover enter" if kind != "expiry" else "expiry label hover enter")
1952
- if kind == "details":
1953
- self._show_details_popover()
1954
- elif kind == "expiry":
1955
- self._show_expiry_popover()
1956
- else:
1957
- self._show_stats_popover()
1980
+ # Dwell-gated (2026-07-16 canvas scroll-perf fix): scrolling the
1981
+ # canvas sweeps these tracking areas under a stationary pointer,
1982
+ # and opening a popover synchronously per enter event (text
1983
+ # measurement + NSPopover build) hitched the scroll. A short
1984
+ # delayed perform only fires if the pointer actually STAYS on the
1985
+ # anchor; enter/exit pairs from content moving underneath cancel
1986
+ # out. Bonus: the delayed perform is queued in the default run-loop
1987
+ # mode, which does not run during a wheel gesture (event-tracking
1988
+ # mode), so a popover can never open mid-scroll at all.
1989
+ self._cancel_pending_hover()
1990
+ self._pending_hover_kind = kind
1991
+ self.performSelector_withObject_afterDelay_("showHoverPopover:", kind, 0.2)
1958
1992
 
1959
1993
  def mouseExited_(self, event):
1960
1994
  kind, slot = self._hover_info(event)
@@ -1965,9 +1999,41 @@ class _ReviewController(NSObject):
1965
1999
  (time.time() - started) * 1000
1966
2000
  )
1967
2001
  return
1968
- _log("eye hover exit")
2002
+ self._cancel_pending_hover()
1969
2003
  self._close_stats_popover()
1970
2004
 
2005
+ @objc.python_method
2006
+ def _cancel_pending_hover(self):
2007
+ pending = getattr(self, "_pending_hover_kind", None)
2008
+ if pending is not None:
2009
+ try:
2010
+ NSObject.cancelPreviousPerformRequestsWithTarget_selector_object_(
2011
+ self, "showHoverPopover:", pending
2012
+ )
2013
+ except Exception:
2014
+ pass
2015
+ self._pending_hover_kind = None
2016
+
2017
+ def showHoverPopover_(self, kind):
2018
+ """Delayed-perform target for a dwelled hover (see mouseEntered_)."""
2019
+ self._pending_hover_kind = None
2020
+ kind = str(kind)
2021
+ try:
2022
+ _log(
2023
+ f"{kind} eye hover enter" if kind != "expiry" else "expiry label hover enter"
2024
+ )
2025
+ if kind == "details":
2026
+ self._show_details_popover()
2027
+ elif kind == "expiry":
2028
+ self._show_expiry_popover()
2029
+ else:
2030
+ self._show_stats_popover()
2031
+ except Exception:
2032
+ # The card may have re-rendered (anchor replaced) between the
2033
+ # hover and the dwell firing; a popover that can't anchor is
2034
+ # simply skipped.
2035
+ pass
2036
+
1971
2037
  @objc.python_method
1972
2038
  def _flush_draft_hovers(self):
1973
2039
  """Bank any hover still in progress (pointer inside a draft box at
@@ -2481,6 +2547,7 @@ class _ReviewController(NSObject):
2481
2547
  @objc.python_method
2482
2548
  def _finish(self):
2483
2549
  global _active
2550
+ self._cancel_pending_hover()
2484
2551
  self._close_stats_popover()
2485
2552
  self._stop_age_expiry_timer()
2486
2553
  if self._host_view is not None: