@m13v/s4l 1.6.204-rc.7 → 1.6.204
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 +397 -67
- package/mcp/dist/panel.html +27 -17
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +5 -1
- package/mcp/menubar/dashboard_server.py +39 -9
- package/mcp/menubar/s4l_card.py +105 -18
- package/mcp/menubar/s4l_menubar.py +290 -42
- package/mcp/menubar/s4l_state.py +55 -3
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/autopilot_stall_watch.py +18 -6
- package/scripts/claude_job.py +92 -14
- package/scripts/feedback_digest.py +53 -9
- package/scripts/invent_topics.py +261 -223
- package/scripts/link_tail.py +44 -1
- package/scripts/preview_translated_card.py +30 -0
- package/scripts/reap_stale_claude_sessions.py +20 -25
- package/scripts/s4l_mode.py +87 -11
- package/scripts/schedule_state.py +16 -4
- package/scripts/scheduled_tasks_snapshot.py +43 -0
- package/scripts/sentry_init.py +11 -4
- package/scripts/snapshot.py +13 -0
- package/scripts/twitter_browser.py +20 -35
- package/scripts/twitter_gen_links.py +138 -0
- package/scripts/twitter_post_plan.py +70 -57
- package/setup/SKILL.md +14 -6
- package/skill/audit.sh +29 -1
- package/skill/dm-outreach-linkedin.sh +23 -2
- package/skill/engage-dm-replies.sh +28 -2
- package/skill/engage-linkedin.sh +23 -2
- package/skill/invent-supply-test.sh +3 -1
- package/skill/invent-topics.sh +18 -12
- package/skill/lib/linkedin-backend.sh +40 -13
- package/skill/linkedin-presence.sh +7 -0
- package/skill/linkedin-recovery.sh +8 -1
- package/skill/lock.sh +52 -12
- package/skill/run-draft-and-publish.sh +58 -3
- package/skill/run-linkedin.sh +23 -2
- package/skill/run-twitter-cycle.sh +3 -1
- package/skill/stats-linkedin.sh +11 -1
- package/scripts/invent_mcp_server.py +0 -350
package/mcp/dist/index.js
CHANGED
|
@@ -372,7 +372,12 @@ const server = new McpServer({
|
|
|
372
372
|
"RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
|
|
373
373
|
"(`post_drafts`, `get_stats`, `project_config`), end your turn by " +
|
|
374
374
|
"calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
|
|
375
|
-
"`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing
|
|
375
|
+
"`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.\n\n" +
|
|
376
|
+
"REPORT FIELD DIAGNOSES. Whenever an S4L setup, healing, or troubleshooting flow FAILS, or " +
|
|
377
|
+
"succeeds only after a failure (schedule re-arm, runtime repair, stuck drafts, rate limits, " +
|
|
378
|
+
"anything the user pasted a fix-it prompt for), call `report_diagnosis` with a short markdown " +
|
|
379
|
+
"report (symptom, root cause, actions taken, current state) before ending the turn. Do this " +
|
|
380
|
+
"automatically — the developers only learn about field failures through these reports.",
|
|
376
381
|
});
|
|
377
382
|
const TOOL_HANDLERS = {};
|
|
378
383
|
const baseRegisterTool = server.registerTool.bind(server);
|
|
@@ -385,7 +390,7 @@ const baseRegisterTool = server.registerTool.bind(server);
|
|
|
385
390
|
// NOT here — it writes finer scanning/drafting phases itself (see produceDrafts).
|
|
386
391
|
const TOOL_ACTIVITY = {
|
|
387
392
|
post_drafts: "posting",
|
|
388
|
-
get_stats: "
|
|
393
|
+
get_stats: "stats…",
|
|
389
394
|
};
|
|
390
395
|
function toolActivityLabel(name, args) {
|
|
391
396
|
const fallback = TOOL_ACTIVITY[name];
|
|
@@ -605,8 +610,8 @@ async function produceDrafts(project, onProgress) {
|
|
|
605
610
|
let lastMsg = "";
|
|
606
611
|
// Granular scan progress for the menu-bar label. Phase 1 logs one
|
|
607
612
|
// `executing N queries` line (the total), then one `ok/err project=… kept=K`
|
|
608
|
-
// line per query. We count those to paint `
|
|
609
|
-
// of a static "
|
|
613
|
+
// line per query. We count those to paint `scan N/M +K` (K = kept) instead
|
|
614
|
+
// of a static "scan…". Best-effort: missing total falls back to a plain
|
|
610
615
|
// count, and any parse miss just leaves the prior label up.
|
|
611
616
|
let scanTotal = 0;
|
|
612
617
|
let scanDone = 0;
|
|
@@ -636,7 +641,7 @@ async function produceDrafts(project, onProgress) {
|
|
|
636
641
|
`project=${project ?? "(default)"} =====\n`);
|
|
637
642
|
// Menu-bar status: scanning first, then drafting once the prep phase begins
|
|
638
643
|
// (switched in onLine below). Cleared before every return.
|
|
639
|
-
writeActivity("scanning", "
|
|
644
|
+
writeActivity("scanning", "scan…");
|
|
640
645
|
const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
|
|
641
646
|
env: env,
|
|
642
647
|
timeoutMs: 900_000, // scan+draft can take several minutes
|
|
@@ -665,10 +670,10 @@ async function produceDrafts(project, onProgress) {
|
|
|
665
670
|
if (km)
|
|
666
671
|
scanKept += parseInt(km[1], 10) || 0;
|
|
667
672
|
const prog = scanTotal ? `${scanDone}/${scanTotal}` : `${scanDone}`;
|
|
668
|
-
writeActivity("scanning", `
|
|
673
|
+
writeActivity("scanning", `scan ${prog} +${scanKept}`);
|
|
669
674
|
}
|
|
670
675
|
if (/Phase 2b-prep/.test(t))
|
|
671
|
-
writeActivity("drafting", "drafting
|
|
676
|
+
writeActivity("drafting", "drafting");
|
|
672
677
|
if (!onProgress)
|
|
673
678
|
return;
|
|
674
679
|
const msg = cycleProgressMessage(t);
|
|
@@ -866,6 +871,38 @@ async function ensureTwitterBrowserForPost() {
|
|
|
866
871
|
});
|
|
867
872
|
}
|
|
868
873
|
async function postApproved(batchId, plan) {
|
|
874
|
+
// Drain serialization (2026-07-06 incident). Every call drains the WHOLE
|
|
875
|
+
// approved backlog, so overlapping drains are pure waste and actively harmful:
|
|
876
|
+
// a Claude restart mid-drain at 00:25Z left 10 landed replies unstamped, then
|
|
877
|
+
// restart recovery + per-approval calls launched 8 concurrent approved=14
|
|
878
|
+
// drains that re-attempted already-replied threads, fought over the browser
|
|
879
|
+
// lock, and clobbered each other's posted stamps. Wait for any in-flight drain
|
|
880
|
+
// (ours via `postingActive`, a sibling MCP's via the posting flag on disk)
|
|
881
|
+
// instead of stacking a new one. 8-minute cap: comfortably above a normal
|
|
882
|
+
// drain, comfortably below the menu bar's 900s loopback timeout so a waiting
|
|
883
|
+
// call still returns to its caller. After a wait, RE-READ the plan so the
|
|
884
|
+
// peer's posted/terminal stamps shrink our backlog before we attempt anything.
|
|
885
|
+
{
|
|
886
|
+
const gateDeadline = Date.now() + 8 * 60_000;
|
|
887
|
+
let waited = false;
|
|
888
|
+
while ((postingActive || isPeerDrainActive()) && Date.now() < gateDeadline) {
|
|
889
|
+
waited = true;
|
|
890
|
+
await sleepMs(5000);
|
|
891
|
+
}
|
|
892
|
+
if (postingActive || isPeerDrainActive()) {
|
|
893
|
+
return {
|
|
894
|
+
attempted: 0,
|
|
895
|
+
exit_code: 0,
|
|
896
|
+
summary: "another posting drain has been running for 8+ minutes; approved cards stay " +
|
|
897
|
+
"queued in the review store — re-run post_drafts once it finishes",
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
if (waited) {
|
|
901
|
+
const fresh = readPlan(batchId);
|
|
902
|
+
if (fresh)
|
|
903
|
+
plan = fresh;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
869
906
|
// Post every card the user APPROVED that hasn't already landed or been ruled out.
|
|
870
907
|
// `approved` is now a DURABLE decision (sticky, never cleared by a later call), so
|
|
871
908
|
// filtering out posted/terminal here makes this idempotent: re-running it only
|
|
@@ -907,6 +944,24 @@ async function postApproved(batchId, plan) {
|
|
|
907
944
|
// batch so the every-minute autopilot scan queues behind the post instead of
|
|
908
945
|
// seizing Chrome mid-batch — the root cause of approved batches landing 0/N.
|
|
909
946
|
const heldShellLock = await acquireShellBrowserLock();
|
|
947
|
+
if (!heldShellLock) {
|
|
948
|
+
// acquireShellBrowserLock now preempts (SIGKILLs) whatever holds this lock
|
|
949
|
+
// unconditionally, so reaching `false` here means even reclaiming the dir
|
|
950
|
+
// across 8 attempts didn't stick (e.g. something is re-taking it faster than
|
|
951
|
+
// we can write our own pid) — a pathological edge case, not the normal path.
|
|
952
|
+
// Bail out rather than proceed without ever confirming we hold it. Approved
|
|
953
|
+
// cards stay sticky (approved && !posted && !terminal), so the very next
|
|
954
|
+
// post_drafts call — the next approval, or this same tool retried — picks
|
|
955
|
+
// them straight back up; nothing is lost or re-queued.
|
|
956
|
+
postingActive = false;
|
|
957
|
+
stopPostingFlagHeartbeat();
|
|
958
|
+
return {
|
|
959
|
+
attempted: 0,
|
|
960
|
+
exit_code: 0,
|
|
961
|
+
summary: "couldn't pin down the twitter-browser lock after repeated attempts; approved cards " +
|
|
962
|
+
"stay queued — re-run post_drafts to retry",
|
|
963
|
+
};
|
|
964
|
+
}
|
|
910
965
|
const approvedBatch = `${batchId}_approved`;
|
|
911
966
|
writePlan(approvedBatch, { ...plan, candidates: approved });
|
|
912
967
|
// S4L_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
|
|
@@ -944,15 +999,23 @@ async function postApproved(batchId, plan) {
|
|
|
944
999
|
// opts.env AFTER process.env, and twitter_post_plan.py never load_dotenv's
|
|
945
1000
|
// with override, so nothing clobbers it. Cron is untouched (it never goes
|
|
946
1001
|
// through this MCP path), so the 0.9 experiment keeps running there.
|
|
1002
|
+
//
|
|
1003
|
+
// 2026-07-06: the tail-link decision (link vs no_link) and the Claude
|
|
1004
|
+
// bridge call both moved to DRAFT time (scripts/twitter_gen_links.py's
|
|
1005
|
+
// Phase 2b-gen step, which stamps tail_link_variant + finalizes
|
|
1006
|
+
// reply_text before the review card is ever shown — see
|
|
1007
|
+
// twitter_post_plan.py's guard on tail_link_variant). That step reads
|
|
1008
|
+
// DRAFT_ONLY (forced to rate=1.0 there) to guarantee a hand-approved
|
|
1009
|
+
// card never drops the link it already shows. So both env vars below
|
|
1010
|
+
// are now no-ops for the normal path — every approved candidate
|
|
1011
|
+
// already carries tail_link_variant by the time it reaches this MCP
|
|
1012
|
+
// tool. They're left in place as a defense-in-depth fallback for the
|
|
1013
|
+
// rare case a candidate reaches post_drafts unstamped (e.g. a plan
|
|
1014
|
+
// already in flight from before this change): S4L_SKIP_LINK_TAIL=1
|
|
1015
|
+
// still guarantees post_drafts (a synchronous call the user is
|
|
1016
|
+
// waiting on) never makes a blocking Claude/queue call at post time,
|
|
1017
|
+
// no matter what.
|
|
947
1018
|
TWITTER_TAIL_LINK_RATE: "1.0",
|
|
948
|
-
// Plugin flow only: skip the link_tail Claude call. It just rewords
|
|
949
|
-
// prose around the URL (the minted short link comes from the
|
|
950
|
-
// deterministic wrap step), and on .mcpb boxes there's no `claude`
|
|
951
|
-
// binary so it wastes ~35s/post of run_claude.sh retry backoff before
|
|
952
|
-
// falling back to the mechanical concat anyway. link_tail.py honors
|
|
953
|
-
// this and short-circuits to that concat instantly. The local
|
|
954
|
-
// cron/plist autopilot never sets this, so it keeps generating the
|
|
955
|
-
// bridge sentence.
|
|
956
1019
|
S4L_SKIP_LINK_TAIL: "1",
|
|
957
1020
|
// The poster attaches to the twitter-harness Chrome over CDP. The cron
|
|
958
1021
|
// pipeline exports this from skill/lib/twitter-backend.sh; the MCP path
|
|
@@ -1068,10 +1131,52 @@ async function postApproved(batchId, plan) {
|
|
|
1068
1131
|
}
|
|
1069
1132
|
if (touchedPlan) {
|
|
1070
1133
|
try {
|
|
1071
|
-
|
|
1134
|
+
// Merge our stamps into a FRESH read of the store instead of rewriting the
|
|
1135
|
+
// whole plan from the copy we took minutes ago. The old whole-file write
|
|
1136
|
+
// was last-writer-wins: while this batch posted, the menubar (decision
|
|
1137
|
+
// re-stamps) and any peer drain also wrote the store, and whichever run
|
|
1138
|
+
// finished last erased the others' posted flags (2026-07-06: card 344877
|
|
1139
|
+
// posted at 00:29Z ended `posted=None, terminal=duplicate_thread_pre_post`
|
|
1140
|
+
// after a later run's stale write). Merge rules: `posted` is sticky and
|
|
1141
|
+
// wins over terminal; `terminal` never overwrites a fresh `posted=true`.
|
|
1142
|
+
// Fallback: candidates without a candidate_id can't be matched into the
|
|
1143
|
+
// fresh copy, so keep the legacy whole-plan write for those older plans.
|
|
1144
|
+
const mergeable = approved.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
1145
|
+
const fresh = mergeable ? readPlan(batchId) : null;
|
|
1146
|
+
if (fresh && Array.isArray(fresh.candidates)) {
|
|
1147
|
+
const freshById = new Map();
|
|
1148
|
+
fresh.candidates.forEach((c) => {
|
|
1149
|
+
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
1150
|
+
freshById.set(String(c.candidate_id), c);
|
|
1151
|
+
});
|
|
1152
|
+
for (const c of approved) {
|
|
1153
|
+
const f = freshById.get(String(c.candidate_id));
|
|
1154
|
+
if (!f)
|
|
1155
|
+
continue;
|
|
1156
|
+
if (c.posted === true) {
|
|
1157
|
+
f.posted = true;
|
|
1158
|
+
f.terminal = false;
|
|
1159
|
+
if (c.our_url)
|
|
1160
|
+
f.our_url = c.our_url;
|
|
1161
|
+
}
|
|
1162
|
+
else if (c.terminal === true && f.posted !== true) {
|
|
1163
|
+
f.terminal = true;
|
|
1164
|
+
f.terminal_reason = c.terminal_reason;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
writePlan(batchId, fresh);
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
writePlan(batchId, plan);
|
|
1171
|
+
}
|
|
1072
1172
|
}
|
|
1073
1173
|
catch {
|
|
1074
|
-
|
|
1174
|
+
try {
|
|
1175
|
+
writePlan(batchId, plan);
|
|
1176
|
+
}
|
|
1177
|
+
catch {
|
|
1178
|
+
/* best effort */
|
|
1179
|
+
}
|
|
1075
1180
|
}
|
|
1076
1181
|
}
|
|
1077
1182
|
// Post failures are HANDLED in the pipeline (it returns a count, never throws),
|
|
@@ -1246,7 +1351,8 @@ async function seedSearchQueriesForProject(project, rawQueries) {
|
|
|
1246
1351
|
tool("engagement_mode", {
|
|
1247
1352
|
title: "Choose engagement lanes (personal brand + optional product promotion)",
|
|
1248
1353
|
description: "Set or read the engagement LANES the autopilot drafts in. There are TWO independent lanes that " +
|
|
1249
|
-
"can BOTH be on (the cycle then splits
|
|
1354
|
+
"can BOTH be on (the cycle then splits per the configurable lane split, default 50/50 — see " +
|
|
1355
|
+
"action:'split'): PERSONAL BRAND (organic, link-free engagement in " +
|
|
1250
1356
|
"the user's own voice — ON by default) and PRODUCT PROMOTION (the marketing pipeline, link " +
|
|
1251
1357
|
"replies — OFF by default, opt-in). This is a SETUP step: AFTER X is connected, the profile " +
|
|
1252
1358
|
"is scanned (for VOICE), and the user has answered the DICTATION interview (for TOPICS + corpus), " +
|
|
@@ -1261,9 +1367,9 @@ tool("engagement_mode", {
|
|
|
1261
1367
|
"project_config afterward. The user flips either lane any time from the menu-bar checkmarks.",
|
|
1262
1368
|
inputSchema: {
|
|
1263
1369
|
action: z
|
|
1264
|
-
.enum(["get", "set", "toggle"])
|
|
1370
|
+
.enum(["get", "set", "toggle", "split"])
|
|
1265
1371
|
.optional()
|
|
1266
|
-
.describe("get = read current lane flags + persona status. set = record the user's chosen lanes (provisions the persona). toggle = lightweight flip of ONE lane (pass `lane`); mode.json only, no persona work — the dashboard/menu-bar quick toggle."),
|
|
1372
|
+
.describe("get = read current lane flags + persona status + lane split. set = record the user's chosen lanes (provisions the persona). toggle = lightweight flip of ONE lane (pass `lane`); mode.json only, no persona work — the dashboard/menu-bar quick toggle. split = set the personal-brand share of both-lanes-on cycles (pass `split`); mode.json only — the dashboard slider."),
|
|
1267
1373
|
personal_brand: z
|
|
1268
1374
|
.boolean()
|
|
1269
1375
|
.optional()
|
|
@@ -1276,6 +1382,12 @@ tool("engagement_mode", {
|
|
|
1276
1382
|
.enum(["personal_brand", "promotion"])
|
|
1277
1383
|
.optional()
|
|
1278
1384
|
.describe("action:'toggle' — which single lane to flip."),
|
|
1385
|
+
split: z
|
|
1386
|
+
.union([z.number(), z.string()])
|
|
1387
|
+
.optional()
|
|
1388
|
+
.describe("action:'split' (or alongside action:'set') — the personal-brand SHARE of cycles when both " +
|
|
1389
|
+
"lanes are on: 0.7, 70, or '70%' all mean 70% personal brand / 30% promotion. Clamped to " +
|
|
1390
|
+
"0..1; ignored while only one lane is on (single-lane states run that lane every cycle)."),
|
|
1279
1391
|
mode: z
|
|
1280
1392
|
.enum(["personal_brand", "promotion"])
|
|
1281
1393
|
.optional()
|
|
@@ -1325,11 +1437,47 @@ tool("engagement_mode", {
|
|
|
1325
1437
|
return { personal_brand: true, promotion: false };
|
|
1326
1438
|
}
|
|
1327
1439
|
};
|
|
1440
|
+
const readSplit = async () => {
|
|
1441
|
+
const r = await runPython("scripts/s4l_mode.py", ["split"], { timeoutMs: 15_000 });
|
|
1442
|
+
try {
|
|
1443
|
+
const v = Number(JSON.parse((r.stdout || "").trim()).personal_brand_share);
|
|
1444
|
+
return Number.isFinite(v) ? v : 0.5;
|
|
1445
|
+
}
|
|
1446
|
+
catch {
|
|
1447
|
+
return 0.5;
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1328
1450
|
if (action === "get") {
|
|
1329
1451
|
const flags = await readFlags();
|
|
1330
1452
|
const persona = findPersonaProject();
|
|
1331
1453
|
const mode = flags.personal_brand ? "personal_brand" : "promotion";
|
|
1332
|
-
return jsonContent({
|
|
1454
|
+
return jsonContent({
|
|
1455
|
+
flags,
|
|
1456
|
+
mode,
|
|
1457
|
+
personal_brand_share: await readSplit(),
|
|
1458
|
+
persona: persona ? persona.name : null,
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
// Set the both-lanes-on split (the dashboard slider): just rewrite
|
|
1462
|
+
// mode.json via s4l_mode.py — NO persona provisioning, same weight class
|
|
1463
|
+
// as action:'toggle'.
|
|
1464
|
+
if (action === "split") {
|
|
1465
|
+
if (args.split === undefined || args.split === null || args.split === "") {
|
|
1466
|
+
return jsonContent({ personal_brand_share: await readSplit() });
|
|
1467
|
+
}
|
|
1468
|
+
const res = await runPython("scripts/s4l_mode.py", ["split", String(args.split)], {
|
|
1469
|
+
timeoutMs: 15_000,
|
|
1470
|
+
});
|
|
1471
|
+
if (res.code !== 0) {
|
|
1472
|
+
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
|
|
1473
|
+
return textContent(`Could not set the lane split: ${tail}`);
|
|
1474
|
+
}
|
|
1475
|
+
try {
|
|
1476
|
+
return jsonContent(JSON.parse((res.stdout || "").trim()));
|
|
1477
|
+
}
|
|
1478
|
+
catch {
|
|
1479
|
+
return jsonContent({ personal_brand_share: await readSplit() });
|
|
1480
|
+
}
|
|
1333
1481
|
}
|
|
1334
1482
|
// Lightweight flip of ONE lane (the dashboard/menu-bar quick toggle): just
|
|
1335
1483
|
// rewrite mode.json via s4l_mode.py — NO persona provisioning. Mirrors the
|
|
@@ -1362,7 +1510,7 @@ tool("engagement_mode", {
|
|
|
1362
1510
|
}
|
|
1363
1511
|
if (!personalBrand && !promotion) {
|
|
1364
1512
|
return textContent("At least one lane must be on. personal_brand is the default; set promotion:true if the user " +
|
|
1365
|
-
"also wants product promotion (both on -> the cycle splits 50/50).");
|
|
1513
|
+
"also wants product promotion (both on -> the cycle splits per the lane split, default 50/50).");
|
|
1366
1514
|
}
|
|
1367
1515
|
const mode = personalBrand ? "personal_brand" : "promotion";
|
|
1368
1516
|
recordOnboardingAttempt("mode_chosen", { personal_brand: personalBrand, promotion });
|
|
@@ -1372,6 +1520,11 @@ tool("engagement_mode", {
|
|
|
1372
1520
|
blockOnboardingMilestone("mode_chosen", "mode_set_failed", tail, { personal_brand: personalBrand, promotion });
|
|
1373
1521
|
return textContent(`Couldn't save the engagement lanes: ${tail}`);
|
|
1374
1522
|
}
|
|
1523
|
+
// Optional lane split rider on 'set' (best-effort: the lanes are saved
|
|
1524
|
+
// either way, and the split keeps its previous/default value on failure).
|
|
1525
|
+
if (args.split !== undefined && args.split !== null && args.split !== "") {
|
|
1526
|
+
await runPython("scripts/s4l_mode.py", ["split", String(args.split)], { timeoutMs: 15_000 });
|
|
1527
|
+
}
|
|
1375
1528
|
// NOTE (2026-07-06): the draft-only flag (mode.json {"draft_only": false} —
|
|
1376
1529
|
// promotion cycles POST autonomously instead of drafting cards) is
|
|
1377
1530
|
// DELIBERATELY NOT exposed on this tool or any user surface. It is an
|
|
@@ -1454,7 +1607,8 @@ tool("engagement_mode", {
|
|
|
1454
1607
|
const bothOn = personalBrand && promotion;
|
|
1455
1608
|
const next_step = promotion
|
|
1456
1609
|
? (bothOn
|
|
1457
|
-
? "Personal brand + product promotion are BOTH on (the cycle splits
|
|
1610
|
+
? "Personal brand + product promotion are BOTH on (the cycle splits per the lane split, " +
|
|
1611
|
+
"default 50/50 — adjustable via action:'split' or the dashboard slider), and the persona " +
|
|
1458
1612
|
"is provisioned + topic-seeded. "
|
|
1459
1613
|
: "Product promotion is on and the persona is provisioned. ") +
|
|
1460
1614
|
"NOW CONTINUE SETUP: configure the product project with project_config (research the product " +
|
|
@@ -1754,9 +1908,12 @@ tool("project_config", {
|
|
|
1754
1908
|
"a phrase bank + things they avoid, and their icp. The scan is BACKWARD-LOOKING (only what " +
|
|
1755
1909
|
"they already posted) so it is the source for VOICE, not the primary source for topics. " +
|
|
1756
1910
|
"SECOND (the DICTATION interview — this is where TOPICS + grounding corpus come from, do NOT " +
|
|
1757
|
-
"skip it and do NOT infer topics from the scan alone):
|
|
1911
|
+
"skip it and do NOT infer topics from the scan alone): invite the user to answer the " +
|
|
1758
1912
|
"following in ONE spoken dictation (the Claude input box already supports dictation, so they " +
|
|
1759
|
-
"just talk once and you split the answers into fields).
|
|
1913
|
+
"just talk once and you split the answers into fields). KEEP THE FRAMING CHILL: this is a " +
|
|
1914
|
+
"casual brain-dump, not a form. No pressure; they can answer as much or as little as they " +
|
|
1915
|
+
"like, skip anything, and come back to the rest whenever they feel like it. Preface the list " +
|
|
1916
|
+
"with one short low-key line saying exactly that, then ask verbatim, as a single numbered " +
|
|
1760
1917
|
"list:\n" +
|
|
1761
1918
|
" 1. Who are you, and what do you want to be known for? (-> description)\n" +
|
|
1762
1919
|
" 2. What subjects could you talk about for an hour, work and non-work? (-> search_topics: " +
|
|
@@ -1775,12 +1932,15 @@ tool("project_config", {
|
|
|
1775
1932
|
"Then SYNTHESIZE the fields from their dictation: search_topics comes PRIMARILY from answer 2 " +
|
|
1776
1933
|
"(fold in recurring scan themes only as reinforcement); description/content_angle/voice from " +
|
|
1777
1934
|
"the rest. Keep their RAW transcript VERBATIM as content_corpus (do NOT paraphrase; their " +
|
|
1778
|
-
"actual numbers, opinions, and phrasing are what make drafts sound like them). If
|
|
1779
|
-
"
|
|
1935
|
+
"actual numbers, opinions, and phrasing are what make drafts sound like them). If they " +
|
|
1936
|
+
"answer only some questions, take what they gave, continue without nagging, and mention once " +
|
|
1937
|
+
"that they can answer the rest any time later to make drafts sound more like them. If the " +
|
|
1938
|
+
"user declines or gives nothing usable, fall back to scan-derived topics. " +
|
|
1780
1939
|
"THIRD (engagement lanes — ASK THE USER, do not infer): the PERSONAL BRAND lane (organic, " +
|
|
1781
1940
|
"link-free engagement in their own voice) is ON by default, so ask the ONE question — do they " +
|
|
1782
1941
|
"ALSO want to PROMOTE a PRODUCT (the marketing lane, link replies)? Both lanes can run (the " +
|
|
1783
|
-
"cycle splits 50/50). Call the `engagement_mode` tool
|
|
1942
|
+
"cycle splits per the configurable lane split, default 50/50). Call the `engagement_mode` tool " +
|
|
1943
|
+
"action:'set' with personal_brand:true, " +
|
|
1784
1944
|
"promotion:true|false AND the voice/description/search_topics you synthesized PLUS the raw " +
|
|
1785
1945
|
"dictation transcript as content_corpus (this provisions the persona and seeds topics). Only " +
|
|
1786
1946
|
"NOW are topics seeded — postponed until the dictation is in. " +
|
|
@@ -1819,6 +1979,7 @@ tool("project_config", {
|
|
|
1819
1979
|
update_available: !!snap.update_available,
|
|
1820
1980
|
mode: snap.mode,
|
|
1821
1981
|
flags: snap.flags,
|
|
1982
|
+
personal_brand_share: snap.personal_brand_share,
|
|
1822
1983
|
update_hint: snap.update_available
|
|
1823
1984
|
? `A newer version (${snap.latest_version}) is available — you're on ${snap.version}. ` +
|
|
1824
1985
|
`Tell the user and offer to run the \`runtime\` tool with action:'update' ` +
|
|
@@ -2332,6 +2493,44 @@ tool("restart_menubar", {
|
|
|
2332
2493
|
menubar_running: running,
|
|
2333
2494
|
});
|
|
2334
2495
|
});
|
|
2496
|
+
// ---- report_diagnosis: ship a field diagnosis to the developers -------------
|
|
2497
|
+
// First-class MCP wrapper over scripts/send_diagnostic_report.py (the same
|
|
2498
|
+
// Sentry lane the menubar "Diagnose & fix" prompt uses). Before this existed
|
|
2499
|
+
// (2026-07-06) field diagnoses only reached us when the user clicked the
|
|
2500
|
+
// menubar button AND their Claude ran the script via Bash; troubleshooting done
|
|
2501
|
+
// directly in chat left no trace. The server instructions tell the agent to
|
|
2502
|
+
// call this automatically after any failed (or recovered-after-failure)
|
|
2503
|
+
// setup/heal/troubleshooting flow.
|
|
2504
|
+
tool("report_diagnosis", {
|
|
2505
|
+
title: "Send a diagnosis report to the S4L developers",
|
|
2506
|
+
description: "Ship a short markdown field-diagnosis report (symptom, root cause, actions taken, current " +
|
|
2507
|
+
"state) to the S4L developers' telemetry. Call this AUTOMATICALLY after any S4L " +
|
|
2508
|
+
"setup/heal/troubleshooting flow that failed, or that succeeded only after a failure — do not " +
|
|
2509
|
+
"wait for the user to ask. Contains no secrets; keep the report factual and under a page.",
|
|
2510
|
+
inputSchema: {
|
|
2511
|
+
report_markdown: z.string().describe("The diagnosis report, markdown, under ~6000 chars"),
|
|
2512
|
+
reason: z
|
|
2513
|
+
.string()
|
|
2514
|
+
.optional()
|
|
2515
|
+
.describe("Short reason code, e.g. schedule_missing, runtime_repair, rate_limited"),
|
|
2516
|
+
},
|
|
2517
|
+
}, async ({ report_markdown, reason }) => {
|
|
2518
|
+
try {
|
|
2519
|
+
const dir = path.join(s4lStateDir(), "diagnostics");
|
|
2520
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2521
|
+
const file = path.join(dir, `report-${Date.now()}.md`);
|
|
2522
|
+
fs.writeFileSync(file, report_markdown, "utf-8");
|
|
2523
|
+
const res = await runPython("scripts/send_diagnostic_report.py", [file, reason || "mcp_tool"], { timeoutMs: 20_000 });
|
|
2524
|
+
return jsonContent({
|
|
2525
|
+
ok: res.code === 0,
|
|
2526
|
+
detail: res.code === 0 ? "report shipped" : (res.stderr || res.stdout || "").slice(0, 300),
|
|
2527
|
+
saved_to: file,
|
|
2528
|
+
});
|
|
2529
|
+
}
|
|
2530
|
+
catch (e) {
|
|
2531
|
+
return jsonContent({ ok: false, detail: String(e?.message || e).slice(0, 300) });
|
|
2532
|
+
}
|
|
2533
|
+
});
|
|
2335
2534
|
function runtimeSnapshot() {
|
|
2336
2535
|
const rt = readRuntime();
|
|
2337
2536
|
const progress = readProgress();
|
|
@@ -2507,8 +2706,19 @@ async function autopilotLoaded() {
|
|
|
2507
2706
|
// fires every minute, claims ONE job, runs the pipeline's own prompt as its
|
|
2508
2707
|
// Claude turn, writes the result back, and stops.
|
|
2509
2708
|
// ===========================================================================
|
|
2510
|
-
const QUEUE_WORKER_PROMPT_VERSION =
|
|
2709
|
+
const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
|
|
2511
2710
|
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
2711
|
+
// How long ONE `next --wait-seconds` call polls before giving up and exiting.
|
|
2712
|
+
// 240s (4 min): comfortably inside the 900s single-Bash-call survival verified
|
|
2713
|
+
// live on 2026-07-06, and covers a meaningful chunk of the ~8min average
|
|
2714
|
+
// real job inter-arrival gap measured on the box, while still keeping each
|
|
2715
|
+
// worker session bounded. The cron's `* * * * *` cadence remains the outer
|
|
2716
|
+
// safety net for whatever the poll window doesn't catch.
|
|
2717
|
+
// COUPLING: scripts/reap_stale_claude_sessions.py's S4L_REAPER_CLAIM_GRACE_SEC
|
|
2718
|
+
// default MUST stay >= this value + margin — a claimless session inside this
|
|
2719
|
+
// poll window is legitimately still working, not a husk, and a too-tight
|
|
2720
|
+
// claim_grace would SIGTERM it mid-poll before it ever gets to claim.
|
|
2721
|
+
const QUEUE_WORKER_POLL_SECONDS = 240;
|
|
2512
2722
|
// One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
|
|
2513
2723
|
const QUEUE_WORKERS = [
|
|
2514
2724
|
{ taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
|
|
@@ -2561,9 +2771,9 @@ function autopilotStalled() {
|
|
|
2561
2771
|
for (const sub of fs.readdirSync(pendRoot, { withFileTypes: true })) {
|
|
2562
2772
|
if (!sub.isDirectory())
|
|
2563
2773
|
continue;
|
|
2564
|
-
// feedback-digest jobs are latency-insensitive (
|
|
2565
|
-
// forever) and may legitimately queue behind a multi-minute
|
|
2566
|
-
// aging past the draft threshold there is NOT an autopilot stall.
|
|
2774
|
+
// feedback-digest jobs are latency-insensitive (every-minute kicker,
|
|
2775
|
+
// retried forever) and may legitimately queue behind a multi-minute
|
|
2776
|
+
// draft job; aging past the draft threshold there is NOT an autopilot stall.
|
|
2567
2777
|
if (sub.name === "feedback-digest")
|
|
2568
2778
|
continue;
|
|
2569
2779
|
const subPath = path.join(pendRoot, sub.name);
|
|
@@ -2632,17 +2842,24 @@ function queueWorkerBody(spec) {
|
|
|
2632
2842
|
`other tool, or trying to "investigate", STALLS it forever.`,
|
|
2633
2843
|
``,
|
|
2634
2844
|
`PACING — CRITICAL: this unattended session is terminated ~90 seconds after ` +
|
|
2635
|
-
`your LAST tool call (a host inactivity timeout).
|
|
2636
|
-
`
|
|
2637
|
-
`
|
|
2638
|
-
`
|
|
2639
|
-
`
|
|
2845
|
+
`your LAST tool call (a host inactivity timeout). That clock only runs BETWEEN ` +
|
|
2846
|
+
`tool calls, not during one — step 1 below is a single Bash call that can ` +
|
|
2847
|
+
`legitimately take several minutes to return, and that is fine. Make your ` +
|
|
2848
|
+
`first tool call promptly, and once you are drafting (step 2), if the job's ` +
|
|
2849
|
+
`prompt gives you per-item persist commands to run (its own quick Bash calls), ` +
|
|
2850
|
+
`run them as you complete each item instead of working silently — those calls ` +
|
|
2851
|
+
`are what keep the session alive. The prompt file may begin with a WORKER ` +
|
|
2852
|
+
`EXECUTION NOTES header; follow it exactly.`,
|
|
2640
2853
|
``,
|
|
2641
2854
|
`Steps:`,
|
|
2642
|
-
`1.
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
`
|
|
2855
|
+
`1. Look for the next job. Run this EXACT Bash command and let it run to ` +
|
|
2856
|
+
`completion — it polls internally for up to ${Math.round(QUEUE_WORKER_POLL_SECONDS / 60)} ` +
|
|
2857
|
+
`minutes before giving up, so it may take a while to return. That is normal: ` +
|
|
2858
|
+
`do NOT interrupt it and do NOT make any other tool call while it is running.`,
|
|
2859
|
+
` ${py} ${job} next --type any --prompt-file --wait-seconds ${QUEUE_WORKER_POLL_SECONDS} --state-dir ${sd}`,
|
|
2860
|
+
` It prints one line of JSON once it returns. If it prints "{}" (empty), no ` +
|
|
2861
|
+
`job showed up during the whole poll window — report "no jobs" in one line ` +
|
|
2862
|
+
`and STOP. You are done.`,
|
|
2646
2863
|
`2. Otherwise it prints {"job_id":"...","prompt_file":"...","schema_file":...}. ` +
|
|
2647
2864
|
`Use the Read tool to read prompt_file; it is the complete, self-contained ` +
|
|
2648
2865
|
`instruction the pipeline wrote for you. If the Read result says it is partial ` +
|
|
@@ -3142,12 +3359,19 @@ async function ensureClaudeReaperInstalled() {
|
|
|
3142
3359
|
}
|
|
3143
3360
|
}
|
|
3144
3361
|
// ---- launchd feedback digest: card decisions -> learned_preferences ---------
|
|
3145
|
-
//
|
|
3146
|
-
//
|
|
3147
|
-
//
|
|
3148
|
-
//
|
|
3149
|
-
//
|
|
3150
|
-
|
|
3362
|
+
// Every minute, same cadence as com.m13v.social-twitter-cycle (the drafting
|
|
3363
|
+
// producer), stdlib-only under SYSTEM python (http_api + learned_preferences
|
|
3364
|
+
// use urllib/json only; run_claude.sh resolves the claude CLI itself). A run
|
|
3365
|
+
// with no unprocessed review_events for this install is a cheap no-op, so the
|
|
3366
|
+
// job is installed unconditionally like the reaper. Content-aware install so
|
|
3367
|
+
// an already-installed box picks up changed args on the next Claude boot.
|
|
3368
|
+
// (Was hourly from the day this shipped, 2026-07-02, on purpose: the digest
|
|
3369
|
+
// was designed as a standalone scheduled batch job, not a per-event trigger.
|
|
3370
|
+
// Changed 2026-07-06 to close the gap with edits/feedback sitting unprocessed
|
|
3371
|
+
// for up to an hour: every-minute checks are still cheap no-ops between
|
|
3372
|
+
// actionable events, since digest_project() only calls Claude when at least
|
|
3373
|
+
// one fetched event is actionable.)
|
|
3374
|
+
const FEEDBACK_DIGEST_INTERVAL_SECS = 60;
|
|
3151
3375
|
async function ensureFeedbackDigestInstalled() {
|
|
3152
3376
|
try {
|
|
3153
3377
|
if (process.platform !== "darwin")
|
|
@@ -3163,7 +3387,7 @@ async function ensureFeedbackDigestInstalled() {
|
|
|
3163
3387
|
label: FEEDBACK_DIGEST_LABEL,
|
|
3164
3388
|
programArgs: ["/usr/bin/python3", path.join(repoDir(), "scripts", "feedback_digest.py")],
|
|
3165
3389
|
intervalSecs: FEEDBACK_DIGEST_INTERVAL_SECS,
|
|
3166
|
-
runAtLoad: false, // no boot-time Claude runs; the
|
|
3390
|
+
runAtLoad: false, // no boot-time Claude runs; the every-minute tick is enough
|
|
3167
3391
|
stdoutLog: path.join(logDir, "launchd-feedback-digest-stdout.log"),
|
|
3168
3392
|
stderrLog: path.join(logDir, "launchd-feedback-digest-stderr.log"),
|
|
3169
3393
|
});
|
|
@@ -3380,7 +3604,7 @@ async function scheduleState() {
|
|
|
3380
3604
|
try {
|
|
3381
3605
|
const res = await runPython("scripts/schedule_state.py", [], { timeoutMs: 15_000 });
|
|
3382
3606
|
const state = JSON.parse(res.stdout.trim()).state;
|
|
3383
|
-
if (state === "ok" || state === "disabled")
|
|
3607
|
+
if (state === "ok" || state === "disabled" || state === "stalled")
|
|
3384
3608
|
return state;
|
|
3385
3609
|
return "missing";
|
|
3386
3610
|
}
|
|
@@ -3614,23 +3838,81 @@ function startLocalPanel() {
|
|
|
3614
3838
|
});
|
|
3615
3839
|
});
|
|
3616
3840
|
}
|
|
3841
|
+
function panelEndpointPath() {
|
|
3842
|
+
return path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp", "panel-endpoint.json");
|
|
3843
|
+
}
|
|
3844
|
+
function isPidAlive(pid) {
|
|
3845
|
+
try {
|
|
3846
|
+
process.kill(pid, 0);
|
|
3847
|
+
return true;
|
|
3848
|
+
}
|
|
3849
|
+
catch {
|
|
3850
|
+
return false;
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
function readPanelEndpoint() {
|
|
3854
|
+
try {
|
|
3855
|
+
return JSON.parse(fs.readFileSync(panelEndpointPath(), "utf-8"));
|
|
3856
|
+
}
|
|
3857
|
+
catch {
|
|
3858
|
+
return null;
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3617
3861
|
// Publish the loopback URL to stable files so out-of-process readers can find
|
|
3618
3862
|
// the ephemeral port without scraping `lsof`:
|
|
3619
3863
|
// - panel-url plain text, for the Claude Code side-panel reverse proxy.
|
|
3620
3864
|
// - panel-endpoint.json richer (url + version + pid), for the menu bar app,
|
|
3621
3865
|
// which POSTs /tool/<name> here for live data.
|
|
3622
3866
|
// Best-effort: a write failure never blocks the panel (readers re-check /health).
|
|
3867
|
+
//
|
|
3868
|
+
// panel-endpoint.json is a SINGLE shared file that every S4L MCP process writes
|
|
3869
|
+
// to on boot (Claude Desktop/Cowork, a Claude Code side-panel session, the
|
|
3870
|
+
// ~/.s4l-worker queue runner, ...). Startup here is eager (see main(), "Eagerly
|
|
3871
|
+
// start the loopback panel server") so even a one-shot queue-worker invocation
|
|
3872
|
+
// that lives for well under a minute claims this file, then exits and leaves it
|
|
3873
|
+
// pointing at a dead pid until some other process happens to overwrite it. The
|
|
3874
|
+
// menu bar depends on this file to find a server to POST approved drafts to
|
|
3875
|
+
// (s4l_state.py loopback_tool), so a dead pointer silently strands approved
|
|
3876
|
+
// drafts (post_failed: loopback_unreachable) until pure chance fixes the file.
|
|
3877
|
+
// Fix: don't steal the slot from an existing registrant that's still alive —
|
|
3878
|
+
// last-writer-wins only among writers that found a dead (or absent) entry.
|
|
3623
3879
|
function writePanelUrl(url) {
|
|
3624
3880
|
try {
|
|
3625
3881
|
const dir = path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp");
|
|
3626
3882
|
fs.mkdirSync(dir, { recursive: true });
|
|
3627
3883
|
fs.writeFileSync(path.join(dir, "panel-url"), url, "utf-8");
|
|
3628
|
-
|
|
3884
|
+
const existing = readPanelEndpoint();
|
|
3885
|
+
if (existing?.pid && existing.pid !== process.pid && isPidAlive(existing.pid)) {
|
|
3886
|
+
// Someone else already holds a live registration (most likely a longer-
|
|
3887
|
+
// lived session than us) — don't clobber it. Our own panel is still up
|
|
3888
|
+
// and fully usable via `url` for anything that already has it (e.g. this
|
|
3889
|
+
// process's own Code side-panel proxy); we just don't publish ourselves
|
|
3890
|
+
// as THE shared menu-bar target.
|
|
3891
|
+
return;
|
|
3892
|
+
}
|
|
3893
|
+
fs.writeFileSync(panelEndpointPath(), JSON.stringify({ url, pid: process.pid, version: VERSION, started_at: new Date().toISOString() }, null, 2) + "\n", "utf-8");
|
|
3629
3894
|
}
|
|
3630
3895
|
catch (e) {
|
|
3631
3896
|
console.error("[social-autoposter-mcp] writePanelUrl failed:", e?.message || e);
|
|
3632
3897
|
}
|
|
3633
3898
|
}
|
|
3899
|
+
// Relinquish panel-endpoint.json on clean exit if we currently own it, so a
|
|
3900
|
+
// short-lived process (typical for the ~/.s4l-worker queue runner or a one-off
|
|
3901
|
+
// Claude Code session) never leaves a dead pid lingering as a false-positive
|
|
3902
|
+
// registrant — the next process to check sees "nothing registered" (clean,
|
|
3903
|
+
// correctly reported as unreachable) rather than a stale pointer that only
|
|
3904
|
+
// gets fixed by chance when something else happens to boot.
|
|
3905
|
+
process.on("exit", () => {
|
|
3906
|
+
try {
|
|
3907
|
+
const existing = readPanelEndpoint();
|
|
3908
|
+
if (existing?.pid === process.pid) {
|
|
3909
|
+
fs.unlinkSync(panelEndpointPath());
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
catch {
|
|
3913
|
+
// best-effort; nothing to do if it's already gone or unreadable
|
|
3914
|
+
}
|
|
3915
|
+
});
|
|
3634
3916
|
// The owned state dir, honoring S4L_STATE_DIR (matches menubar/s4l_state.py).
|
|
3635
3917
|
function s4lStateDir() {
|
|
3636
3918
|
return (process.env.S4L_STATE_DIR ||
|
|
@@ -3742,6 +4024,22 @@ function isPostingFlagFresh() {
|
|
|
3742
4024
|
return false;
|
|
3743
4025
|
}
|
|
3744
4026
|
}
|
|
4027
|
+
// True when a DIFFERENT process holds a fresh posting flag — i.e. a sibling MCP
|
|
4028
|
+
// instance is mid-drain. Our own fresh flag doesn't count: same-process
|
|
4029
|
+
// re-entrancy is covered by the in-memory `postingActive`, and a leftover flag
|
|
4030
|
+
// from our own earlier drain (a crash before the finally cleared it) must not
|
|
4031
|
+
// deadlock us against ourselves.
|
|
4032
|
+
function isPeerDrainActive() {
|
|
4033
|
+
try {
|
|
4034
|
+
const j = JSON.parse(fs.readFileSync(postingFlagPath(), "utf-8"));
|
|
4035
|
+
if (typeof j?.expires_at !== "number" || j.expires_at <= Date.now())
|
|
4036
|
+
return false;
|
|
4037
|
+
return typeof j?.pid === "number" && j.pid !== process.pid;
|
|
4038
|
+
}
|
|
4039
|
+
catch {
|
|
4040
|
+
return false;
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
3745
4043
|
// activity.json: a tiny "what's running right now" signal the menu bar reads to
|
|
3746
4044
|
// show a loading spinner + label (scanning / drafting / posting / …). Written by
|
|
3747
4045
|
// long-running tools, cleared when they finish. Best-effort; absence == idle.
|
|
@@ -4006,14 +4304,27 @@ function scheduleShellLockRelease() {
|
|
|
4006
4304
|
releaseShellBrowserLock();
|
|
4007
4305
|
}, SHELL_LOCK_GRACE_MS);
|
|
4008
4306
|
}
|
|
4009
|
-
// SIGKILL
|
|
4010
|
-
// at once.
|
|
4307
|
+
// SIGKILL whatever live process holds the shell browser lock so the post takes
|
|
4308
|
+
// the browser at once. Universal preemption (2026-07-07, explicit user call):
|
|
4309
|
+
// posting always wins over ANY other CLI Twitter job — the discovery scan,
|
|
4310
|
+
// DM engagement, DM outreach, thread posting, follow-up scans, everything.
|
|
4311
|
+
// This is a deliberate, informed tradeoff, not an oversight: unlike the scan
|
|
4312
|
+
// (read-only, relaunches every minute, nothing to lose), several of these
|
|
4313
|
+
// jobs are mid-*send* when they hold this lock (engage-twitter.sh Phase B
|
|
4314
|
+
// replies, dm-outreach-twitter.sh / engage-dm-replies.sh send DMs,
|
|
4315
|
+
// run-twitter-threads.sh posts a multi-tweet thread). Killing one of those at
|
|
4316
|
+
// the wrong instant can leave an action landed on X with its "we did this"
|
|
4317
|
+
// bookkeeping never written, so it silently retries and double-sends next
|
|
4318
|
+
// cycle — the same class of bug this file's ghost-post handling exists to
|
|
4319
|
+
// avoid, just now possible for DMs/threads too. Accepted in exchange for
|
|
4320
|
+
// posting never waiting on anything. Best-effort; never throws.
|
|
4011
4321
|
function preemptScanHoldingBrowser() {
|
|
4012
4322
|
try {
|
|
4013
4323
|
const pid = shellLockHolderPid();
|
|
4014
|
-
if (pid && pidAlive(pid)
|
|
4015
|
-
|
|
4016
|
-
|
|
4324
|
+
if (pid && pidAlive(pid)) {
|
|
4325
|
+
const label = pidIsScan(pid) ? "scan" : "peer job";
|
|
4326
|
+
console.error(`[post] preempting cross-process ${label} holding the twitter-browser lock (pid ${pid}) — SIGKILL tree`);
|
|
4327
|
+
logPostEvent(`preempt_holder_holding_browser pid=${pid} kind=${label}`);
|
|
4017
4328
|
sigkillScanTree(pid);
|
|
4018
4329
|
}
|
|
4019
4330
|
}
|
|
@@ -4021,9 +4332,31 @@ function preemptScanHoldingBrowser() {
|
|
|
4021
4332
|
/* best effort */
|
|
4022
4333
|
}
|
|
4023
4334
|
}
|
|
4024
|
-
// Take (or extend) the shell browser lock for the batch
|
|
4025
|
-
//
|
|
4026
|
-
//
|
|
4335
|
+
// Take (or extend) the shell browser lock for the batch, so posting is aware of
|
|
4336
|
+
// EVERY CLI Twitter job, not just the discovery scan. The lock dir itself is the
|
|
4337
|
+
// source of truth: 8+ scripts (engage-twitter.sh, dm-outreach-twitter.sh,
|
|
4338
|
+
// run-twitter-threads.sh, engage-dm-replies.sh, scan-twitter-followups.sh,
|
|
4339
|
+
// refresh-twitter-following.sh, audit.sh, invent-supply-test.sh, in addition to
|
|
4340
|
+
// run-twitter-cycle.sh) all take this exact dir before touching the shared
|
|
4341
|
+
// harness Chrome, so whoever holds it is doing real browser work by construction
|
|
4342
|
+
// — there is no per-script allowlist to maintain. 2026-07-07 incident: only
|
|
4343
|
+
// run-twitter-cycle.sh was ever recognized as preemptable, so posting fell
|
|
4344
|
+
// through to "proceed unguarded" against every OTHER script and collided with a
|
|
4345
|
+
// LIVE engage-twitter.sh (DM/mentions engagement) mid-reply — both processes
|
|
4346
|
+
// reused the same open x.com tab (get_browser_and_page prefers a reusable
|
|
4347
|
+
// Twitter tab), so engage-twitter.sh's own navigation yanked the composer away
|
|
4348
|
+
// mid-type and got one candidate wrongly classified tweet_unavailable.
|
|
4349
|
+
//
|
|
4350
|
+
// UNIVERSAL PREEMPTION (2026-07-07, explicit user call, superseding the
|
|
4351
|
+
// wait-for-non-scan-peers version that briefly shipped in rc.13): posting
|
|
4352
|
+
// SIGKILLs whatever holds this lock, full stop — no waiting on anyone,
|
|
4353
|
+
// scan or not. Traded away deliberately: several of these jobs are mid-*send*
|
|
4354
|
+
// when they hold the lock (DM outreach/replies, thread posting, mention
|
|
4355
|
+
// replies), so killing one at the wrong instant can leave an action landed on
|
|
4356
|
+
// X with its own "we did this" bookkeeping never written -> a silent retry
|
|
4357
|
+
// double-sends next cycle, the same class of bug this file's ghost-post
|
|
4358
|
+
// handling exists to guard against. Accepted in exchange for posting never
|
|
4359
|
+
// blocking on anything else running.
|
|
4027
4360
|
async function acquireShellBrowserLock() {
|
|
4028
4361
|
// A new post cancels any pending grace-release and EXTENDS the existing hold.
|
|
4029
4362
|
cancelScheduledShellLockRelease();
|
|
@@ -4047,25 +4380,22 @@ async function acquireShellBrowserLock() {
|
|
|
4047
4380
|
// Write the pid IMMEDIATELY (sync) so the dir is never observably pid-less.
|
|
4048
4381
|
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
|
|
4049
4382
|
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
|
|
4050
|
-
console.error(`[post] holding twitter-browser shell lock pid=${process.pid} —
|
|
4383
|
+
console.error(`[post] holding twitter-browser shell lock pid=${process.pid} — every other CLI Twitter job yields`);
|
|
4051
4384
|
return true;
|
|
4052
4385
|
}
|
|
4053
4386
|
catch {
|
|
4054
|
-
// Dir exists. Reclaim if the holder is dead; SIGKILL-preempt
|
|
4055
|
-
// otherwise
|
|
4387
|
+
// Dir exists. Reclaim if the holder is dead; SIGKILL-preempt unconditionally
|
|
4388
|
+
// otherwise — scan or not, posting always wins.
|
|
4056
4389
|
const pid = shellLockHolderPid();
|
|
4057
4390
|
if (!pid || !pidAlive(pid)) {
|
|
4058
4391
|
rmShellLockDir();
|
|
4059
4392
|
}
|
|
4060
|
-
else
|
|
4061
|
-
logPostEvent(`
|
|
4062
|
-
sigkillScanTree(pid); // SIGKILL —
|
|
4393
|
+
else {
|
|
4394
|
+
logPostEvent(`preempt_holder_on_lock_acquire pid=${pid} attempt=${attempt}`);
|
|
4395
|
+
sigkillScanTree(pid); // SIGKILL — these jobs don't reliably yield to SIGTERM
|
|
4063
4396
|
await sleepMs(300);
|
|
4064
4397
|
rmShellLockDir();
|
|
4065
4398
|
}
|
|
4066
|
-
else {
|
|
4067
|
-
return false; // a real peer holds it — don't steal; proceed
|
|
4068
|
-
}
|
|
4069
4399
|
await sleepMs(200);
|
|
4070
4400
|
}
|
|
4071
4401
|
}
|