@m13v/s4l 1.7.6-rc.28 → 1.7.6-rc.3
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 +96 -288
- package/mcp/dist/telemetry.js +1 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +1 -7
- package/mcp/menubar/s4l_card.py +4 -63
- package/mcp/menubar/s4l_card_canvas.py +11 -62
- package/mcp/menubar/s4l_menubar.py +62 -145
- package/mcp/menubar/s4l_state.py +130 -50
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +14 -58
- package/scripts/amplitude_24h_signups.py +4 -7
- package/scripts/amplitude_signups.py +4 -7
- package/scripts/autopilot_stall_watch.py +1 -8
- package/scripts/cdp_ready_check.py +50 -81
- package/scripts/dm_conversation.py +12 -15
- package/scripts/draft_prompt_core.py +17 -104
- package/scripts/engage_github.py +5 -11
- package/scripts/engage_reddit.py +5 -12
- package/scripts/engagement_signal_feedback.py +4 -1
- package/scripts/engagement_styles.py +98 -135
- package/scripts/feedback_digest.py +11 -54
- package/scripts/find_threads.py +7 -9
- package/scripts/github_tools.py +4 -7
- package/scripts/learned_preferences.py +78 -112
- package/scripts/li_discovery.py +1 -4
- package/scripts/merge_review_queue.py +129 -197
- package/scripts/pick_project.py +4 -7
- package/scripts/pick_thread_target.py +4 -7
- package/scripts/pick_twitter_thread_target.py +4 -7
- package/scripts/platform_strike_events.py +5 -54
- package/scripts/post_github.py +5 -17
- package/scripts/post_reddit.py +7 -27
- package/scripts/project_stats.py +4 -7
- package/scripts/reap_stale_claude_sessions.py +6 -7
- package/scripts/reddit_browser.py +19 -56
- package/scripts/reddit_chat_sync.py +11 -10
- package/scripts/reddit_tools.py +2 -4
- package/scripts/release-mcpb.sh +7 -47
- package/scripts/run_moltbook_cycle.py +5 -9
- package/scripts/scan_dm_candidates.py +9 -15
- package/scripts/scan_github_replies.py +7 -9
- package/scripts/scan_moltbook_replies.py +6 -7
- package/scripts/scan_reddit_replies.py +7 -11
- package/scripts/scan_twitter_mentions_browser.py +6 -7
- package/scripts/scan_twitter_thread_followups.py +6 -7
- package/scripts/score_twitter_candidates.py +2 -4
- package/scripts/setup_twitter_auth.py +96 -20
- package/scripts/stats.py +10 -12
- package/scripts/strike_alert.py +12 -161
- package/scripts/test_no_silent_fallbacks.py +0 -84
- package/scripts/top_performers.py +0 -24
- package/scripts/twitter_browser.py +52 -22
- package/skill/archive-old-logs.sh +0 -24
- package/skill/lib/reddit-backend.sh +0 -1
- package/skill/run-twitter-cycle.sh +66 -146
- package/scripts/browser_lifecycle.py +0 -90
- package/scripts/preview_reddit_card.py +0 -38
- package/scripts/reddit_ban_check.py +0 -220
- package/scripts/store_patch.py +0 -111
- package/scripts/test_cross_language_parity.py +0 -185
- 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,
|
|
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";
|
|
@@ -760,9 +760,7 @@ function renderDraftsTable(plan) {
|
|
|
760
760
|
// Number by FULL-array index (matches post_drafts + the menu bar), then drop
|
|
761
761
|
// already-finished entries so the cards only show what's still pending.
|
|
762
762
|
.map((c, i) => ({ c, n: i + 1 }))
|
|
763
|
-
|
|
764
|
-
// as the menubar cards and the review-request.json count).
|
|
765
|
-
.filter((e) => candidateState(e.c) === "awaiting_review")
|
|
763
|
+
.filter((e) => e.c.posted !== true && e.c.terminal !== true && e.c.approved !== true)
|
|
766
764
|
// The queue is append-only; newest drafts have the highest stable index.
|
|
767
765
|
// Show those first so review starts with likely-live tweets instead of stale
|
|
768
766
|
// low-number drafts that have been sitting around for hours.
|
|
@@ -848,28 +846,40 @@ function parsePostCandidateResults(stdout) {
|
|
|
848
846
|
}
|
|
849
847
|
return [...byId.values()];
|
|
850
848
|
}
|
|
851
|
-
// Resolve the posting
|
|
852
|
-
// env
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
|
|
857
|
-
// refuses loudly on null so a missing handle fails ONCE, not as N silent per-reply
|
|
858
|
-
// no_account_configured skips (and never as a hardcoded impersonation fallback).
|
|
859
|
-
async function resolvePostingHandle() {
|
|
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() {
|
|
860
855
|
const env = (process.env.AUTOPOSTER_TWITTER_HANDLE || "").trim().replace(/^@/, "");
|
|
861
856
|
if (env)
|
|
862
857
|
return env;
|
|
863
858
|
try {
|
|
864
|
-
const
|
|
865
|
-
|
|
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,
|
|
866
878
|
env: ({ S4L_REPO_DIR: repoDir(), PATH: pipelinePath() }),
|
|
867
879
|
});
|
|
868
|
-
const h = (r.stdout || "").trim().replace(/^@/, "");
|
|
869
|
-
return h || null;
|
|
870
880
|
}
|
|
871
881
|
catch {
|
|
872
|
-
|
|
882
|
+
/* best effort */
|
|
873
883
|
}
|
|
874
884
|
}
|
|
875
885
|
async function ensureTwitterBrowserForPost() {
|
|
@@ -899,68 +909,12 @@ async function ensureTwitterBrowserForPost() {
|
|
|
899
909
|
// thread still exists. Without this override, a card approved while (or just
|
|
900
910
|
// before) the sync stamped it is refused as already-decided and the approval
|
|
901
911
|
// silently no-ops (2 of 3 approvals lost on 2026-07-10).
|
|
902
|
-
// Give-up bound for approved cards whose post attempts keep failing
|
|
903
|
-
// transiently (browser lock contention, timeouts). 5 attempts spans several
|
|
904
|
-
// drain cycles — plenty for genuine transients to clear — while guaranteeing
|
|
905
|
-
// no card can retry forever (2026-07-17 zombie-card incident).
|
|
906
|
-
const MAX_POST_ATTEMPTS = 5;
|
|
907
912
|
function expiredStampOverridable(c) {
|
|
908
913
|
return (c.terminal === true &&
|
|
909
914
|
c.posted !== true &&
|
|
910
915
|
c.discard_reason === "backend_status_expired");
|
|
911
916
|
}
|
|
912
|
-
|
|
913
|
-
// carry experiments.sandbox=true; older sandbox rows predate that stamp but all
|
|
914
|
-
// use the synthetic >=900,000,000 id range twitter_prompt_sandbox.py assigns.
|
|
915
|
-
function isSandboxCandidate(c) {
|
|
916
|
-
const exps = c.experiments;
|
|
917
|
-
if (exps && typeof exps === "object" && exps.sandbox)
|
|
918
|
-
return true;
|
|
919
|
-
const id = Number(c.candidate_id);
|
|
920
|
-
return Number.isFinite(id) && id >= 900_000_000;
|
|
921
|
-
}
|
|
922
|
-
function candidateState(c) {
|
|
923
|
-
if (c.posted === true)
|
|
924
|
-
return "posted";
|
|
925
|
-
if (c.terminal === true)
|
|
926
|
-
return "terminal";
|
|
927
|
-
if (c.post_failed)
|
|
928
|
-
return "post_failed";
|
|
929
|
-
if (c.approved === true)
|
|
930
|
-
return "approved";
|
|
931
|
-
return "awaiting_review";
|
|
932
|
-
}
|
|
933
|
-
// Write field patches into the review-queue store UNDER ITS LOCK by shelling
|
|
934
|
-
// to scripts/store_patch.py, which takes the same fcntl.flock the menubar's
|
|
935
|
-
// _store_update and merge_review_queue.py hold around their read-modify-write.
|
|
936
|
-
// Node has no native flock, and this process writing the store directly was
|
|
937
|
-
// the last unlocked writer (the race that erased posted stamps on 2026-07-17).
|
|
938
|
-
// Returns false on any failure so callers can fall back to the legacy write.
|
|
939
|
-
async function patchReviewStore(patches) {
|
|
940
|
-
if (!patches.length)
|
|
941
|
-
return true;
|
|
942
|
-
const tmp = path.join(TMP_DIR, `s4l-store-patches-${process.pid}-${Date.now()}.json`);
|
|
943
|
-
try {
|
|
944
|
-
fs.writeFileSync(tmp, JSON.stringify({ patches }), "utf-8");
|
|
945
|
-
const res = await runPython("scripts/store_patch.py", [tmp], {
|
|
946
|
-
timeoutMs: 30_000,
|
|
947
|
-
env: { S4L_REPO_DIR: repoDir(), PATH: pipelinePath() },
|
|
948
|
-
});
|
|
949
|
-
return res.code === 0;
|
|
950
|
-
}
|
|
951
|
-
catch {
|
|
952
|
-
return false;
|
|
953
|
-
}
|
|
954
|
-
finally {
|
|
955
|
-
try {
|
|
956
|
-
fs.unlinkSync(tmp);
|
|
957
|
-
}
|
|
958
|
-
catch {
|
|
959
|
-
/* best effort */
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
917
|
+
function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
964
918
|
// Merge posted/terminal stamps into a FRESH read of the store instead of
|
|
965
919
|
// rewriting the whole plan from the copy taken minutes ago. The old
|
|
966
920
|
// whole-file write was last-writer-wins: while a batch posted, the menubar
|
|
@@ -972,106 +926,28 @@ async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
|
972
926
|
// overwrites a fresh `posted=true`. Fallback: candidates without a
|
|
973
927
|
// candidate_id can't be matched into the fresh copy, so keep the legacy
|
|
974
928
|
// whole-plan write for those older plans.
|
|
975
|
-
//
|
|
976
|
-
// Review-queue store: go through the LOCKED patch path (store_patch.py)
|
|
977
|
-
// first. The fresh-read merge below closes most of the race window but not
|
|
978
|
-
// all of it — a menubar decision landing between our readPlan and writePlan
|
|
979
|
-
// still gets erased. The locked path holds the store's flock for the whole
|
|
980
|
-
// read-mutate-replace, applies to every sibling row sharing a candidate_id,
|
|
981
|
-
// and enforces the same posted-sticky rules. Legacy path stays as the
|
|
982
|
-
// fallback and for per-batch /tmp plans (single writer, no lock needed).
|
|
983
|
-
try {
|
|
984
|
-
const mergeableForPatch = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
985
|
-
if (batchId === REVIEW_QUEUE_ID && mergeableForPatch) {
|
|
986
|
-
const patches = stamped.map((c) => {
|
|
987
|
-
const set = {};
|
|
988
|
-
const unset = [];
|
|
989
|
-
if (c.posted === true) {
|
|
990
|
-
set.posted = true;
|
|
991
|
-
set.terminal = false;
|
|
992
|
-
if (c.our_url)
|
|
993
|
-
set.our_url = c.our_url;
|
|
994
|
-
// Clear a stale failure stamp too: the menubar's per-card call can
|
|
995
|
-
// see posted=0 (the batch drain posted it under a different call)
|
|
996
|
-
// and stamp post_failed on a card that IS live (seen on 565462,
|
|
997
|
-
// 2026-07-17). posted is the settled truth; the residue just adds a
|
|
998
|
-
// false "didn't post" signal to dashboards/notifications.
|
|
999
|
-
unset.push("discard_reason", "post_failed", "post_error");
|
|
1000
|
-
}
|
|
1001
|
-
else if (c.terminal === true) {
|
|
1002
|
-
set.terminal = true;
|
|
1003
|
-
set.terminal_reason = c.terminal_reason ?? null;
|
|
1004
|
-
// See the zombie-card note in the legacy branch below: a terminal
|
|
1005
|
-
// from a real post attempt must clear the overridable expiry stamp.
|
|
1006
|
-
unset.push("discard_reason");
|
|
1007
|
-
}
|
|
1008
|
-
if (typeof c.post_attempts === "number")
|
|
1009
|
-
set.post_attempts = c.post_attempts;
|
|
1010
|
-
return { candidate_id: c.candidate_id, set, unset };
|
|
1011
|
-
});
|
|
1012
|
-
if (await patchReviewStore(patches))
|
|
1013
|
-
return;
|
|
1014
|
-
console.error("[post] store_patch.py failed; falling back to unlocked stamp merge");
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
catch {
|
|
1018
|
-
/* fall through to the legacy write */
|
|
1019
|
-
}
|
|
1020
929
|
try {
|
|
1021
930
|
const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
1022
931
|
const fresh = mergeable ? readPlan(batchId) : null;
|
|
1023
932
|
if (fresh && Array.isArray(fresh.candidates)) {
|
|
1024
|
-
// candidate_id is NOT unique in the review-queue store: sandbox reruns
|
|
1025
|
-
// and re-merged drafts append sibling rows with the same id. Stamping
|
|
1026
|
-
// only one sibling (the old Map single-slot) left the others matching
|
|
1027
|
-
// the drain's approved && !posted && !terminal filter, so the backlog
|
|
1028
|
-
// re-drained the same candidate every heartbeat forever (2026-07-17
|
|
1029
|
-
// incident: 23-card loop at 60s cadence). Stamp EVERY row with the id.
|
|
1030
933
|
const freshById = new Map();
|
|
1031
934
|
fresh.candidates.forEach((c) => {
|
|
1032
|
-
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
1033
|
-
|
|
1034
|
-
const list = freshById.get(key);
|
|
1035
|
-
if (list)
|
|
1036
|
-
list.push(c);
|
|
1037
|
-
else
|
|
1038
|
-
freshById.set(key, [c]);
|
|
1039
|
-
}
|
|
935
|
+
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
936
|
+
freshById.set(String(c.candidate_id), c);
|
|
1040
937
|
});
|
|
1041
938
|
for (const c of stamped) {
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
delete f.post_error;
|
|
1055
|
-
}
|
|
1056
|
-
else if (c.terminal === true && f.posted !== true) {
|
|
1057
|
-
f.terminal = true;
|
|
1058
|
-
f.terminal_reason = c.terminal_reason;
|
|
1059
|
-
// CRITICAL (2026-07-17 Nhat zombie-card incident, 438 retries over
|
|
1060
|
-
// 5 days): a stale discard_reason="backend_status_expired" left on
|
|
1061
|
-
// the fresh copy makes expiredStampOverridable() treat THIS
|
|
1062
|
-
// post-outcome terminal as overridable, so every later drain
|
|
1063
|
-
// resurrects the card, re-posts, dedup-skips, and re-stamps —
|
|
1064
|
-
// forever. A terminal that came from an actual post attempt is
|
|
1065
|
-
// final; delete the expiry stamp so the override can never fire
|
|
1066
|
-
// on it again. (The drain's own `delete c.discard_reason` happens
|
|
1067
|
-
// on an in-memory copy that is never the write source; this line
|
|
1068
|
-
// is the one that persists.)
|
|
1069
|
-
delete f.discard_reason;
|
|
1070
|
-
}
|
|
1071
|
-
// Carry the retry counter so the give-up bound survives across
|
|
1072
|
-
// drains (each drain reads the store fresh).
|
|
1073
|
-
if (typeof c.post_attempts === "number" && c.post_attempts > (f.post_attempts || 0))
|
|
1074
|
-
f.post_attempts = c.post_attempts;
|
|
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;
|
|
1075
951
|
}
|
|
1076
952
|
}
|
|
1077
953
|
writePlan(batchId, fresh);
|
|
@@ -1165,29 +1041,9 @@ async function postApproved(batchId, plan) {
|
|
|
1165
1041
|
// An approved card whose only blocker is an overridable backend-expiry stamp is
|
|
1166
1042
|
// included: approval outranks the freshness gate (see expiredStampOverridable),
|
|
1167
1043
|
// and the stamp is cleared so every downstream terminal check agrees it's live.
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
// - post_failed cards DO drain again (the transient-retry path, bounded by
|
|
1172
|
-
// MAX_POST_ATTEMPTS at result time). The menubar's restart-resume path
|
|
1173
|
-
// (store_pending_posts) skips them by design; this drain is where they
|
|
1174
|
-
// get their bounded retries.
|
|
1175
|
-
// - an approved card whose ONLY terminal stamp is the freshness gate's
|
|
1176
|
-
// backend_status_expired is resurrected: the human approval outranks the
|
|
1177
|
-
// expiry (2 of 3 approvals lost on 2026-07-10 without this).
|
|
1178
|
-
// - sandbox replays never drain (twitter_post_plan.py post_one()
|
|
1179
|
-
// hard-refuses them, so draining one is pure churn and can loop forever
|
|
1180
|
-
// if its terminal stamp loses a store-write race).
|
|
1181
|
-
const approved = (plan.candidates || []).filter((c) => {
|
|
1182
|
-
if (isSandboxCandidate(c))
|
|
1183
|
-
return false;
|
|
1184
|
-
if (c.approved !== true)
|
|
1185
|
-
return false;
|
|
1186
|
-
const s = candidateState(c);
|
|
1187
|
-
return (s === "approved" ||
|
|
1188
|
-
s === "post_failed" ||
|
|
1189
|
-
(s === "terminal" && expiredStampOverridable(c)));
|
|
1190
|
-
});
|
|
1044
|
+
const approved = (plan.candidates || []).filter((c) => c.approved === true &&
|
|
1045
|
+
c.posted !== true &&
|
|
1046
|
+
(c.terminal !== true || expiredStampOverridable(c)));
|
|
1191
1047
|
for (const c of approved) {
|
|
1192
1048
|
if (c.terminal === true) {
|
|
1193
1049
|
c.terminal = false;
|
|
@@ -1385,7 +1241,7 @@ async function postApproved(batchId, plan) {
|
|
|
1385
1241
|
};
|
|
1386
1242
|
}
|
|
1387
1243
|
if (approvedReddit.length)
|
|
1388
|
-
|
|
1244
|
+
mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1389
1245
|
return {
|
|
1390
1246
|
attempted: approvedReddit.length,
|
|
1391
1247
|
posted: redditPosted,
|
|
@@ -1398,8 +1254,9 @@ async function postApproved(batchId, plan) {
|
|
|
1398
1254
|
// If onboarding never persisted it, self-heal from the live session; if even that
|
|
1399
1255
|
// can't determine it, refuse here with a clear reason rather than launching a
|
|
1400
1256
|
// poster that silently burns the whole batch.
|
|
1401
|
-
|
|
1402
|
-
|
|
1257
|
+
if (!readConfiguredTwitterHandle())
|
|
1258
|
+
await ensurePostingHandle();
|
|
1259
|
+
if (!readConfiguredTwitterHandle()) {
|
|
1403
1260
|
postingActive = false;
|
|
1404
1261
|
stopPostingFlagHeartbeat();
|
|
1405
1262
|
return {
|
|
@@ -1407,9 +1264,9 @@ async function postApproved(batchId, plan) {
|
|
|
1407
1264
|
exit_code: 0,
|
|
1408
1265
|
posted: 0,
|
|
1409
1266
|
summary: "no_account_configured",
|
|
1410
|
-
error: "X is connected but no posting @handle
|
|
1411
|
-
"
|
|
1412
|
-
"
|
|
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.",
|
|
1413
1270
|
};
|
|
1414
1271
|
}
|
|
1415
1272
|
// Mark posting active so the draft-cycle scan DEFERS launching any scan for the
|
|
@@ -1625,21 +1482,6 @@ async function postApproved(batchId, plan) {
|
|
|
1625
1482
|
// here (found 2026-07-16) silently and permanently discarded 7 real
|
|
1626
1483
|
// approved drafts on nothing worse than lock contention — Reddit's
|
|
1627
1484
|
// equivalent transient failures self-healed on the very next drain.
|
|
1628
|
-
//
|
|
1629
|
-
// BOUNDED (2026-07-17): sticky is right, sticky-forever is not — an
|
|
1630
|
-
// unbounded retry is a zombie generator (one card retried 438 times
|
|
1631
|
-
// over 5 days on the Nhat install). Count the transient failures and
|
|
1632
|
-
// give up loudly after MAX_POST_ATTEMPTS; the terminal_reason keeps
|
|
1633
|
-
// the last failure visible so the give-up is diagnosable, and the
|
|
1634
|
-
// on-disk post-events trail records it for forensics.
|
|
1635
|
-
const attempts = (typeof c.post_attempts === "number" ? c.post_attempts : 0) + 1;
|
|
1636
|
-
c.post_attempts = attempts;
|
|
1637
|
-
if (attempts >= MAX_POST_ATTEMPTS) {
|
|
1638
|
-
c.terminal = true;
|
|
1639
|
-
c.terminal_reason = `gave_up_after_${attempts}_failed_attempts:${r.reason || "failed"}`;
|
|
1640
|
-
console.error(`[post] giving up on candidate ${r.candidate_id} after ${attempts} failed attempts (last: ${r.reason || "failed"})`);
|
|
1641
|
-
logPostEvent(`retry_budget_exhausted candidate=${r.candidate_id} attempts=${attempts} last=${r.reason || "failed"}`);
|
|
1642
|
-
}
|
|
1643
1485
|
touchedPlan = true;
|
|
1644
1486
|
}
|
|
1645
1487
|
});
|
|
@@ -1654,7 +1496,7 @@ async function postApproved(batchId, plan) {
|
|
|
1654
1496
|
// Reddit stamps (set in the reddit drain above) merge alongside the twitter
|
|
1655
1497
|
// ones: `approved` here spans both platforms.
|
|
1656
1498
|
if (touchedPlan || redditPosted || redditFailed) {
|
|
1657
|
-
|
|
1499
|
+
mergeApprovedStampsIntoStore(batchId, plan, approved);
|
|
1658
1500
|
}
|
|
1659
1501
|
// Post failures are HANDLED in the pipeline (it returns a count, never throws),
|
|
1660
1502
|
// so they never reach Sentry on their own. Capture an explicit event whenever
|
|
@@ -2899,16 +2741,6 @@ tool("post_drafts", {
|
|
|
2899
2741
|
const total = candidates.length;
|
|
2900
2742
|
const warnings = [];
|
|
2901
2743
|
const inRange = (n) => n >= 1 && n <= total;
|
|
2902
|
-
// Review-queue store: snapshot every row now so the write below can be a
|
|
2903
|
-
// field-level DIFF applied under the store lock (store_patch.py) instead
|
|
2904
|
-
// of an unlocked whole-file replace. This function holds its in-memory
|
|
2905
|
-
// plan across user think-time; a whole-file write here erased any menubar
|
|
2906
|
-
// decision or merge that landed in between (the 2026-07-17 truth-loss
|
|
2907
|
-
// family). Non-store batches keep the plain write: single writer.
|
|
2908
|
-
const isStoreBatch = batch_id === REVIEW_QUEUE_ID;
|
|
2909
|
-
const rowsBefore = isStoreBatch
|
|
2910
|
-
? candidates.map((c) => JSON.stringify(c))
|
|
2911
|
-
: [];
|
|
2912
2744
|
// ---- Rejections: durable + final --------------------------------------
|
|
2913
2745
|
// A rejected draft is marked terminal so it NEVER re-appears for review and is
|
|
2914
2746
|
// never posted. A reject overrides any earlier approve on the same card.
|
|
@@ -2919,7 +2751,7 @@ tool("post_drafts", {
|
|
|
2919
2751
|
return;
|
|
2920
2752
|
}
|
|
2921
2753
|
const c = candidates[n - 1];
|
|
2922
|
-
if (
|
|
2754
|
+
if (c.posted === true) {
|
|
2923
2755
|
warnings.push(`#${n} already posted; not rejecting`);
|
|
2924
2756
|
return;
|
|
2925
2757
|
}
|
|
@@ -3045,40 +2877,7 @@ tool("post_drafts", {
|
|
|
3045
2877
|
if (c)
|
|
3046
2878
|
c.approved = true;
|
|
3047
2879
|
});
|
|
3048
|
-
|
|
3049
|
-
// snapshot and apply only the changed fields under the store lock, so a
|
|
3050
|
-
// concurrent menubar decision or merge is never erased. Anything else
|
|
3051
|
-
// (per-batch /tmp plans): plain write, single writer.
|
|
3052
|
-
let storeWriteDone = false;
|
|
3053
|
-
if (isStoreBatch) {
|
|
3054
|
-
const patches = [];
|
|
3055
|
-
candidates.forEach((c, i) => {
|
|
3056
|
-
const beforeRaw = rowsBefore[i];
|
|
3057
|
-
const afterRaw = JSON.stringify(c);
|
|
3058
|
-
if (beforeRaw === afterRaw)
|
|
3059
|
-
return;
|
|
3060
|
-
const before = JSON.parse(beforeRaw ?? "{}");
|
|
3061
|
-
const after = JSON.parse(afterRaw);
|
|
3062
|
-
const set = {};
|
|
3063
|
-
const unset = [];
|
|
3064
|
-
for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
3065
|
-
if (!(k in after) || after[k] === undefined) {
|
|
3066
|
-
if (k in before)
|
|
3067
|
-
unset.push(k);
|
|
3068
|
-
}
|
|
3069
|
-
else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
|
|
3070
|
-
set[k] = after[k];
|
|
3071
|
-
}
|
|
3072
|
-
}
|
|
3073
|
-
if (Object.keys(set).length || unset.length)
|
|
3074
|
-
patches.push({ candidate_id: c.candidate_id ?? null, n: i + 1, set, unset });
|
|
3075
|
-
});
|
|
3076
|
-
storeWriteDone = await patchReviewStore(patches);
|
|
3077
|
-
if (!storeWriteDone)
|
|
3078
|
-
console.error("[post_drafts] store_patch.py failed; falling back to unlocked plan write");
|
|
3079
|
-
}
|
|
3080
|
-
if (!storeWriteDone)
|
|
3081
|
-
writePlan(batch_id, plan);
|
|
2880
|
+
writePlan(batch_id, plan);
|
|
3082
2881
|
if (approve.size === 0) {
|
|
3083
2882
|
return jsonContent({
|
|
3084
2883
|
batch_id,
|
|
@@ -3669,8 +3468,8 @@ async function autopilotLoaded() {
|
|
|
3669
3468
|
// fires every minute, claims ONE job, runs the pipeline's own prompt as its
|
|
3670
3469
|
// Claude turn, writes the result back, and stops.
|
|
3671
3470
|
// ===========================================================================
|
|
3672
|
-
const QUEUE_WORKER_PROMPT_VERSION =
|
|
3673
|
-
//
|
|
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
|
|
3674
3473
|
// sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
|
|
3675
3474
|
// within one continuous worker session) instead of drafting inline. Validated
|
|
3676
3475
|
// via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
|
|
@@ -3679,26 +3478,19 @@ const QUEUE_WORKER_PROMPT_VERSION = 9; // v9 (2026-07-17): poll window widened 2
|
|
|
3679
3478
|
// notification) or the host kills the whole parent+child chain in 1-3 min.
|
|
3680
3479
|
// Never live-fire tested against a real production job. Full design, what's
|
|
3681
3480
|
// validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
|
|
3682
|
-
// Bump this constant to
|
|
3481
|
+
// Bump this constant to 9 only once that plan is actually implemented.
|
|
3683
3482
|
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
3684
3483
|
// How long ONE `next --wait-seconds` call polls before giving up and exiting.
|
|
3685
|
-
//
|
|
3686
|
-
//
|
|
3687
|
-
//
|
|
3688
|
-
//
|
|
3689
|
-
//
|
|
3690
|
-
// session instead of paying a fresh spin-up, and MCP boot side effects
|
|
3691
|
-
// (backfill checks, backlog drains) run 1/15min instead of 1/5min. Watch
|
|
3692
|
-
// point: 900s has zero margin below the verified ceiling — if workers start
|
|
3693
|
-
// dying mid-poll with no reaper kill recorded, the host clipped the call;
|
|
3694
|
-
// back off to 600s. The cron's `* * * * *` cadence remains the outer safety
|
|
3695
|
-
// 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.
|
|
3696
3489
|
// COUPLING: scripts/reap_stale_claude_sessions.py's S4L_REAPER_CLAIM_GRACE_SEC
|
|
3697
3490
|
// default MUST stay >= this value + margin — a claimless session inside this
|
|
3698
3491
|
// poll window is legitimately still working, not a husk, and a too-tight
|
|
3699
3492
|
// claim_grace would SIGTERM it mid-poll before it ever gets to claim.
|
|
3700
|
-
|
|
3701
|
-
const QUEUE_WORKER_POLL_SECONDS = 900;
|
|
3493
|
+
const QUEUE_WORKER_POLL_SECONDS = 240;
|
|
3702
3494
|
// One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
|
|
3703
3495
|
const QUEUE_WORKERS = [
|
|
3704
3496
|
{ taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
|
|
@@ -5892,18 +5684,30 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
|
|
|
5892
5684
|
},
|
|
5893
5685
|
],
|
|
5894
5686
|
}));
|
|
5895
|
-
//
|
|
5896
|
-
//
|
|
5897
|
-
//
|
|
5898
|
-
//
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
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
|
+
}
|
|
5907
5711
|
async function main() {
|
|
5908
5712
|
initSentry();
|
|
5909
5713
|
// Detect a self-update (old_version -> new_version) as the very first thing
|
|
@@ -6079,9 +5883,13 @@ async function main() {
|
|
|
6079
5883
|
void startLocalPanel()
|
|
6080
5884
|
.then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
|
|
6081
5885
|
.catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
|
|
6082
|
-
//
|
|
6083
|
-
//
|
|
6084
|
-
|
|
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
|
+
}
|
|
6085
5893
|
// Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
|
|
6086
5894
|
// and cheap when already present, so existing installs pick it up on the next
|
|
6087
5895
|
// Claude restart without re-provisioning. Best-effort: never blocks boot.
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
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.3",
|
|
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
|
-
|
|
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
|