@m13v/s4l 1.7.6-rc.2 → 1.7.6-rc.20
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.
- package/mcp/dist/index.js +256 -92
- package/mcp/dist/telemetry.js +2 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +144 -18
- package/mcp/menubar/s4l_card_canvas.py +135 -14
- package/mcp/menubar/s4l_menubar.py +216 -73
- package/mcp/menubar/s4l_state.py +45 -130
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +35 -1
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/draft_prompt_core.py +100 -17
- package/scripts/engagement_styles.py +135 -98
- package/scripts/merge_review_queue.py +192 -129
- package/scripts/platform_strike_events.py +54 -5
- package/scripts/post_reddit.py +10 -0
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/reap_stale_claude_sessions.py +7 -6
- package/scripts/reddit_ban_check.py +220 -0
- package/scripts/reddit_browser.py +24 -0
- package/scripts/release-mcpb.sh +38 -7
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/store_patch.py +111 -0
- package/scripts/strike_alert.py +161 -12
- package/scripts/test_no_silent_fallbacks.py +11 -0
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/skill/archive-old-logs.sh +24 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-twitter-cycle.sh +146 -66
package/mcp/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import { screencast, bringBrowserToFront } from "./screencast.js";
|
|
|
19
19
|
import os from "node:os";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import fs from "node:fs";
|
|
22
|
-
import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
|
|
22
|
+
import { repoDir, runPython, run, readPlan, writePlan, planPath, TMP_DIR, } from "./repo.js";
|
|
23
23
|
import { applySetup, resolveProject, hasReadyProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, ensureConfigInStateDir, normalizeStringList, recordRedditAccount, } from "./setup.js";
|
|
24
24
|
import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
|
|
25
25
|
import { redditStatus, redditConnect, redditDetectSources, summarizeRedditAuth, } from "./redditAuth.js";
|
|
@@ -846,40 +846,28 @@ function parsePostCandidateResults(stdout) {
|
|
|
846
846
|
}
|
|
847
847
|
return [...byId.values()];
|
|
848
848
|
}
|
|
849
|
-
// Resolve the
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
|
|
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() {
|
|
855
858
|
const env = (process.env.AUTOPOSTER_TWITTER_HANDLE || "").trim().replace(/^@/, "");
|
|
856
859
|
if (env)
|
|
857
860
|
return env;
|
|
858
861
|
try {
|
|
859
|
-
const
|
|
860
|
-
|
|
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,
|
|
862
|
+
const r = await runPython("scripts/account_resolver.py", ["twitter"], {
|
|
863
|
+
timeoutMs: 30_000,
|
|
878
864
|
env: ({ S4L_REPO_DIR: repoDir(), PATH: pipelinePath() }),
|
|
879
865
|
});
|
|
866
|
+
const h = (r.stdout || "").trim().replace(/^@/, "");
|
|
867
|
+
return h || null;
|
|
880
868
|
}
|
|
881
869
|
catch {
|
|
882
|
-
|
|
870
|
+
return null;
|
|
883
871
|
}
|
|
884
872
|
}
|
|
885
873
|
async function ensureTwitterBrowserForPost() {
|
|
@@ -909,12 +897,57 @@ async function ensureTwitterBrowserForPost() {
|
|
|
909
897
|
// thread still exists. Without this override, a card approved while (or just
|
|
910
898
|
// before) the sync stamped it is refused as already-decided and the approval
|
|
911
899
|
// 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;
|
|
912
905
|
function expiredStampOverridable(c) {
|
|
913
906
|
return (c.terminal === true &&
|
|
914
907
|
c.posted !== true &&
|
|
915
908
|
c.discard_reason === "backend_status_expired");
|
|
916
909
|
}
|
|
917
|
-
|
|
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) {
|
|
918
951
|
// Merge posted/terminal stamps into a FRESH read of the store instead of
|
|
919
952
|
// rewriting the whole plan from the copy taken minutes ago. The old
|
|
920
953
|
// whole-file write was last-writer-wins: while a batch posted, the menubar
|
|
@@ -926,28 +959,106 @@ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
|
926
959
|
// overwrites a fresh `posted=true`. Fallback: candidates without a
|
|
927
960
|
// candidate_id can't be matched into the fresh copy, so keep the legacy
|
|
928
961
|
// 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
|
+
}
|
|
929
1007
|
try {
|
|
930
1008
|
const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
931
1009
|
const fresh = mergeable ? readPlan(batchId) : null;
|
|
932
1010
|
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.
|
|
933
1017
|
const freshById = new Map();
|
|
934
1018
|
fresh.candidates.forEach((c) => {
|
|
935
|
-
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
936
|
-
|
|
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
|
+
}
|
|
937
1027
|
});
|
|
938
1028
|
for (const c of stamped) {
|
|
939
|
-
const f
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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;
|
|
951
1062
|
}
|
|
952
1063
|
}
|
|
953
1064
|
writePlan(batchId, fresh);
|
|
@@ -1043,7 +1154,12 @@ async function postApproved(batchId, plan) {
|
|
|
1043
1154
|
// and the stamp is cleared so every downstream terminal check agrees it's live.
|
|
1044
1155
|
const approved = (plan.candidates || []).filter((c) => c.approved === true &&
|
|
1045
1156
|
c.posted !== true &&
|
|
1046
|
-
(c.terminal !== true || expiredStampOverridable(c))
|
|
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));
|
|
1047
1163
|
for (const c of approved) {
|
|
1048
1164
|
if (c.terminal === true) {
|
|
1049
1165
|
c.terminal = false;
|
|
@@ -1241,7 +1357,7 @@ async function postApproved(batchId, plan) {
|
|
|
1241
1357
|
};
|
|
1242
1358
|
}
|
|
1243
1359
|
if (approvedReddit.length)
|
|
1244
|
-
mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1360
|
+
await mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1245
1361
|
return {
|
|
1246
1362
|
attempted: approvedReddit.length,
|
|
1247
1363
|
posted: redditPosted,
|
|
@@ -1254,9 +1370,8 @@ async function postApproved(batchId, plan) {
|
|
|
1254
1370
|
// If onboarding never persisted it, self-heal from the live session; if even that
|
|
1255
1371
|
// can't determine it, refuse here with a clear reason rather than launching a
|
|
1256
1372
|
// poster that silently burns the whole batch.
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
if (!readConfiguredTwitterHandle()) {
|
|
1373
|
+
const postingHandle = await resolvePostingHandle();
|
|
1374
|
+
if (!postingHandle) {
|
|
1260
1375
|
postingActive = false;
|
|
1261
1376
|
stopPostingFlagHeartbeat();
|
|
1262
1377
|
return {
|
|
@@ -1264,9 +1379,9 @@ async function postApproved(batchId, plan) {
|
|
|
1264
1379
|
exit_code: 0,
|
|
1265
1380
|
posted: 0,
|
|
1266
1381
|
summary: "no_account_configured",
|
|
1267
|
-
error: "X is connected but no posting @handle
|
|
1268
|
-
"
|
|
1269
|
-
"
|
|
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.",
|
|
1270
1385
|
};
|
|
1271
1386
|
}
|
|
1272
1387
|
// Mark posting active so the draft-cycle scan DEFERS launching any scan for the
|
|
@@ -1482,6 +1597,21 @@ async function postApproved(batchId, plan) {
|
|
|
1482
1597
|
// here (found 2026-07-16) silently and permanently discarded 7 real
|
|
1483
1598
|
// approved drafts on nothing worse than lock contention — Reddit's
|
|
1484
1599
|
// 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
|
+
}
|
|
1485
1615
|
touchedPlan = true;
|
|
1486
1616
|
}
|
|
1487
1617
|
});
|
|
@@ -1496,7 +1626,7 @@ async function postApproved(batchId, plan) {
|
|
|
1496
1626
|
// Reddit stamps (set in the reddit drain above) merge alongside the twitter
|
|
1497
1627
|
// ones: `approved` here spans both platforms.
|
|
1498
1628
|
if (touchedPlan || redditPosted || redditFailed) {
|
|
1499
|
-
mergeApprovedStampsIntoStore(batchId, plan, approved);
|
|
1629
|
+
await mergeApprovedStampsIntoStore(batchId, plan, approved);
|
|
1500
1630
|
}
|
|
1501
1631
|
// Post failures are HANDLED in the pipeline (it returns a count, never throws),
|
|
1502
1632
|
// so they never reach Sentry on their own. Capture an explicit event whenever
|
|
@@ -2741,6 +2871,16 @@ tool("post_drafts", {
|
|
|
2741
2871
|
const total = candidates.length;
|
|
2742
2872
|
const warnings = [];
|
|
2743
2873
|
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
|
+
: [];
|
|
2744
2884
|
// ---- Rejections: durable + final --------------------------------------
|
|
2745
2885
|
// A rejected draft is marked terminal so it NEVER re-appears for review and is
|
|
2746
2886
|
// never posted. A reject overrides any earlier approve on the same card.
|
|
@@ -2877,7 +3017,40 @@ tool("post_drafts", {
|
|
|
2877
3017
|
if (c)
|
|
2878
3018
|
c.approved = true;
|
|
2879
3019
|
});
|
|
2880
|
-
|
|
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);
|
|
2881
3054
|
if (approve.size === 0) {
|
|
2882
3055
|
return jsonContent({
|
|
2883
3056
|
batch_id,
|
|
@@ -3468,8 +3641,8 @@ async function autopilotLoaded() {
|
|
|
3468
3641
|
// fires every minute, claims ONE job, runs the pipeline's own prompt as its
|
|
3469
3642
|
// Claude turn, writes the result back, and stops.
|
|
3470
3643
|
// ===========================================================================
|
|
3471
|
-
const QUEUE_WORKER_PROMPT_VERSION =
|
|
3472
|
-
//
|
|
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
|
|
3473
3646
|
// sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
|
|
3474
3647
|
// within one continuous worker session) instead of drafting inline. Validated
|
|
3475
3648
|
// via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
|
|
@@ -3478,19 +3651,26 @@ const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_jo
|
|
|
3478
3651
|
// notification) or the host kills the whole parent+child chain in 1-3 min.
|
|
3479
3652
|
// Never live-fire tested against a real production job. Full design, what's
|
|
3480
3653
|
// validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
|
|
3481
|
-
// Bump this constant to
|
|
3654
|
+
// Bump this constant to 10 only once that plan is actually implemented.
|
|
3482
3655
|
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
3483
3656
|
// How long ONE `next --wait-seconds` call polls before giving up and exiting.
|
|
3484
|
-
//
|
|
3485
|
-
// live on 2026-07-06
|
|
3486
|
-
//
|
|
3487
|
-
//
|
|
3488
|
-
//
|
|
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.
|
|
3489
3668
|
// COUPLING: scripts/reap_stale_claude_sessions.py's S4L_REAPER_CLAIM_GRACE_SEC
|
|
3490
3669
|
// default MUST stay >= this value + margin — a claimless session inside this
|
|
3491
3670
|
// poll window is legitimately still working, not a husk, and a too-tight
|
|
3492
3671
|
// claim_grace would SIGTERM it mid-poll before it ever gets to claim.
|
|
3493
|
-
|
|
3672
|
+
// (Bumped to 1020s alongside this change.)
|
|
3673
|
+
const QUEUE_WORKER_POLL_SECONDS = 900;
|
|
3494
3674
|
// One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
|
|
3495
3675
|
const QUEUE_WORKERS = [
|
|
3496
3676
|
{ taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
|
|
@@ -5684,30 +5864,18 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
|
|
|
5684
5864
|
},
|
|
5685
5865
|
],
|
|
5686
5866
|
}));
|
|
5687
|
-
//
|
|
5688
|
-
//
|
|
5689
|
-
//
|
|
5690
|
-
//
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
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
|
-
}
|
|
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.
|
|
5711
5879
|
async function main() {
|
|
5712
5880
|
initSentry();
|
|
5713
5881
|
// Detect a self-update (old_version -> new_version) as the very first thing
|
|
@@ -5883,13 +6051,9 @@ async function main() {
|
|
|
5883
6051
|
void startLocalPanel()
|
|
5884
6052
|
.then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
|
|
5885
6053
|
.catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
|
|
5886
|
-
//
|
|
5887
|
-
//
|
|
5888
|
-
|
|
5889
|
-
const t = setTimeout(() => void drainApprovedBacklog(), 30_000);
|
|
5890
|
-
if (typeof t.unref === "function")
|
|
5891
|
-
t.unref();
|
|
5892
|
-
}
|
|
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).
|
|
5893
6057
|
// Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
|
|
5894
6058
|
// and cheap when already present, so existing installs pick it up on the next
|
|
5895
6059
|
// Claude restart without re-provisioning. Best-effort: never blocks boot.
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/mcp/dist/version.json
CHANGED
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.
|
|
5
|
+
"version": "1.7.6-rc.20",
|
|
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,7 +46,13 @@ 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
|
-
|
|
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", "")
|
|
50
56
|
|
|
51
57
|
# Within this window, repeats of the same (cause, pid) are counted, not emitted.
|
|
52
58
|
# A screencast-reconnect storm raises Chrome every few seconds; one line per
|