@m13v/s4l 1.7.6-rc.3 → 1.7.6-rc.30
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 +418 -123
- package/mcp/dist/panel.html +31 -17
- package/mcp/dist/setup.js +27 -7
- package/mcp/dist/telemetry.js +2 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +9 -1
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +63 -4
- package/mcp/menubar/s4l_card_canvas.py +62 -11
- package/mcp/menubar/s4l_menubar.py +406 -76
- package/mcp/menubar/s4l_state.py +54 -130
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +58 -14
- package/scripts/amplitude_24h_signups.py +7 -4
- package/scripts/amplitude_signups.py +7 -4
- 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/dm_conversation.py +15 -12
- package/scripts/draft_prompt_core.py +104 -17
- package/scripts/engage_github.py +11 -5
- package/scripts/engage_reddit.py +12 -5
- package/scripts/engagement_signal_feedback.py +1 -4
- package/scripts/engagement_styles.py +135 -98
- package/scripts/feedback_digest.py +54 -11
- package/scripts/find_threads.py +9 -7
- package/scripts/github_tools.py +7 -4
- package/scripts/learned_preferences.py +112 -78
- package/scripts/li_discovery.py +4 -1
- package/scripts/live_status.py +151 -0
- package/scripts/merge_review_queue.py +197 -129
- package/scripts/pick_project.py +7 -4
- package/scripts/pick_thread_target.py +7 -4
- package/scripts/pick_twitter_thread_target.py +7 -4
- package/scripts/platform_strike_events.py +54 -5
- package/scripts/post_github.py +17 -5
- package/scripts/post_reddit.py +27 -7
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/project_stats.py +7 -4
- package/scripts/reap_stale_claude_sessions.py +7 -6
- package/scripts/reddit_ban_check.py +220 -0
- package/scripts/reddit_browser.py +56 -19
- package/scripts/reddit_chat_sync.py +10 -11
- package/scripts/reddit_tools.py +4 -2
- package/scripts/release-mcpb.sh +47 -7
- package/scripts/run_moltbook_cycle.py +9 -5
- package/scripts/scan_dm_candidates.py +15 -9
- package/scripts/scan_github_replies.py +9 -7
- package/scripts/scan_moltbook_replies.py +7 -6
- package/scripts/scan_reddit_replies.py +11 -7
- package/scripts/scan_twitter_mentions_browser.py +7 -6
- package/scripts/scan_twitter_thread_followups.py +7 -6
- package/scripts/schedule_state.py +8 -4
- package/scripts/score_twitter_candidates.py +4 -2
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/snapshot.py +19 -0
- package/scripts/stats.py +12 -10
- package/scripts/store_patch.py +111 -0
- package/scripts/strike_alert.py +161 -12
- package/scripts/test_cross_language_parity.py +185 -0
- package/scripts/test_no_silent_fallbacks.py +84 -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-draft-and-publish.sh +9 -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";
|
|
@@ -760,7 +760,9 @@ 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
|
-
|
|
763
|
+
// awaiting_review is the ONLY state the review surfaces present (same rule
|
|
764
|
+
// as the menubar cards and the review-request.json count).
|
|
765
|
+
.filter((e) => candidateState(e.c) === "awaiting_review")
|
|
764
766
|
// The queue is append-only; newest drafts have the highest stable index.
|
|
765
767
|
// Show those first so review starts with likely-live tweets instead of stale
|
|
766
768
|
// low-number drafts that have been sitting around for hours.
|
|
@@ -846,40 +848,28 @@ function parsePostCandidateResults(stdout) {
|
|
|
846
848
|
}
|
|
847
849
|
return [...byId.values()];
|
|
848
850
|
}
|
|
849
|
-
// Resolve the
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
|
|
851
|
+
// Resolve the posting @handle through the ONE resolver (scripts/account_resolver.py:
|
|
852
|
+
// env AUTOPOSTER_TWITTER_HANDLE -> config.json accounts.twitter.handle -> the durable
|
|
853
|
+
// connect-time cookie mirror). Deferring to it, instead of re-reading config.json here,
|
|
854
|
+
// keeps a single resolution path shared with the poster (twitter_browser.our_handle),
|
|
855
|
+
// and keeps the handle in a single durable home (the cookie mirror) rather than a
|
|
856
|
+
// second copy in config.json. Returns the bare handle (no @) or null; the preflight
|
|
857
|
+
// refuses loudly on null so a missing handle fails ONCE, not as N silent per-reply
|
|
858
|
+
// no_account_configured skips (and never as a hardcoded impersonation fallback).
|
|
859
|
+
async function resolvePostingHandle() {
|
|
855
860
|
const env = (process.env.AUTOPOSTER_TWITTER_HANDLE || "").trim().replace(/^@/, "");
|
|
856
861
|
if (env)
|
|
857
862
|
return env;
|
|
858
863
|
try {
|
|
859
|
-
const
|
|
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,
|
|
864
|
+
const r = await runPython("scripts/account_resolver.py", ["twitter"], {
|
|
865
|
+
timeoutMs: 30_000,
|
|
878
866
|
env: ({ S4L_REPO_DIR: repoDir(), PATH: pipelinePath() }),
|
|
879
867
|
});
|
|
868
|
+
const h = (r.stdout || "").trim().replace(/^@/, "");
|
|
869
|
+
return h || null;
|
|
880
870
|
}
|
|
881
871
|
catch {
|
|
882
|
-
|
|
872
|
+
return null;
|
|
883
873
|
}
|
|
884
874
|
}
|
|
885
875
|
async function ensureTwitterBrowserForPost() {
|
|
@@ -909,12 +899,68 @@ async function ensureTwitterBrowserForPost() {
|
|
|
909
899
|
// thread still exists. Without this override, a card approved while (or just
|
|
910
900
|
// before) the sync stamped it is refused as already-decided and the approval
|
|
911
901
|
// 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;
|
|
912
907
|
function expiredStampOverridable(c) {
|
|
913
908
|
return (c.terminal === true &&
|
|
914
909
|
c.posted !== true &&
|
|
915
910
|
c.discard_reason === "backend_status_expired");
|
|
916
911
|
}
|
|
917
|
-
|
|
912
|
+
// Prompt-sandbox replay cards (run-twitter-cycle.sh S4L_SANDBOX_CANDIDATES_FILE)
|
|
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) {
|
|
918
964
|
// Merge posted/terminal stamps into a FRESH read of the store instead of
|
|
919
965
|
// rewriting the whole plan from the copy taken minutes ago. The old
|
|
920
966
|
// whole-file write was last-writer-wins: while a batch posted, the menubar
|
|
@@ -926,28 +972,106 @@ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
|
926
972
|
// overwrites a fresh `posted=true`. Fallback: candidates without a
|
|
927
973
|
// candidate_id can't be matched into the fresh copy, so keep the legacy
|
|
928
974
|
// 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
|
+
}
|
|
929
1020
|
try {
|
|
930
1021
|
const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
931
1022
|
const fresh = mergeable ? readPlan(batchId) : null;
|
|
932
1023
|
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.
|
|
933
1030
|
const freshById = new Map();
|
|
934
1031
|
fresh.candidates.forEach((c) => {
|
|
935
|
-
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
936
|
-
|
|
1032
|
+
if (c.candidate_id !== undefined && c.candidate_id !== null) {
|
|
1033
|
+
const key = String(c.candidate_id);
|
|
1034
|
+
const list = freshById.get(key);
|
|
1035
|
+
if (list)
|
|
1036
|
+
list.push(c);
|
|
1037
|
+
else
|
|
1038
|
+
freshById.set(key, [c]);
|
|
1039
|
+
}
|
|
937
1040
|
});
|
|
938
1041
|
for (const c of stamped) {
|
|
939
|
-
const f
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1042
|
+
for (const f of freshById.get(String(c.candidate_id)) ?? []) {
|
|
1043
|
+
if (c.posted === true) {
|
|
1044
|
+
f.posted = true;
|
|
1045
|
+
f.terminal = false;
|
|
1046
|
+
if (c.our_url)
|
|
1047
|
+
f.our_url = c.our_url;
|
|
1048
|
+
// A post outcome closes the card's history: the pre-approval
|
|
1049
|
+
// freshness stamp must not survive it. Same for a stale
|
|
1050
|
+
// post_failed from a peer call's posted=0 misattribution (see
|
|
1051
|
+
// the locked-patch branch above).
|
|
1052
|
+
delete f.discard_reason;
|
|
1053
|
+
delete f.post_failed;
|
|
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;
|
|
951
1075
|
}
|
|
952
1076
|
}
|
|
953
1077
|
writePlan(batchId, fresh);
|
|
@@ -1041,9 +1165,29 @@ async function postApproved(batchId, plan) {
|
|
|
1041
1165
|
// An approved card whose only blocker is an overridable backend-expiry stamp is
|
|
1042
1166
|
// included: approval outranks the freshness gate (see expiredStampOverridable),
|
|
1043
1167
|
// and the stamp is cleared so every downstream terminal check agrees it's live.
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1168
|
+
// Drain-eligible, in canonical state terms (candidateState — keep in lockstep
|
|
1169
|
+
// with s4l_state.candidate_state). DELIBERATE deviations from a plain
|
|
1170
|
+
// state === "approved" check, each load-bearing:
|
|
1171
|
+
// - post_failed cards DO drain again (the transient-retry path, bounded by
|
|
1172
|
+
// MAX_POST_ATTEMPTS at result time). The menubar's restart-resume path
|
|
1173
|
+
// (store_pending_posts) skips them by design; this drain is where they
|
|
1174
|
+
// get their bounded retries.
|
|
1175
|
+
// - an approved card whose ONLY terminal stamp is the freshness gate's
|
|
1176
|
+
// backend_status_expired is resurrected: the human approval outranks the
|
|
1177
|
+
// expiry (2 of 3 approvals lost on 2026-07-10 without this).
|
|
1178
|
+
// - sandbox replays never drain (twitter_post_plan.py post_one()
|
|
1179
|
+
// hard-refuses them, so draining one is pure churn and can loop forever
|
|
1180
|
+
// if its terminal stamp loses a store-write race).
|
|
1181
|
+
const approved = (plan.candidates || []).filter((c) => {
|
|
1182
|
+
if (isSandboxCandidate(c))
|
|
1183
|
+
return false;
|
|
1184
|
+
if (c.approved !== true)
|
|
1185
|
+
return false;
|
|
1186
|
+
const s = candidateState(c);
|
|
1187
|
+
return (s === "approved" ||
|
|
1188
|
+
s === "post_failed" ||
|
|
1189
|
+
(s === "terminal" && expiredStampOverridable(c)));
|
|
1190
|
+
});
|
|
1047
1191
|
for (const c of approved) {
|
|
1048
1192
|
if (c.terminal === true) {
|
|
1049
1193
|
c.terminal = false;
|
|
@@ -1241,7 +1385,7 @@ async function postApproved(batchId, plan) {
|
|
|
1241
1385
|
};
|
|
1242
1386
|
}
|
|
1243
1387
|
if (approvedReddit.length)
|
|
1244
|
-
mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1388
|
+
await mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1245
1389
|
return {
|
|
1246
1390
|
attempted: approvedReddit.length,
|
|
1247
1391
|
posted: redditPosted,
|
|
@@ -1254,9 +1398,8 @@ async function postApproved(batchId, plan) {
|
|
|
1254
1398
|
// If onboarding never persisted it, self-heal from the live session; if even that
|
|
1255
1399
|
// can't determine it, refuse here with a clear reason rather than launching a
|
|
1256
1400
|
// poster that silently burns the whole batch.
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
if (!readConfiguredTwitterHandle()) {
|
|
1401
|
+
const postingHandle = await resolvePostingHandle();
|
|
1402
|
+
if (!postingHandle) {
|
|
1260
1403
|
postingActive = false;
|
|
1261
1404
|
stopPostingFlagHeartbeat();
|
|
1262
1405
|
return {
|
|
@@ -1264,9 +1407,9 @@ async function postApproved(batchId, plan) {
|
|
|
1264
1407
|
exit_code: 0,
|
|
1265
1408
|
posted: 0,
|
|
1266
1409
|
summary: "no_account_configured",
|
|
1267
|
-
error: "X is connected but no posting @handle
|
|
1268
|
-
"
|
|
1269
|
-
"
|
|
1410
|
+
error: "X is connected but no posting @handle could be resolved (env, config, or the " +
|
|
1411
|
+
"connect-time cookie mirror), so every reply would be refused (no_account_configured). " +
|
|
1412
|
+
"Re-run project_config action:'connect_x' to re-capture the handle.",
|
|
1270
1413
|
};
|
|
1271
1414
|
}
|
|
1272
1415
|
// Mark posting active so the draft-cycle scan DEFERS launching any scan for the
|
|
@@ -1482,6 +1625,21 @@ async function postApproved(batchId, plan) {
|
|
|
1482
1625
|
// here (found 2026-07-16) silently and permanently discarded 7 real
|
|
1483
1626
|
// approved drafts on nothing worse than lock contention — Reddit's
|
|
1484
1627
|
// 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
|
+
}
|
|
1485
1643
|
touchedPlan = true;
|
|
1486
1644
|
}
|
|
1487
1645
|
});
|
|
@@ -1496,7 +1654,7 @@ async function postApproved(batchId, plan) {
|
|
|
1496
1654
|
// Reddit stamps (set in the reddit drain above) merge alongside the twitter
|
|
1497
1655
|
// ones: `approved` here spans both platforms.
|
|
1498
1656
|
if (touchedPlan || redditPosted || redditFailed) {
|
|
1499
|
-
mergeApprovedStampsIntoStore(batchId, plan, approved);
|
|
1657
|
+
await mergeApprovedStampsIntoStore(batchId, plan, approved);
|
|
1500
1658
|
}
|
|
1501
1659
|
// Post failures are HANDLED in the pipeline (it returns a count, never throws),
|
|
1502
1660
|
// so they never reach Sentry on their own. Capture an explicit event whenever
|
|
@@ -2333,6 +2491,7 @@ tool("project_config", {
|
|
|
2333
2491
|
const r = await redditConnect(args.reddit_source, args.reddit_manual_login);
|
|
2334
2492
|
let verified = false;
|
|
2335
2493
|
let configNote;
|
|
2494
|
+
let redditKicker;
|
|
2336
2495
|
if (r.connected) {
|
|
2337
2496
|
completeOnboardingMilestone("reddit_connected", { state: r.state });
|
|
2338
2497
|
// Persist the discovered username through the server's config-write
|
|
@@ -2357,11 +2516,18 @@ tool("project_config", {
|
|
|
2357
2516
|
});
|
|
2358
2517
|
}
|
|
2359
2518
|
// Reddit is live: install the discovery kicker (idempotent; gated on
|
|
2360
|
-
// runtime +
|
|
2361
|
-
// hand-built plist pointing outside the managed package).
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2519
|
+
// runtime + draftable-lane readiness inside, and it never touches a
|
|
2520
|
+
// hand-built plist pointing outside the managed package). Await it and
|
|
2521
|
+
// surface the result on the response so a skip (e.g. no ready project AND
|
|
2522
|
+
// no active persona) is VISIBLE instead of failing silently to stderr.
|
|
2523
|
+
try {
|
|
2524
|
+
redditKicker = await ensureRedditKickerInstalled();
|
|
2525
|
+
console.error(`[reddit-kicker] post-connect install: ${redditKicker.ok ? "ok" : "skip"} (${redditKicker.detail})`);
|
|
2526
|
+
}
|
|
2527
|
+
catch (e) {
|
|
2528
|
+
redditKicker = { ok: false, detail: e?.message || String(e) };
|
|
2529
|
+
console.error("[reddit-kicker] post-connect install failed:", e?.message || e);
|
|
2530
|
+
}
|
|
2365
2531
|
}
|
|
2366
2532
|
else {
|
|
2367
2533
|
blockOnboardingMilestone("reddit_connected", `reddit_${r.state || "not_connected"}`, r.error || r.note || summarizeRedditAuth(r), { state: r.state || "not_connected" });
|
|
@@ -2382,11 +2548,20 @@ tool("project_config", {
|
|
|
2382
2548
|
summary: summarizeRedditAuth(r),
|
|
2383
2549
|
note: r.note,
|
|
2384
2550
|
attempts: r.attempts,
|
|
2551
|
+
// The launchd discovery kicker's install result (or skip reason), so the
|
|
2552
|
+
// agent can SEE whether reddit drafting is actually scheduled rather than
|
|
2553
|
+
// assuming "connected" means "producing drafts".
|
|
2554
|
+
discovery_job: redditKicker,
|
|
2385
2555
|
onboarding: onboardingSnapshot(),
|
|
2386
2556
|
next_step: r.connected
|
|
2387
|
-
?
|
|
2388
|
-
"
|
|
2389
|
-
|
|
2557
|
+
? (redditKicker?.ok
|
|
2558
|
+
? "Reddit is connected and the discovery job is scheduled (runs every ~15 min); " +
|
|
2559
|
+
"drafts flow into the same review cards as X and nothing posts without approval."
|
|
2560
|
+
: "Reddit is connected, but the discovery job is NOT scheduled yet (" +
|
|
2561
|
+
(redditKicker?.detail || "unknown") + "). It installs automatically once a product " +
|
|
2562
|
+
"project is ready OR personal_brand mode is on with a ready persona; until then no " +
|
|
2563
|
+
"reddit drafts will appear. Drafts, once flowing, land in the same review cards as X " +
|
|
2564
|
+
"and nothing posts without approval.") +
|
|
2390
2565
|
(r.warning ? " Relay the `warning` field to the user so expectations are set." : "")
|
|
2391
2566
|
: r.state === "needs_login"
|
|
2392
2567
|
? "The user must finish signing in to reddit.com in the Chrome window that opened. Tell " +
|
|
@@ -2741,6 +2916,16 @@ tool("post_drafts", {
|
|
|
2741
2916
|
const total = candidates.length;
|
|
2742
2917
|
const warnings = [];
|
|
2743
2918
|
const inRange = (n) => n >= 1 && n <= total;
|
|
2919
|
+
// Review-queue store: snapshot every row now so the write below can be a
|
|
2920
|
+
// field-level DIFF applied under the store lock (store_patch.py) instead
|
|
2921
|
+
// of an unlocked whole-file replace. This function holds its in-memory
|
|
2922
|
+
// plan across user think-time; a whole-file write here erased any menubar
|
|
2923
|
+
// decision or merge that landed in between (the 2026-07-17 truth-loss
|
|
2924
|
+
// family). Non-store batches keep the plain write: single writer.
|
|
2925
|
+
const isStoreBatch = batch_id === REVIEW_QUEUE_ID;
|
|
2926
|
+
const rowsBefore = isStoreBatch
|
|
2927
|
+
? candidates.map((c) => JSON.stringify(c))
|
|
2928
|
+
: [];
|
|
2744
2929
|
// ---- Rejections: durable + final --------------------------------------
|
|
2745
2930
|
// A rejected draft is marked terminal so it NEVER re-appears for review and is
|
|
2746
2931
|
// never posted. A reject overrides any earlier approve on the same card.
|
|
@@ -2751,7 +2936,7 @@ tool("post_drafts", {
|
|
|
2751
2936
|
return;
|
|
2752
2937
|
}
|
|
2753
2938
|
const c = candidates[n - 1];
|
|
2754
|
-
if (c
|
|
2939
|
+
if (candidateState(c) === "posted") {
|
|
2755
2940
|
warnings.push(`#${n} already posted; not rejecting`);
|
|
2756
2941
|
return;
|
|
2757
2942
|
}
|
|
@@ -2877,7 +3062,40 @@ tool("post_drafts", {
|
|
|
2877
3062
|
if (c)
|
|
2878
3063
|
c.approved = true;
|
|
2879
3064
|
});
|
|
2880
|
-
|
|
3065
|
+
// Persist the decision mutations. Store batch: diff each row against its
|
|
3066
|
+
// snapshot and apply only the changed fields under the store lock, so a
|
|
3067
|
+
// concurrent menubar decision or merge is never erased. Anything else
|
|
3068
|
+
// (per-batch /tmp plans): plain write, single writer.
|
|
3069
|
+
let storeWriteDone = false;
|
|
3070
|
+
if (isStoreBatch) {
|
|
3071
|
+
const patches = [];
|
|
3072
|
+
candidates.forEach((c, i) => {
|
|
3073
|
+
const beforeRaw = rowsBefore[i];
|
|
3074
|
+
const afterRaw = JSON.stringify(c);
|
|
3075
|
+
if (beforeRaw === afterRaw)
|
|
3076
|
+
return;
|
|
3077
|
+
const before = JSON.parse(beforeRaw ?? "{}");
|
|
3078
|
+
const after = JSON.parse(afterRaw);
|
|
3079
|
+
const set = {};
|
|
3080
|
+
const unset = [];
|
|
3081
|
+
for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
3082
|
+
if (!(k in after) || after[k] === undefined) {
|
|
3083
|
+
if (k in before)
|
|
3084
|
+
unset.push(k);
|
|
3085
|
+
}
|
|
3086
|
+
else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
|
|
3087
|
+
set[k] = after[k];
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
if (Object.keys(set).length || unset.length)
|
|
3091
|
+
patches.push({ candidate_id: c.candidate_id ?? null, n: i + 1, set, unset });
|
|
3092
|
+
});
|
|
3093
|
+
storeWriteDone = await patchReviewStore(patches);
|
|
3094
|
+
if (!storeWriteDone)
|
|
3095
|
+
console.error("[post_drafts] store_patch.py failed; falling back to unlocked plan write");
|
|
3096
|
+
}
|
|
3097
|
+
if (!storeWriteDone)
|
|
3098
|
+
writePlan(batch_id, plan);
|
|
2881
3099
|
if (approve.size === 0) {
|
|
2882
3100
|
return jsonContent({
|
|
2883
3101
|
batch_id,
|
|
@@ -3149,6 +3367,70 @@ tool("restart_menubar", {
|
|
|
3149
3367
|
menubar_running: running,
|
|
3150
3368
|
});
|
|
3151
3369
|
});
|
|
3370
|
+
// ---- scan_now: fire one scanner cycle immediately --------------------------
|
|
3371
|
+
// The scanner (the twitter scan+draft cycle) is what drives the whole pipeline;
|
|
3372
|
+
// it runs on the launchd kicker's fixed StartInterval. This nudges launchd to
|
|
3373
|
+
// fire it RIGHT NOW via `launchctl kickstart` — the EXACT same mechanism the
|
|
3374
|
+
// first-run onboarding kick uses (ensureQueueKickerInstalled): the job runs
|
|
3375
|
+
// THROUGH launchd with its full baked env + the run-*-singleton.sh lock, so it
|
|
3376
|
+
// is NOT a bare `bash run-twitter-cycle.sh` (which would leave an empty-plan
|
|
3377
|
+
// artifact). launchd keeps a single instance, so if a cycle is already running
|
|
3378
|
+
// this is a harmless no-op; while paused the kicker is unloaded, so we report
|
|
3379
|
+
// that rather than kick nothing.
|
|
3380
|
+
//
|
|
3381
|
+
// This is deliberately NOT the removed run_draft_cycle / draft_cycle tool: it
|
|
3382
|
+
// spawns no warm in-chat Claude draft session, it only asks launchd to run the
|
|
3383
|
+
// already-scheduled cycle one interval early. Do NOT re-introduce a draft_cycle.
|
|
3384
|
+
tool("scan_now", {
|
|
3385
|
+
title: "Run the scanner now",
|
|
3386
|
+
description: "Fire ONE scanner cycle immediately instead of waiting for the next scheduled run. The scanner " +
|
|
3387
|
+
"(the twitter scan+draft cycle) is what discovers threads and kicks off drafting, so this is the " +
|
|
3388
|
+
"'run now' control behind the menu bar + dashboard button. It nudges the existing launchd job to " +
|
|
3389
|
+
"run one interval early; it does NOT change the schedule. If a cycle is already running it's a " +
|
|
3390
|
+
"no-op, and if S4L is paused it reports that (nothing runs while paused). Use when the user asks " +
|
|
3391
|
+
"to scan now, run the scanner, run now, or check for new threads immediately.",
|
|
3392
|
+
inputSchema: {},
|
|
3393
|
+
}, async () => {
|
|
3394
|
+
if (isPaused()) {
|
|
3395
|
+
return jsonContent({
|
|
3396
|
+
ok: false,
|
|
3397
|
+
paused: true,
|
|
3398
|
+
detail: "S4L is paused — resume it first, then the scanner can run.",
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
3401
|
+
const uid = process.getuid ? process.getuid() : 0;
|
|
3402
|
+
const kick = await run("launchctl", ["kickstart", `gui/${uid}/${TWITTER_AUTOPILOT_LABEL}`], { timeoutMs: 15_000 });
|
|
3403
|
+
const ok = kick.code === 0;
|
|
3404
|
+
return jsonContent({
|
|
3405
|
+
ok,
|
|
3406
|
+
detail: ok
|
|
3407
|
+
? "Scanner cycle kicked off — new drafts land in a few minutes."
|
|
3408
|
+
: `Could not start the scanner (launchctl rc=${kick.code}).`,
|
|
3409
|
+
});
|
|
3410
|
+
});
|
|
3411
|
+
// ---- scan_status: live pipeline status + next-scanner-run countdown --------
|
|
3412
|
+
// A cheap, pure READ the dashboard widget polls (~5s) to keep its scanner status
|
|
3413
|
+
// pill + countdown live between heavier snapshot refreshes. Returns the SAME
|
|
3414
|
+
// fields the menu bar renders (scripts/live_status.py — activity_state,
|
|
3415
|
+
// next_run_secs, …), plus `paused`, so the two surfaces can't drift. No side
|
|
3416
|
+
// effects: safe to poll and to replay over the loopback /tool/ endpoint.
|
|
3417
|
+
tool("scan_status", {
|
|
3418
|
+
title: "Live scanner status + next-run countdown",
|
|
3419
|
+
description: "Read-only: what the pipeline is doing right now (scanning/drafting/posting/idle) and how many " +
|
|
3420
|
+
"seconds until the next scanner run. Backs the dashboard's live status pill + countdown. Pure " +
|
|
3421
|
+
"read; safe to poll frequently.",
|
|
3422
|
+
inputSchema: {},
|
|
3423
|
+
}, async () => {
|
|
3424
|
+
let live = {};
|
|
3425
|
+
try {
|
|
3426
|
+
const res = await runPython("scripts/live_status.py", [], { timeoutMs: 10_000 });
|
|
3427
|
+
live = JSON.parse((res.stdout || "").trim().split("\n").slice(-1)[0] || "{}");
|
|
3428
|
+
}
|
|
3429
|
+
catch {
|
|
3430
|
+
live = {};
|
|
3431
|
+
}
|
|
3432
|
+
return jsonContent({ ...live, paused: isPaused() });
|
|
3433
|
+
});
|
|
3152
3434
|
// ---- posting_volume: per-install posting-volume mode (virality bar) --------
|
|
3153
3435
|
// Server-side throttle (2026-07-13): installations.posting_mode maps to a
|
|
3154
3436
|
// virality-bar percentile on the API (high~0.90, medium~0.97, low~0.995) and
|
|
@@ -3468,8 +3750,8 @@ async function autopilotLoaded() {
|
|
|
3468
3750
|
// fires every minute, claims ONE job, runs the pipeline's own prompt as its
|
|
3469
3751
|
// Claude turn, writes the result back, and stops.
|
|
3470
3752
|
// ===========================================================================
|
|
3471
|
-
const QUEUE_WORKER_PROMPT_VERSION =
|
|
3472
|
-
//
|
|
3753
|
+
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.
|
|
3754
|
+
// v10 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
|
|
3473
3755
|
// sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
|
|
3474
3756
|
// within one continuous worker session) instead of drafting inline. Validated
|
|
3475
3757
|
// via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
|
|
@@ -3478,19 +3760,26 @@ const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_jo
|
|
|
3478
3760
|
// notification) or the host kills the whole parent+child chain in 1-3 min.
|
|
3479
3761
|
// Never live-fire tested against a real production job. Full design, what's
|
|
3480
3762
|
// validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
|
|
3481
|
-
// Bump this constant to
|
|
3763
|
+
// Bump this constant to 10 only once that plan is actually implemented.
|
|
3482
3764
|
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
3483
3765
|
// How long ONE `next --wait-seconds` call polls before giving up and exiting.
|
|
3484
|
-
//
|
|
3485
|
-
// live on 2026-07-06
|
|
3486
|
-
//
|
|
3487
|
-
//
|
|
3488
|
-
//
|
|
3766
|
+
// 900s (15 min, per Matthew 2026-07-17, up from 240s): sits AT the single-
|
|
3767
|
+
// Bash-call survival ceiling verified live on 2026-07-06 (the host's ~90s
|
|
3768
|
+
// inactivity kill fires only on model silence, and one in-flight tool call
|
|
3769
|
+
// survived a full 900s probe). This covers the ~8min average real job
|
|
3770
|
+
// inter-arrival gap outright, so most jobs are claimed by an already-polling
|
|
3771
|
+
// session instead of paying a fresh spin-up, and MCP boot side effects
|
|
3772
|
+
// (backfill checks, backlog drains) run 1/15min instead of 1/5min. Watch
|
|
3773
|
+
// point: 900s has zero margin below the verified ceiling — if workers start
|
|
3774
|
+
// dying mid-poll with no reaper kill recorded, the host clipped the call;
|
|
3775
|
+
// back off to 600s. The cron's `* * * * *` cadence remains the outer safety
|
|
3776
|
+
// net for whatever the poll window doesn't catch.
|
|
3489
3777
|
// COUPLING: scripts/reap_stale_claude_sessions.py's S4L_REAPER_CLAIM_GRACE_SEC
|
|
3490
3778
|
// default MUST stay >= this value + margin — a claimless session inside this
|
|
3491
3779
|
// poll window is legitimately still working, not a husk, and a too-tight
|
|
3492
3780
|
// claim_grace would SIGTERM it mid-poll before it ever gets to claim.
|
|
3493
|
-
|
|
3781
|
+
// (Bumped to 1020s alongside this change.)
|
|
3782
|
+
const QUEUE_WORKER_POLL_SECONDS = 900;
|
|
3494
3783
|
// One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
|
|
3495
3784
|
const QUEUE_WORKERS = [
|
|
3496
3785
|
{ taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
|
|
@@ -3978,26 +4267,40 @@ function kickerEnv() {
|
|
|
3978
4267
|
// skill/run-twitter-cycle.sh (single source of truth, no env dependency).
|
|
3979
4268
|
};
|
|
3980
4269
|
}
|
|
4270
|
+
// Shared draftability gate for BOTH autopilot kickers (X queue + Reddit search).
|
|
4271
|
+
// A launchd discovery/draft kicker only makes sense to schedule when SOMETHING is
|
|
4272
|
+
// draftable, and the definition of "draftable" is identical across platforms — so
|
|
4273
|
+
// it lives here as the single source of truth. Two paths qualify:
|
|
4274
|
+
// (a) a managed product project is ready (promotion lane), OR
|
|
4275
|
+
// (b) personal_brand mode is on AND the persona is ready (self-promo lane).
|
|
4276
|
+
// Path (b) is easy to miss: the persona is deliberately excluded from the
|
|
4277
|
+
// managed-products scope (see ensurePersonaProject), so a personal-brand-only
|
|
4278
|
+
// install has an empty managedProjects() and a product-only check is always
|
|
4279
|
+
// false. That gap silently sank the X kicker (fixed 2026-06-30) and then the
|
|
4280
|
+
// Reddit kicker (fixed 2026-07-16, which reused the product-only check instead of
|
|
4281
|
+
// this persona-aware one). Keeping both kickers on this ONE helper means the
|
|
4282
|
+
// personal-brand path can never regress on one platform but not the other.
|
|
4283
|
+
function draftableLaneStatus() {
|
|
4284
|
+
const productReady = listManagedProjectStatus().some((p) => p.ready);
|
|
4285
|
+
if (productReady)
|
|
4286
|
+
return { draftable: true, detail: "ready project" };
|
|
4287
|
+
const personaActive = currentFlags().personal_brand && personaReady();
|
|
4288
|
+
if (personaActive) {
|
|
4289
|
+
return { draftable: true, detail: "active persona (personal_brand)" };
|
|
4290
|
+
}
|
|
4291
|
+
return { draftable: false, detail: "no ready project or active persona yet" };
|
|
4292
|
+
}
|
|
3981
4293
|
async function ensureQueueKickerInstalled() {
|
|
3982
4294
|
try {
|
|
3983
4295
|
if (process.platform !== "darwin")
|
|
3984
4296
|
return { ok: false, detail: "not macOS" };
|
|
3985
4297
|
if (!runtimeReady())
|
|
3986
4298
|
return { ok: false, detail: "runtime not ready" };
|
|
3987
|
-
// Gate: install the kicker when SOMETHING is draftable
|
|
3988
|
-
//
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
// installed (no drafts, no first-run kick). The persona is excluded from
|
|
3993
|
-
// managed scope by design, so check it explicitly.
|
|
3994
|
-
const productReady = listManagedProjectStatus().some((p) => p.ready);
|
|
3995
|
-
const personaActive = currentFlags().personal_brand && personaReady();
|
|
3996
|
-
if (!productReady && !personaActive) {
|
|
3997
|
-
return {
|
|
3998
|
-
ok: false,
|
|
3999
|
-
detail: "no ready project or active persona yet",
|
|
4000
|
-
};
|
|
4299
|
+
// Gate: install the kicker only when SOMETHING is draftable (product lane OR
|
|
4300
|
+
// active persona). See draftableLaneStatus — shared with the Reddit kicker.
|
|
4301
|
+
const lane = draftableLaneStatus();
|
|
4302
|
+
if (!lane.draftable) {
|
|
4303
|
+
return { ok: false, detail: lane.detail };
|
|
4001
4304
|
}
|
|
4002
4305
|
// Additional gate: X must be connected and verified before autopilot starts.
|
|
4003
4306
|
// This prevents wasted cycles against a logged-out x.com if X connection didn't
|
|
@@ -4115,9 +4418,14 @@ async function ensureRedditKickerInstalled() {
|
|
|
4115
4418
|
return { ok: false, detail: "not macOS" };
|
|
4116
4419
|
if (!runtimeReady())
|
|
4117
4420
|
return { ok: false, detail: "runtime not ready" };
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4421
|
+
// Gate: install the kicker only when SOMETHING is draftable. Mirror the X
|
|
4422
|
+
// queue kicker EXACTLY via the shared draftableLaneStatus — a personal-brand-
|
|
4423
|
+
// only install has no managed product, so the old product-only check here made
|
|
4424
|
+
// this kicker permanently uninstallable (no reddit drafts) even with a ready
|
|
4425
|
+
// persona (2026-07-16 bug). Both kickers now share the one persona-aware gate.
|
|
4426
|
+
const lane = draftableLaneStatus();
|
|
4427
|
+
if (!lane.draftable)
|
|
4428
|
+
return { ok: false, detail: lane.detail };
|
|
4121
4429
|
// Reddit session gate. status is read-only (never launches Chrome): a live
|
|
4122
4430
|
// me.json validation when the harness is up, the on-disk profile cookie
|
|
4123
4431
|
// check (connected_idle) when it isn't.
|
|
@@ -5684,30 +5992,18 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
|
|
|
5684
5992
|
},
|
|
5685
5993
|
],
|
|
5686
5994
|
}));
|
|
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
|
-
}
|
|
5995
|
+
// REMOVED (2026-07-17): drainApprovedBacklog. It ran 30s after EVERY MCP boot,
|
|
5996
|
+
// which was sane when boots meant "user launched Claude Desktop" — but each
|
|
5997
|
+
// queue-worker session boots its own MCP server, so the drain had silently
|
|
5998
|
+
// become a ~5-minute cron running across up to 4 concurrent MCP instances.
|
|
5999
|
+
// Combined with universal posting preemption, every drain wakeup SIGKILLed
|
|
6000
|
+
// whatever held the twitter-browser lock (profile scans included), and any
|
|
6001
|
+
// stamp bug turned into an infinite retry loop (438 retries over 5 days on
|
|
6002
|
+
// one Nhat card). Backlog recovery is now owned by ONE long-lived process:
|
|
6003
|
+
// the menubar's _resume_approved_queue, which runs on loopback-reachable and
|
|
6004
|
+
// periodically thereafter (mcp/menubar/s4l_menubar.py). Do NOT re-add a
|
|
6005
|
+
// boot-time drain here; if the menubar is dead, ensureMenubar() below revives
|
|
6006
|
+
// it and its resume covers the backlog.
|
|
5711
6007
|
async function main() {
|
|
5712
6008
|
initSentry();
|
|
5713
6009
|
// Detect a self-update (old_version -> new_version) as the very first thing
|
|
@@ -5791,8 +6087,11 @@ async function main() {
|
|
|
5791
6087
|
console.error("[queue-worker] launchd kicker: skip (paused)");
|
|
5792
6088
|
}
|
|
5793
6089
|
// Reddit discovery kicker (optional platform): installs only when reddit is
|
|
5794
|
-
// connected AND a
|
|
5795
|
-
//
|
|
6090
|
+
// connected AND a draftable lane exists (product ready OR active persona, via
|
|
6091
|
+
// the shared draftableLaneStatus). A box that never connected reddit is a cheap
|
|
6092
|
+
// no-op skip. This boot-time call also SELF-HEALS installs broken by the old
|
|
6093
|
+
// product-only gate: once the gate passes, the plist lands on the next boot
|
|
6094
|
+
// (worker sessions boot ~1/min). Paused-gated like the X kicker (produces drafts).
|
|
5796
6095
|
if (!isPaused()) {
|
|
5797
6096
|
void ensureRedditKickerInstalled()
|
|
5798
6097
|
.then((r) => console.error(`[reddit-kicker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
@@ -5883,13 +6182,9 @@ async function main() {
|
|
|
5883
6182
|
void startLocalPanel()
|
|
5884
6183
|
.then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
|
|
5885
6184
|
.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
|
-
}
|
|
6185
|
+
// NOTE (2026-07-17): the boot-time drainApprovedBacklog() call that lived
|
|
6186
|
+
// here is gone — backlog recovery is owned by the menubar's periodic
|
|
6187
|
+
// _resume_approved_queue (single drainer; see the removal note above).
|
|
5893
6188
|
// Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
|
|
5894
6189
|
// and cheap when already present, so existing installs pick it up on the next
|
|
5895
6190
|
// Claude restart without re-provisioning. Best-effort: never blocks boot.
|