@m13v/s4l 1.7.6-rc.19 → 1.7.6-rc.2

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 (35) hide show
  1. package/mcp/dist/index.js +92 -256
  2. package/mcp/dist/telemetry.js +1 -2
  3. package/mcp/dist/version.json +2 -2
  4. package/mcp/manifest.json +1 -1
  5. package/mcp/menubar/s4l_browser_foreground.py +1 -7
  6. package/mcp/menubar/s4l_card.py +18 -144
  7. package/mcp/menubar/s4l_card_canvas.py +14 -135
  8. package/mcp/menubar/s4l_menubar.py +73 -216
  9. package/mcp/menubar/s4l_state.py +130 -45
  10. package/mcp/package.json +1 -1
  11. package/package.json +1 -1
  12. package/scripts/account_resolver.py +1 -35
  13. package/scripts/autopilot_stall_watch.py +1 -8
  14. package/scripts/cdp_ready_check.py +50 -81
  15. package/scripts/draft_prompt_core.py +17 -100
  16. package/scripts/engagement_styles.py +98 -135
  17. package/scripts/merge_review_queue.py +129 -192
  18. package/scripts/platform_strike_events.py +5 -54
  19. package/scripts/post_reddit.py +0 -10
  20. package/scripts/reap_stale_claude_sessions.py +6 -7
  21. package/scripts/reddit_browser.py +0 -24
  22. package/scripts/release-mcpb.sh +7 -38
  23. package/scripts/setup_twitter_auth.py +96 -20
  24. package/scripts/strike_alert.py +12 -161
  25. package/scripts/test_no_silent_fallbacks.py +0 -11
  26. package/scripts/top_performers.py +0 -24
  27. package/scripts/twitter_browser.py +52 -22
  28. package/skill/archive-old-logs.sh +0 -24
  29. package/skill/lib/reddit-backend.sh +0 -1
  30. package/skill/run-twitter-cycle.sh +66 -146
  31. package/scripts/browser_lifecycle.py +0 -90
  32. package/scripts/preview_reddit_card.py +0 -38
  33. package/scripts/reddit_ban_check.py +0 -220
  34. package/scripts/store_patch.py +0 -111
  35. package/scripts/test_stall_watch_batch_progression.py +0 -99
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, TMP_DIR, } from "./repo.js";
22
+ import { repoDir, runPython, run, readPlan, writePlan, planPath, } 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";
@@ -846,28 +846,40 @@ function parsePostCandidateResults(stdout) {
846
846
  }
847
847
  return [...byId.values()];
848
848
  }
849
- // Resolve the posting @handle through the ONE resolver (scripts/account_resolver.py:
850
- // env AUTOPOSTER_TWITTER_HANDLE -> config.json accounts.twitter.handle -> the durable
851
- // connect-time cookie mirror). Deferring to it, instead of re-reading config.json here,
852
- // keeps a single resolution path shared with the poster (twitter_browser.our_handle),
853
- // and keeps the handle in a single durable home (the cookie mirror) rather than a
854
- // second copy in config.json. Returns the bare handle (no @) or null; the preflight
855
- // refuses loudly on null so a missing handle fails ONCE, not as N silent per-reply
856
- // no_account_configured skips (and never as a hardcoded impersonation fallback).
857
- async function resolvePostingHandle() {
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() {
858
855
  const env = (process.env.AUTOPOSTER_TWITTER_HANDLE || "").trim().replace(/^@/, "");
859
856
  if (env)
860
857
  return env;
861
858
  try {
862
- const r = await runPython("scripts/account_resolver.py", ["twitter"], {
863
- timeoutMs: 30_000,
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
878
  env: ({ S4L_REPO_DIR: repoDir(), PATH: pipelinePath() }),
865
879
  });
866
- const h = (r.stdout || "").trim().replace(/^@/, "");
867
- return h || null;
868
880
  }
869
881
  catch {
870
- return null;
882
+ /* best effort */
871
883
  }
872
884
  }
873
885
  async function ensureTwitterBrowserForPost() {
@@ -897,57 +909,12 @@ async function ensureTwitterBrowserForPost() {
897
909
  // thread still exists. Without this override, a card approved while (or just
898
910
  // before) the sync stamped it is refused as already-decided and the approval
899
911
  // silently no-ops (2 of 3 approvals lost on 2026-07-10).
900
- // Give-up bound for approved cards whose post attempts keep failing
901
- // transiently (browser lock contention, timeouts). 5 attempts spans several
902
- // drain cycles — plenty for genuine transients to clear — while guaranteeing
903
- // no card can retry forever (2026-07-17 zombie-card incident).
904
- const MAX_POST_ATTEMPTS = 5;
905
912
  function expiredStampOverridable(c) {
906
913
  return (c.terminal === true &&
907
914
  c.posted !== true &&
908
915
  c.discard_reason === "backend_status_expired");
909
916
  }
910
- // Prompt-sandbox replay cards (run-twitter-cycle.sh S4L_SANDBOX_CANDIDATES_FILE)
911
- // carry experiments.sandbox=true; older sandbox rows predate that stamp but all
912
- // use the synthetic >=900,000,000 id range twitter_prompt_sandbox.py assigns.
913
- function isSandboxCandidate(c) {
914
- const exps = c.experiments;
915
- if (exps && typeof exps === "object" && exps.sandbox)
916
- return true;
917
- const id = Number(c.candidate_id);
918
- return Number.isFinite(id) && id >= 900_000_000;
919
- }
920
- // Write field patches into the review-queue store UNDER ITS LOCK by shelling
921
- // to scripts/store_patch.py, which takes the same fcntl.flock the menubar's
922
- // _store_update and merge_review_queue.py hold around their read-modify-write.
923
- // Node has no native flock, and this process writing the store directly was
924
- // the last unlocked writer (the race that erased posted stamps on 2026-07-17).
925
- // Returns false on any failure so callers can fall back to the legacy write.
926
- async function patchReviewStore(patches) {
927
- if (!patches.length)
928
- return true;
929
- const tmp = path.join(TMP_DIR, `s4l-store-patches-${process.pid}-${Date.now()}.json`);
930
- try {
931
- fs.writeFileSync(tmp, JSON.stringify({ patches }), "utf-8");
932
- const res = await runPython("scripts/store_patch.py", [tmp], {
933
- timeoutMs: 30_000,
934
- env: { S4L_REPO_DIR: repoDir(), PATH: pipelinePath() },
935
- });
936
- return res.code === 0;
937
- }
938
- catch {
939
- return false;
940
- }
941
- finally {
942
- try {
943
- fs.unlinkSync(tmp);
944
- }
945
- catch {
946
- /* best effort */
947
- }
948
- }
949
- }
950
- async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
917
+ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
951
918
  // Merge posted/terminal stamps into a FRESH read of the store instead of
952
919
  // rewriting the whole plan from the copy taken minutes ago. The old
953
920
  // whole-file write was last-writer-wins: while a batch posted, the menubar
@@ -959,106 +926,28 @@ async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
959
926
  // overwrites a fresh `posted=true`. Fallback: candidates without a
960
927
  // candidate_id can't be matched into the fresh copy, so keep the legacy
961
928
  // whole-plan write for those older plans.
962
- //
963
- // Review-queue store: go through the LOCKED patch path (store_patch.py)
964
- // first. The fresh-read merge below closes most of the race window but not
965
- // all of it — a menubar decision landing between our readPlan and writePlan
966
- // still gets erased. The locked path holds the store's flock for the whole
967
- // read-mutate-replace, applies to every sibling row sharing a candidate_id,
968
- // and enforces the same posted-sticky rules. Legacy path stays as the
969
- // fallback and for per-batch /tmp plans (single writer, no lock needed).
970
- try {
971
- const mergeableForPatch = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
972
- if (batchId === REVIEW_QUEUE_ID && mergeableForPatch) {
973
- const patches = stamped.map((c) => {
974
- const set = {};
975
- const unset = [];
976
- if (c.posted === true) {
977
- set.posted = true;
978
- set.terminal = false;
979
- if (c.our_url)
980
- set.our_url = c.our_url;
981
- // Clear a stale failure stamp too: the menubar's per-card call can
982
- // see posted=0 (the batch drain posted it under a different call)
983
- // and stamp post_failed on a card that IS live (seen on 565462,
984
- // 2026-07-17). posted is the settled truth; the residue just adds a
985
- // false "didn't post" signal to dashboards/notifications.
986
- unset.push("discard_reason", "post_failed", "post_error");
987
- }
988
- else if (c.terminal === true) {
989
- set.terminal = true;
990
- set.terminal_reason = c.terminal_reason ?? null;
991
- // See the zombie-card note in the legacy branch below: a terminal
992
- // from a real post attempt must clear the overridable expiry stamp.
993
- unset.push("discard_reason");
994
- }
995
- if (typeof c.post_attempts === "number")
996
- set.post_attempts = c.post_attempts;
997
- return { candidate_id: c.candidate_id, set, unset };
998
- });
999
- if (await patchReviewStore(patches))
1000
- return;
1001
- console.error("[post] store_patch.py failed; falling back to unlocked stamp merge");
1002
- }
1003
- }
1004
- catch {
1005
- /* fall through to the legacy write */
1006
- }
1007
929
  try {
1008
930
  const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
1009
931
  const fresh = mergeable ? readPlan(batchId) : null;
1010
932
  if (fresh && Array.isArray(fresh.candidates)) {
1011
- // candidate_id is NOT unique in the review-queue store: sandbox reruns
1012
- // and re-merged drafts append sibling rows with the same id. Stamping
1013
- // only one sibling (the old Map single-slot) left the others matching
1014
- // the drain's approved && !posted && !terminal filter, so the backlog
1015
- // re-drained the same candidate every heartbeat forever (2026-07-17
1016
- // incident: 23-card loop at 60s cadence). Stamp EVERY row with the id.
1017
933
  const freshById = new Map();
1018
934
  fresh.candidates.forEach((c) => {
1019
- if (c.candidate_id !== undefined && c.candidate_id !== null) {
1020
- const key = String(c.candidate_id);
1021
- const list = freshById.get(key);
1022
- if (list)
1023
- list.push(c);
1024
- else
1025
- freshById.set(key, [c]);
1026
- }
935
+ if (c.candidate_id !== undefined && c.candidate_id !== null)
936
+ freshById.set(String(c.candidate_id), c);
1027
937
  });
1028
938
  for (const c of stamped) {
1029
- for (const f of freshById.get(String(c.candidate_id)) ?? []) {
1030
- if (c.posted === true) {
1031
- f.posted = true;
1032
- f.terminal = false;
1033
- if (c.our_url)
1034
- f.our_url = c.our_url;
1035
- // A post outcome closes the card's history: the pre-approval
1036
- // freshness stamp must not survive it. Same for a stale
1037
- // post_failed from a peer call's posted=0 misattribution (see
1038
- // the locked-patch branch above).
1039
- delete f.discard_reason;
1040
- delete f.post_failed;
1041
- delete f.post_error;
1042
- }
1043
- else if (c.terminal === true && f.posted !== true) {
1044
- f.terminal = true;
1045
- f.terminal_reason = c.terminal_reason;
1046
- // CRITICAL (2026-07-17 Nhat zombie-card incident, 438 retries over
1047
- // 5 days): a stale discard_reason="backend_status_expired" left on
1048
- // the fresh copy makes expiredStampOverridable() treat THIS
1049
- // post-outcome terminal as overridable, so every later drain
1050
- // resurrects the card, re-posts, dedup-skips, and re-stamps —
1051
- // forever. A terminal that came from an actual post attempt is
1052
- // final; delete the expiry stamp so the override can never fire
1053
- // on it again. (The drain's own `delete c.discard_reason` happens
1054
- // on an in-memory copy that is never the write source; this line
1055
- // is the one that persists.)
1056
- delete f.discard_reason;
1057
- }
1058
- // Carry the retry counter so the give-up bound survives across
1059
- // drains (each drain reads the store fresh).
1060
- if (typeof c.post_attempts === "number" && c.post_attempts > (f.post_attempts || 0))
1061
- f.post_attempts = c.post_attempts;
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;
1062
951
  }
1063
952
  }
1064
953
  writePlan(batchId, fresh);
@@ -1154,12 +1043,7 @@ async function postApproved(batchId, plan) {
1154
1043
  // and the stamp is cleared so every downstream terminal check agrees it's live.
1155
1044
  const approved = (plan.candidates || []).filter((c) => c.approved === true &&
1156
1045
  c.posted !== true &&
1157
- (c.terminal !== true || expiredStampOverridable(c)) &&
1158
- // Prompt-sandbox replays can never post (twitter_post_plan.py post_one()
1159
- // hard-refuses them), so draining one is pure churn: it burns a browser
1160
- // lock turn and, if its terminal stamp later loses a store-write race,
1161
- // loops forever. Exclude them here regardless of stamp state.
1162
- !isSandboxCandidate(c));
1046
+ (c.terminal !== true || expiredStampOverridable(c)));
1163
1047
  for (const c of approved) {
1164
1048
  if (c.terminal === true) {
1165
1049
  c.terminal = false;
@@ -1357,7 +1241,7 @@ async function postApproved(batchId, plan) {
1357
1241
  };
1358
1242
  }
1359
1243
  if (approvedReddit.length)
1360
- await mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1244
+ mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1361
1245
  return {
1362
1246
  attempted: approvedReddit.length,
1363
1247
  posted: redditPosted,
@@ -1370,8 +1254,9 @@ async function postApproved(batchId, plan) {
1370
1254
  // If onboarding never persisted it, self-heal from the live session; if even that
1371
1255
  // can't determine it, refuse here with a clear reason rather than launching a
1372
1256
  // poster that silently burns the whole batch.
1373
- const postingHandle = await resolvePostingHandle();
1374
- if (!postingHandle) {
1257
+ if (!readConfiguredTwitterHandle())
1258
+ await ensurePostingHandle();
1259
+ if (!readConfiguredTwitterHandle()) {
1375
1260
  postingActive = false;
1376
1261
  stopPostingFlagHeartbeat();
1377
1262
  return {
@@ -1379,9 +1264,9 @@ async function postApproved(batchId, plan) {
1379
1264
  exit_code: 0,
1380
1265
  posted: 0,
1381
1266
  summary: "no_account_configured",
1382
- error: "X is connected but no posting @handle could be resolved (env, config, or the " +
1383
- "connect-time cookie mirror), so every reply would be refused (no_account_configured). " +
1384
- "Re-run project_config action:'connect_x' to re-capture the handle.",
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.",
1385
1270
  };
1386
1271
  }
1387
1272
  // Mark posting active so the draft-cycle scan DEFERS launching any scan for the
@@ -1597,21 +1482,6 @@ async function postApproved(batchId, plan) {
1597
1482
  // here (found 2026-07-16) silently and permanently discarded 7 real
1598
1483
  // approved drafts on nothing worse than lock contention — Reddit's
1599
1484
  // equivalent transient failures self-healed on the very next drain.
1600
- //
1601
- // BOUNDED (2026-07-17): sticky is right, sticky-forever is not — an
1602
- // unbounded retry is a zombie generator (one card retried 438 times
1603
- // over 5 days on the Nhat install). Count the transient failures and
1604
- // give up loudly after MAX_POST_ATTEMPTS; the terminal_reason keeps
1605
- // the last failure visible so the give-up is diagnosable, and the
1606
- // on-disk post-events trail records it for forensics.
1607
- const attempts = (typeof c.post_attempts === "number" ? c.post_attempts : 0) + 1;
1608
- c.post_attempts = attempts;
1609
- if (attempts >= MAX_POST_ATTEMPTS) {
1610
- c.terminal = true;
1611
- c.terminal_reason = `gave_up_after_${attempts}_failed_attempts:${r.reason || "failed"}`;
1612
- console.error(`[post] giving up on candidate ${r.candidate_id} after ${attempts} failed attempts (last: ${r.reason || "failed"})`);
1613
- logPostEvent(`retry_budget_exhausted candidate=${r.candidate_id} attempts=${attempts} last=${r.reason || "failed"}`);
1614
- }
1615
1485
  touchedPlan = true;
1616
1486
  }
1617
1487
  });
@@ -1626,7 +1496,7 @@ async function postApproved(batchId, plan) {
1626
1496
  // Reddit stamps (set in the reddit drain above) merge alongside the twitter
1627
1497
  // ones: `approved` here spans both platforms.
1628
1498
  if (touchedPlan || redditPosted || redditFailed) {
1629
- await mergeApprovedStampsIntoStore(batchId, plan, approved);
1499
+ mergeApprovedStampsIntoStore(batchId, plan, approved);
1630
1500
  }
1631
1501
  // Post failures are HANDLED in the pipeline (it returns a count, never throws),
1632
1502
  // so they never reach Sentry on their own. Capture an explicit event whenever
@@ -2871,16 +2741,6 @@ tool("post_drafts", {
2871
2741
  const total = candidates.length;
2872
2742
  const warnings = [];
2873
2743
  const inRange = (n) => n >= 1 && n <= total;
2874
- // Review-queue store: snapshot every row now so the write below can be a
2875
- // field-level DIFF applied under the store lock (store_patch.py) instead
2876
- // of an unlocked whole-file replace. This function holds its in-memory
2877
- // plan across user think-time; a whole-file write here erased any menubar
2878
- // decision or merge that landed in between (the 2026-07-17 truth-loss
2879
- // family). Non-store batches keep the plain write: single writer.
2880
- const isStoreBatch = batch_id === REVIEW_QUEUE_ID;
2881
- const rowsBefore = isStoreBatch
2882
- ? candidates.map((c) => JSON.stringify(c))
2883
- : [];
2884
2744
  // ---- Rejections: durable + final --------------------------------------
2885
2745
  // A rejected draft is marked terminal so it NEVER re-appears for review and is
2886
2746
  // never posted. A reject overrides any earlier approve on the same card.
@@ -3017,40 +2877,7 @@ tool("post_drafts", {
3017
2877
  if (c)
3018
2878
  c.approved = true;
3019
2879
  });
3020
- // Persist the decision mutations. Store batch: diff each row against its
3021
- // snapshot and apply only the changed fields under the store lock, so a
3022
- // concurrent menubar decision or merge is never erased. Anything else
3023
- // (per-batch /tmp plans): plain write, single writer.
3024
- let storeWriteDone = false;
3025
- if (isStoreBatch) {
3026
- const patches = [];
3027
- candidates.forEach((c, i) => {
3028
- const beforeRaw = rowsBefore[i];
3029
- const afterRaw = JSON.stringify(c);
3030
- if (beforeRaw === afterRaw)
3031
- return;
3032
- const before = JSON.parse(beforeRaw ?? "{}");
3033
- const after = JSON.parse(afterRaw);
3034
- const set = {};
3035
- const unset = [];
3036
- for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
3037
- if (!(k in after) || after[k] === undefined) {
3038
- if (k in before)
3039
- unset.push(k);
3040
- }
3041
- else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
3042
- set[k] = after[k];
3043
- }
3044
- }
3045
- if (Object.keys(set).length || unset.length)
3046
- patches.push({ candidate_id: c.candidate_id ?? null, n: i + 1, set, unset });
3047
- });
3048
- storeWriteDone = await patchReviewStore(patches);
3049
- if (!storeWriteDone)
3050
- console.error("[post_drafts] store_patch.py failed; falling back to unlocked plan write");
3051
- }
3052
- if (!storeWriteDone)
3053
- writePlan(batch_id, plan);
2880
+ writePlan(batch_id, plan);
3054
2881
  if (approve.size === 0) {
3055
2882
  return jsonContent({
3056
2883
  batch_id,
@@ -3641,8 +3468,8 @@ async function autopilotLoaded() {
3641
3468
  // fires every minute, claims ONE job, runs the pipeline's own prompt as its
3642
3469
  // Claude turn, writes the result back, and stops.
3643
3470
  // ===========================================================================
3644
- 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.
3645
- // v10 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
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
3646
3473
  // sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
3647
3474
  // within one continuous worker session) instead of drafting inline. Validated
3648
3475
  // via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
@@ -3651,26 +3478,19 @@ const QUEUE_WORKER_PROMPT_VERSION = 9; // v9 (2026-07-17): poll window widened 2
3651
3478
  // notification) or the host kills the whole parent+child chain in 1-3 min.
3652
3479
  // Never live-fire tested against a real production job. Full design, what's
3653
3480
  // validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
3654
- // Bump this constant to 10 only once that plan is actually implemented.
3481
+ // Bump this constant to 9 only once that plan is actually implemented.
3655
3482
  const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
3656
3483
  // How long ONE `next --wait-seconds` call polls before giving up and exiting.
3657
- // 900s (15 min, per Matthew 2026-07-17, up from 240s): sits AT the single-
3658
- // Bash-call survival ceiling verified live on 2026-07-06 (the host's ~90s
3659
- // inactivity kill fires only on model silence, and one in-flight tool call
3660
- // survived a full 900s probe). This covers the ~8min average real job
3661
- // inter-arrival gap outright, so most jobs are claimed by an already-polling
3662
- // session instead of paying a fresh spin-up, and MCP boot side effects
3663
- // (backfill checks, backlog drains) run 1/15min instead of 1/5min. Watch
3664
- // point: 900s has zero margin below the verified ceiling — if workers start
3665
- // dying mid-poll with no reaper kill recorded, the host clipped the call;
3666
- // back off to 600s. The cron's `* * * * *` cadence remains the outer safety
3667
- // net for whatever the poll window doesn't catch.
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.
3668
3489
  // COUPLING: scripts/reap_stale_claude_sessions.py's S4L_REAPER_CLAIM_GRACE_SEC
3669
3490
  // default MUST stay >= this value + margin — a claimless session inside this
3670
3491
  // poll window is legitimately still working, not a husk, and a too-tight
3671
3492
  // claim_grace would SIGTERM it mid-poll before it ever gets to claim.
3672
- // (Bumped to 1020s alongside this change.)
3673
- const QUEUE_WORKER_POLL_SECONDS = 900;
3493
+ const QUEUE_WORKER_POLL_SECONDS = 240;
3674
3494
  // One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
3675
3495
  const QUEUE_WORKERS = [
3676
3496
  { taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
@@ -5864,18 +5684,30 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
5864
5684
  },
5865
5685
  ],
5866
5686
  }));
5867
- // REMOVED (2026-07-17): drainApprovedBacklog. It ran 30s after EVERY MCP boot,
5868
- // which was sane when boots meant "user launched Claude Desktop" — but each
5869
- // queue-worker session boots its own MCP server, so the drain had silently
5870
- // become a ~5-minute cron running across up to 4 concurrent MCP instances.
5871
- // Combined with universal posting preemption, every drain wakeup SIGKILLed
5872
- // whatever held the twitter-browser lock (profile scans included), and any
5873
- // stamp bug turned into an infinite retry loop (438 retries over 5 days on
5874
- // one Nhat card). Backlog recovery is now owned by ONE long-lived process:
5875
- // the menubar's _resume_approved_queue, which runs on loopback-reachable and
5876
- // periodically thereafter (mcp/menubar/s4l_menubar.py). Do NOT re-add a
5877
- // boot-time drain here; if the menubar is dead, ensureMenubar() below revives
5878
- // it and its resume covers the backlog.
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
+ }
5879
5711
  async function main() {
5880
5712
  initSentry();
5881
5713
  // Detect a self-update (old_version -> new_version) as the very first thing
@@ -6051,9 +5883,13 @@ async function main() {
6051
5883
  void startLocalPanel()
6052
5884
  .then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
6053
5885
  .catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
6054
- // NOTE (2026-07-17): the boot-time drainApprovedBacklog() call that lived
6055
- // here is gone — backlog recovery is owned by the menubar's periodic
6056
- // _resume_approved_queue (single drainer; see the removal note above).
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
+ }
6057
5893
  // Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
6058
5894
  // and cheap when already present, so existing installs pick it up on the next
6059
5895
  // Claude restart without re-provisioning. Best-effort: never blocks boot.
@@ -301,8 +301,7 @@ 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.json removed 2026-07-17: the ledger is gone; the review
305
- // store is the only local decision record.
304
+ ["approved_queue", "approved-queue.json", 256_000],
306
305
  ];
307
306
  for (const [key, file, cap] of stateFiles) {
308
307
  const val = readJsonCapped(path.join(stateDir, file), cap);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.19",
3
- "installedAt": "2026-07-17T22:21:24.853Z"
2
+ "version": "1.7.6-rc.2",
3
+ "installedAt": "2026-07-17T00:56:09.664Z"
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.6-rc.19",
5
+ "version": "1.7.6-rc.2",
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": {
@@ -46,13 +46,7 @@ import s4l_log_relay
46
46
 
47
47
  # A managed harness Chrome is one launched on a profile under this marker
48
48
  # (browser-harness = twitter 9555, browser-harness-linkedin = 9556, ...).
49
- # Any managed harness profile under browser-profiles/ counts — matching the
50
- # literal "browser-harness" prefix silently EXCLUDED the reddit harness
51
- # (profile "reddit-harness"), so reddit-Chrome activations went unlogged and
52
- # unattributable for days (2026-07-17). The remote-debugging-port requirement
53
- # in the check below keeps the user's own Chrome / MCP-agent profiles (which
54
- # use the debugging PIPE, not a port) out of scope.
55
- _PROFILE_MARKER = os.path.join(".claude", "browser-profiles", "")
49
+ _PROFILE_MARKER = os.path.join(".claude", "browser-profiles", "browser-harness")
56
50
 
57
51
  # Within this window, repeats of the same (cause, pid) are counted, not emitted.
58
52
  # A screencast-reconnect storm raises Chrome every few seconds; one line per