@m13v/s4l 1.7.6-rc.8 → 1.7.6
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/README.md +1 -1
- package/bin/cli.js +33 -9
- package/mcp/dist/index.js +290 -177
- package/mcp/dist/panel.html +31 -17
- package/mcp/dist/setup.js +27 -7
- package/mcp/dist/telemetry.js +15 -6
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +10 -2
- 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 +380 -95
- package/mcp/menubar/s4l_state.py +119 -41
- 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/claude_job.py +70 -10
- package/scripts/dm_conversation.py +15 -12
- package/scripts/draft_prompt_core.py +132 -64
- 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 +144 -105
- package/scripts/feedback_digest.py +54 -11
- package/scripts/find_threads.py +9 -7
- package/scripts/github_tools.py +23 -10
- package/scripts/learned_preferences.py +112 -78
- package/scripts/li_discovery.py +4 -1
- package/scripts/link_tail.py +1 -1
- package/scripts/live_status.py +151 -0
- package/scripts/merge_review_queue.py +15 -2
- package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
- package/scripts/pick_project.py +7 -4
- package/scripts/pick_thread_target.py +64 -13
- package/scripts/pick_twitter_thread_target.py +7 -4
- package/scripts/platform_strike_events.py +139 -7
- package/scripts/post_github.py +376 -35
- package/scripts/post_reddit.py +202 -192
- package/scripts/preflight.sh +23 -3
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/project_stats.py +7 -4
- package/scripts/reddit_ban_check.py +324 -0
- package/scripts/reddit_browser.py +56 -19
- package/scripts/reddit_chat_sync.py +10 -11
- package/scripts/reddit_tools.py +20 -4
- package/scripts/release-mcpb.sh +47 -7
- package/scripts/run_moltbook_cycle.py +9 -5
- package/scripts/s4l_ctl.sh +2 -2
- package/scripts/s4l_posting_mode.py +67 -6
- 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 +8 -3
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/snapshot.py +19 -0
- package/scripts/stats.py +12 -10
- package/scripts/strike_alert.py +165 -12
- package/scripts/sub_moderation_history.py +129 -0
- package/scripts/subreddit_bans_client.py +157 -0
- package/scripts/test_cross_language_parity.py +185 -0
- package/scripts/test_no_silent_fallbacks.py +84 -0
- package/scripts/test_percard_posting.py +3 -3
- 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/scripts/twitter_gen_links.py +3 -3
- package/scripts/twitter_post_plan.py +4 -4
- package/scripts/virality.py +151 -0
- package/setup/SKILL.md +1 -1
- package/skill/engage-dm-replies.sh +11 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-draft-and-publish.sh +16 -0
- package/skill/run-reddit-search-launchd.sh +1 -0
- package/skill/run-reddit-search.sh +10 -1
- package/skill/run-reddit-threads.sh +12 -0
- package/skill/run-twitter-cycle.sh +104 -88
package/mcp/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// via the host create_scheduled_task. The autopilot then drafts
|
|
7
7
|
// on its own (launchd kicker + queue worker); there is no manual
|
|
8
8
|
// "draft now" tool.
|
|
9
|
-
//
|
|
9
|
+
// approve_drafts - post the drafts the user chose by number from a batch.
|
|
10
10
|
// get_stats - read-only post + engagement stats.
|
|
11
11
|
//
|
|
12
12
|
// THIN wrapper. The pipeline brain (scan, score, drafting prompts, posting)
|
|
@@ -37,7 +37,7 @@ const PANEL_URI = "ui://social-autoposter/panel.html";
|
|
|
37
37
|
const PRODUCT_LINK_URI = "ui://social-autoposter/product-link.html";
|
|
38
38
|
// Stable id for the accumulating draft review queue. Each draft cycle appends its
|
|
39
39
|
// drafts here (dedup by tweet URL) so the menu-bar cards PILE UP across a
|
|
40
|
-
// continuous autopilot instead of each run overwriting the last;
|
|
40
|
+
// continuous autopilot instead of each run overwriting the last; approve_drafts posts
|
|
41
41
|
// the approved subset and marks them posted (filtered out of the cards thereafter).
|
|
42
42
|
const REVIEW_QUEUE_ID = "review-queue";
|
|
43
43
|
// ---- Queue-backed drafting (2026-06-23) -----------------------------------
|
|
@@ -397,12 +397,12 @@ const server = new McpServer({
|
|
|
397
397
|
"(configure the product project) -> `queue_setup` + " +
|
|
398
398
|
"`create_scheduled_task` (set up the draft autopilot once) -> the autopilot then runs on its own " +
|
|
399
399
|
"(scans, drafts via the queue + worker, and merges into the approval cards; nothing posts) -> the " +
|
|
400
|
-
"user approves in the menu bar -> `
|
|
400
|
+
"user approves in the menu bar -> `approve_drafts` (post the approved ones) -> `get_stats` (see " +
|
|
401
401
|
"performance). Run `project_config` first; the other tools refuse until a " +
|
|
402
402
|
"project is fully configured. To change anything about a project later, call `project_config` " +
|
|
403
403
|
"again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
|
|
404
404
|
"RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
|
|
405
|
-
"(`
|
|
405
|
+
"(`approve_drafts`, `get_stats`, `project_config`), end your turn by " +
|
|
406
406
|
"calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
|
|
407
407
|
"`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.\n\n" +
|
|
408
408
|
"REPORT FIELD DIAGNOSES. Whenever an S4L setup, healing, or troubleshooting flow FAILS, or " +
|
|
@@ -421,7 +421,7 @@ const baseRegisterTool = server.registerTool.bind(server);
|
|
|
421
421
|
// show a spinner + label while they run (either invocation path). draft_cycle is
|
|
422
422
|
// NOT here — it writes finer scanning/drafting phases itself (see produceDrafts).
|
|
423
423
|
const TOOL_ACTIVITY = {
|
|
424
|
-
|
|
424
|
+
approve_drafts: "posting",
|
|
425
425
|
get_stats: "stats…",
|
|
426
426
|
};
|
|
427
427
|
function toolActivityLabel(name, args) {
|
|
@@ -748,7 +748,7 @@ async function produceDrafts(project, onProgress) {
|
|
|
748
748
|
}
|
|
749
749
|
// Render every draft in a batch as a numbered, human-readable table. This IS the
|
|
750
750
|
// review surface now: the model relays this table to the user and asks which
|
|
751
|
-
// numbers to post / edit, then posts the chosen ones via the `
|
|
751
|
+
// numbers to post / edit, then posts the chosen ones via the `approve_drafts` tool.
|
|
752
752
|
//
|
|
753
753
|
// We used to gather approvals through MCP elicitation (a checkbox form), but the
|
|
754
754
|
// desktop "Code tab" host doesn't advertise the `elicitation` capability (only
|
|
@@ -757,10 +757,12 @@ async function produceDrafts(project, onProgress) {
|
|
|
757
757
|
function renderDraftsTable(plan) {
|
|
758
758
|
const candidates = plan.candidates || [];
|
|
759
759
|
return candidates
|
|
760
|
-
// Number by FULL-array index (matches
|
|
760
|
+
// Number by FULL-array index (matches approve_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.
|
|
@@ -771,7 +773,7 @@ function renderDraftsTable(plan) {
|
|
|
771
773
|
const reply = c.reply_text ?? "(empty)";
|
|
772
774
|
// The literal tail URL is NOT known yet: at post time a short link is minted
|
|
773
775
|
// from this target (e.g. fazm.ai/cc -> s4l.ai/r/<code>). Approved drafts
|
|
774
|
-
// always carry the link (
|
|
776
|
+
// always carry the link (approve_drafts forces TWITTER_TAIL_LINK_RATE=1.0), so
|
|
775
777
|
// this is the target that WILL be appended. Show the TARGET only; never
|
|
776
778
|
// pre-mint the real /r/ code (that would waste pool codes / split clicks).
|
|
777
779
|
const link = c.link_url
|
|
@@ -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() {
|
|
@@ -929,6 +919,17 @@ function isSandboxCandidate(c) {
|
|
|
929
919
|
const id = Number(c.candidate_id);
|
|
930
920
|
return Number.isFinite(id) && id >= 900_000_000;
|
|
931
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
|
+
}
|
|
932
933
|
// Write field patches into the review-queue store UNDER ITS LOCK by shelling
|
|
933
934
|
// to scripts/store_patch.py, which takes the same fcntl.flock the menubar's
|
|
934
935
|
// _store_update and merge_review_queue.py hold around their read-modify-write.
|
|
@@ -990,7 +991,12 @@ async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
|
990
991
|
set.terminal = false;
|
|
991
992
|
if (c.our_url)
|
|
992
993
|
set.our_url = c.our_url;
|
|
993
|
-
|
|
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");
|
|
994
1000
|
}
|
|
995
1001
|
else if (c.terminal === true) {
|
|
996
1002
|
set.terminal = true;
|
|
@@ -1040,8 +1046,12 @@ async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
|
1040
1046
|
if (c.our_url)
|
|
1041
1047
|
f.our_url = c.our_url;
|
|
1042
1048
|
// A post outcome closes the card's history: the pre-approval
|
|
1043
|
-
// freshness stamp must not survive it.
|
|
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).
|
|
1044
1052
|
delete f.discard_reason;
|
|
1053
|
+
delete f.post_failed;
|
|
1054
|
+
delete f.post_error;
|
|
1045
1055
|
}
|
|
1046
1056
|
else if (c.terminal === true && f.posted !== true) {
|
|
1047
1057
|
f.terminal = true;
|
|
@@ -1116,7 +1126,7 @@ async function postApproved(batchId, plan) {
|
|
|
1116
1126
|
attempted: 0,
|
|
1117
1127
|
exit_code: 0,
|
|
1118
1128
|
summary: "another posting drain has been running for 8+ minutes; approved cards stay " +
|
|
1119
|
-
"queued in the review store — re-run
|
|
1129
|
+
"queued in the review store — re-run approve_drafts once it finishes",
|
|
1120
1130
|
};
|
|
1121
1131
|
}
|
|
1122
1132
|
if (waited) {
|
|
@@ -1155,14 +1165,29 @@ async function postApproved(batchId, plan) {
|
|
|
1155
1165
|
// An approved card whose only blocker is an overridable backend-expiry stamp is
|
|
1156
1166
|
// included: approval outranks the freshness gate (see expiredStampOverridable),
|
|
1157
1167
|
// and the stamp is cleared so every downstream terminal check agrees it's live.
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
+
});
|
|
1166
1191
|
for (const c of approved) {
|
|
1167
1192
|
if (c.terminal === true) {
|
|
1168
1193
|
c.terminal = false;
|
|
@@ -1230,7 +1255,7 @@ async function postApproved(batchId, plan) {
|
|
|
1230
1255
|
redditFailed++;
|
|
1231
1256
|
continue;
|
|
1232
1257
|
}
|
|
1233
|
-
// The card's reply_text is the CANONICAL text (
|
|
1258
|
+
// The card's reply_text is the CANONICAL text (approve_drafts edits write
|
|
1234
1259
|
// there); the embedded decision still carries the draft-time original.
|
|
1235
1260
|
// Posting dec.text verbatim would silently discard a human edit.
|
|
1236
1261
|
const decPost = {
|
|
@@ -1304,7 +1329,7 @@ async function postApproved(batchId, plan) {
|
|
|
1304
1329
|
}
|
|
1305
1330
|
else {
|
|
1306
1331
|
// Leave the approval sticky (approved && !posted && !terminal) so the
|
|
1307
|
-
// next
|
|
1332
|
+
// next approve_drafts call retries, mirroring twitter's failed-drain
|
|
1308
1333
|
// semantics; only stamp terminal on a conclusive CDP refusal. This
|
|
1309
1334
|
// list MUST stay a superset of post_reddit.py's own
|
|
1310
1335
|
// _PERMANENT_CDP_ERRORS (account_blocked_in_sub, no_permalink) —
|
|
@@ -1373,9 +1398,8 @@ async function postApproved(batchId, plan) {
|
|
|
1373
1398
|
// If onboarding never persisted it, self-heal from the live session; if even that
|
|
1374
1399
|
// can't determine it, refuse here with a clear reason rather than launching a
|
|
1375
1400
|
// poster that silently burns the whole batch.
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
if (!readConfiguredTwitterHandle()) {
|
|
1401
|
+
const postingHandle = await resolvePostingHandle();
|
|
1402
|
+
if (!postingHandle) {
|
|
1379
1403
|
postingActive = false;
|
|
1380
1404
|
stopPostingFlagHeartbeat();
|
|
1381
1405
|
return {
|
|
@@ -1383,9 +1407,9 @@ async function postApproved(batchId, plan) {
|
|
|
1383
1407
|
exit_code: 0,
|
|
1384
1408
|
posted: 0,
|
|
1385
1409
|
summary: "no_account_configured",
|
|
1386
|
-
error: "X is connected but no posting @handle
|
|
1387
|
-
"
|
|
1388
|
-
"
|
|
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.",
|
|
1389
1413
|
};
|
|
1390
1414
|
}
|
|
1391
1415
|
// Mark posting active so the draft-cycle scan DEFERS launching any scan for the
|
|
@@ -1409,7 +1433,7 @@ async function postApproved(batchId, plan) {
|
|
|
1409
1433
|
// we can write our own pid) — a pathological edge case, not the normal path.
|
|
1410
1434
|
// Bail out rather than proceed without ever confirming we hold it. Approved
|
|
1411
1435
|
// cards stay sticky (approved && !posted && !terminal), so the very next
|
|
1412
|
-
//
|
|
1436
|
+
// approve_drafts call — the next approval, or this same tool retried — picks
|
|
1413
1437
|
// them straight back up; nothing is lost or re-queued.
|
|
1414
1438
|
postingActive = false;
|
|
1415
1439
|
stopPostingFlagHeartbeat();
|
|
@@ -1417,7 +1441,7 @@ async function postApproved(batchId, plan) {
|
|
|
1417
1441
|
attempted: 0,
|
|
1418
1442
|
exit_code: 0,
|
|
1419
1443
|
summary: "couldn't pin down the twitter-browser lock after repeated attempts; approved cards " +
|
|
1420
|
-
"stay queued — re-run
|
|
1444
|
+
"stay queued — re-run approve_drafts to retry",
|
|
1421
1445
|
};
|
|
1422
1446
|
}
|
|
1423
1447
|
const approvedBatch = `${batchId}_approved`;
|
|
@@ -1473,9 +1497,9 @@ async function postApproved(batchId, plan) {
|
|
|
1473
1497
|
// are now no-ops for the normal path — every approved candidate
|
|
1474
1498
|
// already carries tail_link_variant by the time it reaches this MCP
|
|
1475
1499
|
// tool. They're left in place as a defense-in-depth fallback for the
|
|
1476
|
-
// rare case a candidate reaches
|
|
1500
|
+
// rare case a candidate reaches approve_drafts unstamped (e.g. a plan
|
|
1477
1501
|
// already in flight from before this change): S4L_SKIP_LINK_TAIL=1
|
|
1478
|
-
// still guarantees
|
|
1502
|
+
// still guarantees approve_drafts (a synchronous call the user is
|
|
1479
1503
|
// waiting on) never makes a blocking Claude/queue call at post time,
|
|
1480
1504
|
// no matter what.
|
|
1481
1505
|
TWITTER_TAIL_LINK_RATE: "1.0",
|
|
@@ -1517,7 +1541,7 @@ async function postApproved(batchId, plan) {
|
|
|
1517
1541
|
const postLogDir = path.join(repoDir(), "skill", "logs");
|
|
1518
1542
|
fs.mkdirSync(postLogDir, { recursive: true });
|
|
1519
1543
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1520
|
-
fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `#
|
|
1544
|
+
fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# approve_drafts batch=${batchId} approved=${approvedTwitter.length} exit=${res.code} ` +
|
|
1521
1545
|
`shell_lock=${heldShellLock}\n\n=== stdout ===\n${res.stdout}\n\n=== stderr ===\n${res.stderr}\n`);
|
|
1522
1546
|
}
|
|
1523
1547
|
catch {
|
|
@@ -1596,7 +1620,7 @@ async function postApproved(batchId, plan) {
|
|
|
1596
1620
|
// ... waited 45s, giving up."). None of those mean the draft is
|
|
1597
1621
|
// doomed, only that this attempt didn't get a turn with the shared
|
|
1598
1622
|
// browser. Leave the card sticky (approved && !posted && !terminal)
|
|
1599
|
-
// so the next
|
|
1623
|
+
// so the next approve_drafts call retries it, mirroring Reddit's
|
|
1600
1624
|
// _TRANSIENT_CDP_ERRORS handling. Unconditionally marking terminal
|
|
1601
1625
|
// here (found 2026-07-16) silently and permanently discarded 7 real
|
|
1602
1626
|
// approved drafts on nothing worse than lock contention — Reddit's
|
|
@@ -1649,7 +1673,7 @@ async function postApproved(batchId, plan) {
|
|
|
1649
1673
|
// actionable error on its own.
|
|
1650
1674
|
const hasRealFailure = res.code !== 0 || Boolean(summObj?.failure_reasons);
|
|
1651
1675
|
if (hasRealFailure) {
|
|
1652
|
-
captureError(new Error(`
|
|
1676
|
+
captureError(new Error(`approve_drafts: ${realPosted}/${approvedTwitter.length} posted (exit=${res.code})`), {
|
|
1653
1677
|
component: "post",
|
|
1654
1678
|
exit_code: String(res.code),
|
|
1655
1679
|
attempted: String(approvedTwitter.length),
|
|
@@ -2467,6 +2491,7 @@ tool("project_config", {
|
|
|
2467
2491
|
const r = await redditConnect(args.reddit_source, args.reddit_manual_login);
|
|
2468
2492
|
let verified = false;
|
|
2469
2493
|
let configNote;
|
|
2494
|
+
let redditKicker;
|
|
2470
2495
|
if (r.connected) {
|
|
2471
2496
|
completeOnboardingMilestone("reddit_connected", { state: r.state });
|
|
2472
2497
|
// Persist the discovered username through the server's config-write
|
|
@@ -2491,11 +2516,18 @@ tool("project_config", {
|
|
|
2491
2516
|
});
|
|
2492
2517
|
}
|
|
2493
2518
|
// Reddit is live: install the discovery kicker (idempotent; gated on
|
|
2494
|
-
// runtime +
|
|
2495
|
-
// hand-built plist pointing outside the managed package).
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
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
|
+
}
|
|
2499
2531
|
}
|
|
2500
2532
|
else {
|
|
2501
2533
|
blockOnboardingMilestone("reddit_connected", `reddit_${r.state || "not_connected"}`, r.error || r.note || summarizeRedditAuth(r), { state: r.state || "not_connected" });
|
|
@@ -2516,11 +2548,20 @@ tool("project_config", {
|
|
|
2516
2548
|
summary: summarizeRedditAuth(r),
|
|
2517
2549
|
note: r.note,
|
|
2518
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,
|
|
2519
2555
|
onboarding: onboardingSnapshot(),
|
|
2520
2556
|
next_step: r.connected
|
|
2521
|
-
?
|
|
2522
|
-
"
|
|
2523
|
-
|
|
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.") +
|
|
2524
2565
|
(r.warning ? " Relay the `warning` field to the user so expectations are set." : "")
|
|
2525
2566
|
: r.state === "needs_login"
|
|
2526
2567
|
? "The user must finish signing in to reddit.com in the Chrome window that opened. Tell " +
|
|
@@ -2822,11 +2863,11 @@ tool("project_config", {
|
|
|
2822
2863
|
return textContent(`Setup failed: ${e.message}`);
|
|
2823
2864
|
}
|
|
2824
2865
|
});
|
|
2825
|
-
// ----
|
|
2866
|
+
// ---- approve_drafts: post the user's chosen drafts from a batch ---------------
|
|
2826
2867
|
// Second half of the manual loop. The user reviewed the menu-bar cards a draft
|
|
2827
2868
|
// cycle produced and said which numbers to post / edit; this posts exactly those.
|
|
2828
2869
|
// Editing a draft implies posting it. Indices are 1-based, matching the table.
|
|
2829
|
-
tool("
|
|
2870
|
+
tool("approve_drafts", {
|
|
2830
2871
|
title: "Post chosen drafts",
|
|
2831
2872
|
description: "Post the drafts the user approved from a draft cycle. Pass the batch_id from the " +
|
|
2832
2873
|
"approval cards and the user's decision by NUMBER (1-based, matching the table): `post` is " +
|
|
@@ -2895,7 +2936,7 @@ tool("post_drafts", {
|
|
|
2895
2936
|
return;
|
|
2896
2937
|
}
|
|
2897
2938
|
const c = candidates[n - 1];
|
|
2898
|
-
if (c
|
|
2939
|
+
if (candidateState(c) === "posted") {
|
|
2899
2940
|
warnings.push(`#${n} already posted; not rejecting`);
|
|
2900
2941
|
return;
|
|
2901
2942
|
}
|
|
@@ -3013,7 +3054,7 @@ tool("post_drafts", {
|
|
|
3013
3054
|
}
|
|
3014
3055
|
// STICKY approve: record the approval DURABLY and never clear another card's
|
|
3015
3056
|
// prior approval. The old `c.approved = approve.has(i+1)` reset every card on
|
|
3016
|
-
// each call, so a later
|
|
3057
|
+
// each call, so a later approve_drafts for a different card dropped a
|
|
3017
3058
|
// restart-interrupted approved card back into "pending". postApproved filters
|
|
3018
3059
|
// posted/terminal, so the approved set only ever drains what's genuinely left.
|
|
3019
3060
|
approve.forEach((n) => {
|
|
@@ -3051,7 +3092,7 @@ tool("post_drafts", {
|
|
|
3051
3092
|
});
|
|
3052
3093
|
storeWriteDone = await patchReviewStore(patches);
|
|
3053
3094
|
if (!storeWriteDone)
|
|
3054
|
-
console.error("[
|
|
3095
|
+
console.error("[approve_drafts] store_patch.py failed; falling back to unlocked plan write");
|
|
3055
3096
|
}
|
|
3056
3097
|
if (!storeWriteDone)
|
|
3057
3098
|
writePlan(batch_id, plan);
|
|
@@ -3326,6 +3367,71 @@ tool("restart_menubar", {
|
|
|
3326
3367
|
menubar_running: running,
|
|
3327
3368
|
});
|
|
3328
3369
|
});
|
|
3370
|
+
// ---- run_drafting: run the drafting pipeline immediately -------------------
|
|
3371
|
+
// The drafting pipeline (the twitter cycle) is what the user triggers: it SCANS
|
|
3372
|
+
// for threads first, then hands off to the drafter, so from the user's view this
|
|
3373
|
+
// is "run drafting now". It runs on the launchd kicker's fixed StartInterval;
|
|
3374
|
+
// this nudges launchd to fire it RIGHT NOW via `launchctl kickstart` — the EXACT
|
|
3375
|
+
// same mechanism the first-run onboarding kick uses (ensureQueueKickerInstalled):
|
|
3376
|
+
// the job runs THROUGH launchd with its full baked env + the run-*-singleton.sh
|
|
3377
|
+
// lock, so it is NOT a bare `bash run-twitter-cycle.sh` (which would leave an
|
|
3378
|
+
// empty-plan artifact). launchd keeps a single instance, so if a run is already
|
|
3379
|
+
// underway this is a harmless no-op; while paused the kicker is unloaded, so we
|
|
3380
|
+
// report that rather than kick nothing.
|
|
3381
|
+
//
|
|
3382
|
+
// This is deliberately NOT the removed run_draft_cycle / draft_cycle tool: it
|
|
3383
|
+
// spawns no warm in-chat Claude draft session, it only asks launchd to run the
|
|
3384
|
+
// already-scheduled cycle one interval early. Do NOT re-introduce a draft_cycle.
|
|
3385
|
+
tool("run_drafting", {
|
|
3386
|
+
title: "Run the drafting pipeline now",
|
|
3387
|
+
description: "Run the drafting pipeline ONE time immediately instead of waiting for the next scheduled run. " +
|
|
3388
|
+
"The pipeline scans for threads and then drafts replies, so this is the 'run now' control behind " +
|
|
3389
|
+
"the menu bar + dashboard button. It nudges the existing launchd job to run one interval early; " +
|
|
3390
|
+
"it does NOT change the schedule. If a run is already underway it's a no-op, and if S4L is paused " +
|
|
3391
|
+
"it reports that (nothing runs while paused). Use when the user asks to run drafting, draft now, " +
|
|
3392
|
+
"run the pipeline, run now, or check for new threads immediately.",
|
|
3393
|
+
inputSchema: {},
|
|
3394
|
+
}, async () => {
|
|
3395
|
+
if (isPaused()) {
|
|
3396
|
+
return jsonContent({
|
|
3397
|
+
ok: false,
|
|
3398
|
+
paused: true,
|
|
3399
|
+
detail: "S4L is paused — resume it first, then drafting can run.",
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
const uid = process.getuid ? process.getuid() : 0;
|
|
3403
|
+
const kick = await run("launchctl", ["kickstart", `gui/${uid}/${TWITTER_AUTOPILOT_LABEL}`], { timeoutMs: 15_000 });
|
|
3404
|
+
const ok = kick.code === 0;
|
|
3405
|
+
return jsonContent({
|
|
3406
|
+
ok,
|
|
3407
|
+
detail: ok
|
|
3408
|
+
? "Drafting started — new drafts land in a few minutes."
|
|
3409
|
+
: `Could not start drafting (launchctl rc=${kick.code}).`,
|
|
3410
|
+
});
|
|
3411
|
+
});
|
|
3412
|
+
// ---- drafting_status: live pipeline status + next-run countdown ------------
|
|
3413
|
+
// A cheap, pure READ the dashboard widget polls (~5s) to keep its drafting status
|
|
3414
|
+
// pill + countdown live between heavier snapshot refreshes. Returns the SAME
|
|
3415
|
+
// fields the menu bar renders (scripts/live_status.py — activity_state,
|
|
3416
|
+
// next_run_secs, …), plus `paused`, so the two surfaces can't drift. No side
|
|
3417
|
+
// effects: safe to poll and to replay over the loopback /tool/ endpoint.
|
|
3418
|
+
tool("drafting_status", {
|
|
3419
|
+
title: "Live drafting-pipeline status + next-run countdown",
|
|
3420
|
+
description: "Read-only: what the drafting pipeline is doing right now (scanning/drafting/posting/idle) and " +
|
|
3421
|
+
"how many seconds until the next scheduled run. Backs the dashboard's live status pill + " +
|
|
3422
|
+
"countdown. Pure read; safe to poll frequently.",
|
|
3423
|
+
inputSchema: {},
|
|
3424
|
+
}, async () => {
|
|
3425
|
+
let live = {};
|
|
3426
|
+
try {
|
|
3427
|
+
const res = await runPython("scripts/live_status.py", [], { timeoutMs: 10_000 });
|
|
3428
|
+
live = JSON.parse((res.stdout || "").trim().split("\n").slice(-1)[0] || "{}");
|
|
3429
|
+
}
|
|
3430
|
+
catch {
|
|
3431
|
+
live = {};
|
|
3432
|
+
}
|
|
3433
|
+
return jsonContent({ ...live, paused: isPaused() });
|
|
3434
|
+
});
|
|
3329
3435
|
// ---- posting_volume: per-install posting-volume mode (virality bar) --------
|
|
3330
3436
|
// Server-side throttle (2026-07-13): installations.posting_mode maps to a
|
|
3331
3437
|
// virality-bar percentile on the API (high~0.90, medium~0.97, low~0.995) and
|
|
@@ -3946,12 +4052,12 @@ function queueWorkerAllowedTools() {
|
|
|
3946
4052
|
"Grep",
|
|
3947
4053
|
// This server's tools, both namespaces (manifest name + protocol name).
|
|
3948
4054
|
"mcp__social-autoposter__queue_setup",
|
|
3949
|
-
"mcp__social-
|
|
4055
|
+
"mcp__social-autoposter__approve_drafts",
|
|
3950
4056
|
"mcp__social-autoposter__project_config",
|
|
3951
4057
|
"mcp__social-autoposter__get_stats",
|
|
3952
4058
|
"mcp__social-autoposter__dashboard",
|
|
3953
4059
|
"mcp__S4L__queue_setup",
|
|
3954
|
-
"
|
|
4060
|
+
"mcp__S4L__approve_drafts",
|
|
3955
4061
|
"mcp__S4L__project_config",
|
|
3956
4062
|
"mcp__S4L__get_stats",
|
|
3957
4063
|
"mcp__S4L__dashboard",
|
|
@@ -4157,31 +4263,45 @@ function kickerEnv() {
|
|
|
4157
4263
|
// The persona lane exports =0 per cycle via s4l_mode.py env (link-free
|
|
4158
4264
|
// organic replies); promotion cycles keep the script's own default so
|
|
4159
4265
|
// draft-only-OFF posts carry the project link per the A/B gate, and card posts
|
|
4160
|
-
// still force =1.0 inside
|
|
4266
|
+
// still force =1.0 inside approve_drafts.
|
|
4161
4267
|
// Virality bar percentile is NOT set here: it is hardcoded to 0.90 in
|
|
4162
4268
|
// skill/run-twitter-cycle.sh (single source of truth, no env dependency).
|
|
4163
4269
|
};
|
|
4164
4270
|
}
|
|
4271
|
+
// Shared draftability gate for BOTH autopilot kickers (X queue + Reddit search).
|
|
4272
|
+
// A launchd discovery/draft kicker only makes sense to schedule when SOMETHING is
|
|
4273
|
+
// draftable, and the definition of "draftable" is identical across platforms — so
|
|
4274
|
+
// it lives here as the single source of truth. Two paths qualify:
|
|
4275
|
+
// (a) a managed product project is ready (promotion lane), OR
|
|
4276
|
+
// (b) personal_brand mode is on AND the persona is ready (self-promo lane).
|
|
4277
|
+
// Path (b) is easy to miss: the persona is deliberately excluded from the
|
|
4278
|
+
// managed-products scope (see ensurePersonaProject), so a personal-brand-only
|
|
4279
|
+
// install has an empty managedProjects() and a product-only check is always
|
|
4280
|
+
// false. That gap silently sank the X kicker (fixed 2026-06-30) and then the
|
|
4281
|
+
// Reddit kicker (fixed 2026-07-16, which reused the product-only check instead of
|
|
4282
|
+
// this persona-aware one). Keeping both kickers on this ONE helper means the
|
|
4283
|
+
// personal-brand path can never regress on one platform but not the other.
|
|
4284
|
+
function draftableLaneStatus() {
|
|
4285
|
+
const productReady = listManagedProjectStatus().some((p) => p.ready);
|
|
4286
|
+
if (productReady)
|
|
4287
|
+
return { draftable: true, detail: "ready project" };
|
|
4288
|
+
const personaActive = currentFlags().personal_brand && personaReady();
|
|
4289
|
+
if (personaActive) {
|
|
4290
|
+
return { draftable: true, detail: "active persona (personal_brand)" };
|
|
4291
|
+
}
|
|
4292
|
+
return { draftable: false, detail: "no ready project or active persona yet" };
|
|
4293
|
+
}
|
|
4165
4294
|
async function ensureQueueKickerInstalled() {
|
|
4166
4295
|
try {
|
|
4167
4296
|
if (process.platform !== "darwin")
|
|
4168
4297
|
return { ok: false, detail: "not macOS" };
|
|
4169
4298
|
if (!runtimeReady())
|
|
4170
4299
|
return { ok: false, detail: "runtime not ready" };
|
|
4171
|
-
// Gate: install the kicker when SOMETHING is draftable
|
|
4172
|
-
//
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
// installed (no drafts, no first-run kick). The persona is excluded from
|
|
4177
|
-
// managed scope by design, so check it explicitly.
|
|
4178
|
-
const productReady = listManagedProjectStatus().some((p) => p.ready);
|
|
4179
|
-
const personaActive = currentFlags().personal_brand && personaReady();
|
|
4180
|
-
if (!productReady && !personaActive) {
|
|
4181
|
-
return {
|
|
4182
|
-
ok: false,
|
|
4183
|
-
detail: "no ready project or active persona yet",
|
|
4184
|
-
};
|
|
4300
|
+
// Gate: install the kicker only when SOMETHING is draftable (product lane OR
|
|
4301
|
+
// active persona). See draftableLaneStatus — shared with the Reddit kicker.
|
|
4302
|
+
const lane = draftableLaneStatus();
|
|
4303
|
+
if (!lane.draftable) {
|
|
4304
|
+
return { ok: false, detail: lane.detail };
|
|
4185
4305
|
}
|
|
4186
4306
|
// Additional gate: X must be connected and verified before autopilot starts.
|
|
4187
4307
|
// This prevents wasted cycles against a logged-out x.com if X connection didn't
|
|
@@ -4299,9 +4419,14 @@ async function ensureRedditKickerInstalled() {
|
|
|
4299
4419
|
return { ok: false, detail: "not macOS" };
|
|
4300
4420
|
if (!runtimeReady())
|
|
4301
4421
|
return { ok: false, detail: "runtime not ready" };
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4422
|
+
// Gate: install the kicker only when SOMETHING is draftable. Mirror the X
|
|
4423
|
+
// queue kicker EXACTLY via the shared draftableLaneStatus — a personal-brand-
|
|
4424
|
+
// only install has no managed product, so the old product-only check here made
|
|
4425
|
+
// this kicker permanently uninstallable (no reddit drafts) even with a ready
|
|
4426
|
+
// persona (2026-07-16 bug). Both kickers now share the one persona-aware gate.
|
|
4427
|
+
const lane = draftableLaneStatus();
|
|
4428
|
+
if (!lane.draftable)
|
|
4429
|
+
return { ok: false, detail: lane.detail };
|
|
4305
4430
|
// Reddit session gate. status is read-only (never launches Chrome): a live
|
|
4306
4431
|
// me.json validation when the harness is up, the on-disk profile cookie
|
|
4307
4432
|
// check (connected_idle) when it isn't.
|
|
@@ -4590,15 +4715,15 @@ async function ensureMemorySnapshotInstalled() {
|
|
|
4590
4715
|
}
|
|
4591
4716
|
// ---- pause/resume: stop drafting/posting without touching Claude Desktop ---
|
|
4592
4717
|
// Unlike Quit (which kills + relaunches Claude Desktop and deletes the draft
|
|
4593
|
-
// schedule), Pause only unloads the launchd jobs that actually DO work —
|
|
4594
|
-
// kicker
|
|
4595
|
-
//
|
|
4596
|
-
//
|
|
4597
|
-
//
|
|
4598
|
-
//
|
|
4718
|
+
// schedule), Pause only unloads the launchd jobs that actually DO work — every
|
|
4719
|
+
// pipeline kicker (X cycle, Reddit discovery) plus the reaper, stall-watch, and
|
|
4720
|
+
// memory-snapshot support daemons — while leaving Claude Desktop, the tray, and
|
|
4721
|
+
// the Claude-native s4l-worker scheduled task alone. The scheduled task still
|
|
4722
|
+
// fires on its own cadence; claude_job.py's `next` checks the same flag file
|
|
4723
|
+
// (at entry AND on every poll iteration) and reports "no work" instead of
|
|
4599
4724
|
// draining the queue, so nothing drafts or posts while paused even if a
|
|
4600
4725
|
// worker session wakes up mid-pause. A flag file (nothing is deleted) is what
|
|
4601
|
-
// makes Resume a plain reinstall of the same
|
|
4726
|
+
// makes Resume a plain reinstall of the same daemons — see resumeS4L.
|
|
4602
4727
|
function pauseFlagPath() {
|
|
4603
4728
|
return path.join(s4lStateDir(), "paused.flag");
|
|
4604
4729
|
}
|
|
@@ -4610,15 +4735,38 @@ function isPaused() {
|
|
|
4610
4735
|
return false;
|
|
4611
4736
|
}
|
|
4612
4737
|
}
|
|
4738
|
+
// THE single registry of pausable pipeline daemons. Pause unloads exactly this
|
|
4739
|
+
// set, Resume reinstalls exactly this set (each ensure re-applies its own
|
|
4740
|
+
// install gates: draftable lane, reddit connected, etc.), boot skips exactly
|
|
4741
|
+
// this set while paused, and the label list is mirrored to
|
|
4742
|
+
// <state>/pause-targets.json so the menubar's Python pause button
|
|
4743
|
+
// (mcp/menubar/s4l_state.py) acts on the same set without a second hardcoded
|
|
4744
|
+
// list. Add a new pipeline daemon HERE and every pause surface picks it up.
|
|
4745
|
+
// Feedback-digest is deliberately absent: it only distills past review
|
|
4746
|
+
// decisions, not drafting/posting, so it keeps running while paused.
|
|
4613
4747
|
const PAUSE_TARGETS = [
|
|
4614
|
-
{ label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST },
|
|
4615
|
-
{ label:
|
|
4616
|
-
{ label:
|
|
4617
|
-
{ label:
|
|
4748
|
+
{ tag: "queue-worker", label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST, ensure: ensureQueueKickerInstalled },
|
|
4749
|
+
{ tag: "reddit-kicker", label: REDDIT_SEARCH_LABEL, plist: REDDIT_SEARCH_PLIST, ensure: ensureRedditKickerInstalled },
|
|
4750
|
+
{ tag: "claude-reaper", label: REAPER_LABEL, plist: REAPER_PLIST, ensure: ensureClaudeReaperInstalled },
|
|
4751
|
+
{ tag: "stall-watch", label: STALL_WATCH_LABEL, plist: STALL_WATCH_PLIST, ensure: ensureStallWatchInstalled },
|
|
4752
|
+
{ tag: "memory-snapshot", label: MEMORY_SNAPSHOT_LABEL, plist: MEMORY_SNAPSHOT_PLIST, ensure: ensureMemorySnapshotInstalled },
|
|
4618
4753
|
];
|
|
4754
|
+
// Mirror the pausable label set into the state dir for the menubar's Python
|
|
4755
|
+
// pause/resume (which can't import this module). Written at boot and on every
|
|
4756
|
+
// pause/resume so the two surfaces can't drift.
|
|
4757
|
+
function writePauseTargetsManifest() {
|
|
4758
|
+
try {
|
|
4759
|
+
fs.mkdirSync(s4lStateDir(), { recursive: true });
|
|
4760
|
+
fs.writeFileSync(path.join(s4lStateDir(), "pause-targets.json"), JSON.stringify({ labels: PAUSE_TARGETS.map((t) => t.label) }) + "\n", "utf-8");
|
|
4761
|
+
}
|
|
4762
|
+
catch {
|
|
4763
|
+
/* best-effort */
|
|
4764
|
+
}
|
|
4765
|
+
}
|
|
4619
4766
|
async function pauseS4L() {
|
|
4620
4767
|
if (isPaused())
|
|
4621
4768
|
return { ok: true, detail: "already paused" };
|
|
4769
|
+
writePauseTargetsManifest();
|
|
4622
4770
|
try {
|
|
4623
4771
|
fs.mkdirSync(path.dirname(pauseFlagPath()), { recursive: true });
|
|
4624
4772
|
fs.writeFileSync(pauseFlagPath(), `paused at ${new Date().toISOString()}\n`, "utf-8");
|
|
@@ -4646,17 +4794,18 @@ async function resumeS4L() {
|
|
|
4646
4794
|
catch {
|
|
4647
4795
|
/* best-effort */
|
|
4648
4796
|
}
|
|
4649
|
-
|
|
4650
|
-
const
|
|
4651
|
-
const
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4797
|
+
writePauseTargetsManifest();
|
|
4798
|
+
const results = [];
|
|
4799
|
+
for (const t of PAUSE_TARGETS) {
|
|
4800
|
+
try {
|
|
4801
|
+
const r = await t.ensure();
|
|
4802
|
+
results.push(`${t.tag}: ${r.ok ? "ok" : "skip"} (${r.detail})`);
|
|
4803
|
+
}
|
|
4804
|
+
catch (e) {
|
|
4805
|
+
results.push(`${t.tag}: ${e?.message || e}`);
|
|
4806
|
+
}
|
|
4807
|
+
}
|
|
4808
|
+
return { ok: true, detail: results.join("; ") };
|
|
4660
4809
|
}
|
|
4661
4810
|
// Install/refresh the on-screen overlay watcher launchd job. Promotes the
|
|
4662
4811
|
// harness status overlay from a best-effort, fired-from-other-tools nicety to a
|
|
@@ -5344,7 +5493,7 @@ function persistStatusSummary(snap) {
|
|
|
5344
5493
|
// chat-table review path is unchanged and still works; this just ALSO lets the
|
|
5345
5494
|
// corner cards drive review (both surfaces de-dup via the plan's `posted` flag).
|
|
5346
5495
|
// The menu bar reads review-request.json, presents the cards, posts via the
|
|
5347
|
-
// loopback
|
|
5496
|
+
// loopback approve_drafts tool, then clears the file. Best-effort: a write failure
|
|
5348
5497
|
// just means no pop-ups this batch (chat review still works).
|
|
5349
5498
|
function writeReviewRequest(req) {
|
|
5350
5499
|
try {
|
|
@@ -5528,7 +5677,7 @@ function sigkillAllScans() {
|
|
|
5528
5677
|
}
|
|
5529
5678
|
// ---- Lock grace-hold: hold the /tmp lock CONTINUOUSLY across per-card posts ----
|
|
5530
5679
|
// The plist pipeline acquires the browser lock ONCE and holds it through the whole
|
|
5531
|
-
// posting phase. The MCP posts per approved card (separate
|
|
5680
|
+
// posting phase. The MCP posts per approved card (separate approve_drafts calls), and
|
|
5532
5681
|
// the old code acquired+released the lock PER CARD — leaving a release window
|
|
5533
5682
|
// BETWEEN every card that a parked scan stale-reclaimed (the hijack). Instead we
|
|
5534
5683
|
// keep the lock and only release it after SHELL_LOCK_GRACE_MS of no posting, so the
|
|
@@ -5683,7 +5832,7 @@ appTool("dashboard", {
|
|
|
5683
5832
|
"connection, autopilot state, and 7-day stats, with buttons to set up the schedule, connect X, " +
|
|
5684
5833
|
"and refresh. Use when the user asks to see the dashboard, panel, " +
|
|
5685
5834
|
"status, or controls. ALSO call this at the end of any state-changing or results-producing " +
|
|
5686
|
-
"action (
|
|
5835
|
+
"action (approve_drafts, get_stats, project_config) so the user sees the " +
|
|
5687
5836
|
"updated dashboard. Hosts without UI support get the same data as text.",
|
|
5688
5837
|
inputSchema: {},
|
|
5689
5838
|
// fallback_url is set only when the host can't render the ui:// resource and
|
|
@@ -5697,7 +5846,7 @@ appTool("dashboard", {
|
|
|
5697
5846
|
(snap.update_available && snap.latest_version ? ` (update to ${snap.latest_version})` : "") +
|
|
5698
5847
|
` — projects ${snap.projects_ready}/${snap.projects_total} ready, ` +
|
|
5699
5848
|
`X ${snap.x_connected ? "connected" : "not connected"}, ` +
|
|
5700
|
-
`autopilot ${snap.autopilot_on ? "on" : "off"}.`;
|
|
5849
|
+
`autopilot ${snap.paused ? "PAUSED" : snap.autopilot_on ? "on" : "off"}.`;
|
|
5701
5850
|
const base = {
|
|
5702
5851
|
content: [{ type: "text", text: human }],
|
|
5703
5852
|
structuredContent: { snapshot: JSON.stringify(snap) },
|
|
@@ -5949,70 +6098,34 @@ async function main() {
|
|
|
5949
6098
|
catch (e) {
|
|
5950
6099
|
console.error(`[queue-worker] could not create worker folder: ${e?.message || e}`);
|
|
5951
6100
|
}
|
|
5952
|
-
//
|
|
5953
|
-
//
|
|
6101
|
+
// Pausable pipeline daemons — one loop over the SAME registry pause/resume
|
|
6102
|
+
// use (PAUSE_TARGETS: X kicker, reddit kicker, reaper, stall-watch,
|
|
6103
|
+
// memory-snapshot), so boot can never install a daemon Pause doesn't cover.
|
|
6104
|
+
// Each is skipped while paused.flag is present, so a Claude Desktop restart
|
|
5954
6105
|
// during a Pause doesn't silently un-pause the pipeline — see pauseS4L/
|
|
5955
|
-
// resumeS4L.
|
|
5956
|
-
//
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
void ensureRedditKickerInstalled()
|
|
5970
|
-
.then((r) => console.error(`[reddit-kicker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5971
|
-
.catch((e) => console.error("[reddit-kicker] kicker install failed:", e?.message || e));
|
|
5972
|
-
}
|
|
5973
|
-
else {
|
|
5974
|
-
console.error("[reddit-kicker] launchd kicker: skip (paused)");
|
|
5975
|
-
}
|
|
5976
|
-
// Self-healing reaper for the agent-mode session leak the queue autopilot
|
|
5977
|
-
// produces (finished `claude` worker sessions Desktop never tears down). A
|
|
5978
|
-
// standalone guardrail; install unconditionally so it caps memory even on a
|
|
5979
|
-
// box whose project isn't ready yet. Best-effort; must never block boot.
|
|
5980
|
-
if (!isPaused()) {
|
|
5981
|
-
void ensureClaudeReaperInstalled()
|
|
5982
|
-
.then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5983
|
-
.catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
|
|
5984
|
-
}
|
|
5985
|
-
else {
|
|
5986
|
-
console.error("[claude-reaper] launchd reaper: skip (paused)");
|
|
6106
|
+
// resumeS4L. Each ensure keeps its own install gates (draftable lane, reddit
|
|
6107
|
+
// connected, etc.); a box that never connected reddit is a cheap no-op skip.
|
|
6108
|
+
// The boot-time call also SELF-HEALS installs broken by older gates: once a
|
|
6109
|
+
// gate passes, the plist lands on the next boot. All best-effort; never block
|
|
6110
|
+
// boot.
|
|
6111
|
+
writePauseTargetsManifest();
|
|
6112
|
+
for (const t of PAUSE_TARGETS) {
|
|
6113
|
+
if (isPaused()) {
|
|
6114
|
+
console.error(`[${t.tag}] launchd ${t.label}: skip (paused)`);
|
|
6115
|
+
continue;
|
|
6116
|
+
}
|
|
6117
|
+
void t.ensure()
|
|
6118
|
+
.then((r) => console.error(`[${t.tag}] launchd: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6119
|
+
.catch((e) => console.error(`[${t.tag}] launchd install failed:`, e?.message || e));
|
|
5987
6120
|
}
|
|
5988
6121
|
// Feedback digest: hourly distillation of the user's card approve/reject
|
|
5989
6122
|
// decisions into learned_preferences (see scripts/feedback_digest.py).
|
|
5990
|
-
// Best-effort; a box with no review events runs a no-op.
|
|
6123
|
+
// Best-effort; a box with no review events runs a no-op. NOT pause-gated (and
|
|
6124
|
+
// not in PAUSE_TARGETS): it only distills past review decisions, not
|
|
6125
|
+
// drafting/posting, so it's harmless to keep running.
|
|
5991
6126
|
void ensureFeedbackDigestInstalled()
|
|
5992
6127
|
.then((r) => console.error(`[feedback-digest] launchd digest: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5993
6128
|
.catch((e) => console.error("[feedback-digest] digest install failed:", e?.message || e));
|
|
5994
|
-
// Autopilot stall watchdog: fleet-side Sentry alert when the draft routines stop
|
|
5995
|
-
// draining (most often an account switch orphaning them). The menu bar shows the
|
|
5996
|
-
// user the Re-arm action; this is the part we see. Best-effort; never blocks boot.
|
|
5997
|
-
if (!isPaused()) {
|
|
5998
|
-
void ensureStallWatchInstalled()
|
|
5999
|
-
.then((r) => console.error(`[stall-watch] launchd watchdog: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6000
|
-
.catch((e) => console.error("[stall-watch] watchdog install failed:", e?.message || e));
|
|
6001
|
-
}
|
|
6002
|
-
else {
|
|
6003
|
-
console.error("[stall-watch] launchd watchdog: skip (paused)");
|
|
6004
|
-
}
|
|
6005
|
-
// Periodic host-resource sampler (memory/process snapshot -> local JSONL). Gives
|
|
6006
|
-
// us per-box resource history to diagnose RAM blowups (e.g. the agent-mode
|
|
6007
|
-
// session leak). Best-effort; never blocks boot. Disable with S4L_MEMORY_SNAPSHOT=0.
|
|
6008
|
-
if (!isPaused()) {
|
|
6009
|
-
void ensureMemorySnapshotInstalled()
|
|
6010
|
-
.then((r) => console.error(`[memory-snapshot] launchd sampler: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6011
|
-
.catch((e) => console.error("[memory-snapshot] sampler install failed:", e?.message || e));
|
|
6012
|
-
}
|
|
6013
|
-
else {
|
|
6014
|
-
console.error("[memory-snapshot] launchd sampler: skip (paused)");
|
|
6015
|
-
}
|
|
6016
6129
|
// On-screen overlay watcher supervisor. The harness status overlay only renders
|
|
6017
6130
|
// while the watcher process is alive, and that watcher had no supervisor — when
|
|
6018
6131
|
// it died nothing respawned it and the overlay silently vanished. Install it as
|