@m13v/s4l 1.6.204-rc.9 → 1.7.0
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 +243 -56
- package/mcp/dist/panel.html +16 -16
- 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 +38 -14
- package/mcp/menubar/s4l_menubar.py +242 -38
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/autopilot_stall_watch.py +18 -6
- package/scripts/claude_job.py +88 -16
- package/scripts/feedback_digest.py +53 -9
- 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/schedule_state.py +16 -4
- package/scripts/scheduled_tasks_snapshot.py +43 -0
- package/scripts/sentry_init.py +11 -4
- package/scripts/twitter_browser.py +20 -35
- package/scripts/twitter_gen_links.py +138 -0
- package/scripts/twitter_post_plan.py +70 -57
- 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/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 +18 -6
- package/skill/run-linkedin.sh +23 -2
- package/skill/stats-linkedin.sh +11 -1
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);
|
|
@@ -939,6 +944,24 @@ async function postApproved(batchId, plan) {
|
|
|
939
944
|
// batch so the every-minute autopilot scan queues behind the post instead of
|
|
940
945
|
// seizing Chrome mid-batch — the root cause of approved batches landing 0/N.
|
|
941
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
|
+
}
|
|
942
965
|
const approvedBatch = `${batchId}_approved`;
|
|
943
966
|
writePlan(approvedBatch, { ...plan, candidates: approved });
|
|
944
967
|
// S4L_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
|
|
@@ -976,15 +999,23 @@ async function postApproved(batchId, plan) {
|
|
|
976
999
|
// opts.env AFTER process.env, and twitter_post_plan.py never load_dotenv's
|
|
977
1000
|
// with override, so nothing clobbers it. Cron is untouched (it never goes
|
|
978
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.
|
|
979
1018
|
TWITTER_TAIL_LINK_RATE: "1.0",
|
|
980
|
-
// Plugin flow only: skip the link_tail Claude call. It just rewords
|
|
981
|
-
// prose around the URL (the minted short link comes from the
|
|
982
|
-
// deterministic wrap step), and on .mcpb boxes there's no `claude`
|
|
983
|
-
// binary so it wastes ~35s/post of run_claude.sh retry backoff before
|
|
984
|
-
// falling back to the mechanical concat anyway. link_tail.py honors
|
|
985
|
-
// this and short-circuits to that concat instantly. The local
|
|
986
|
-
// cron/plist autopilot never sets this, so it keeps generating the
|
|
987
|
-
// bridge sentence.
|
|
988
1019
|
S4L_SKIP_LINK_TAIL: "1",
|
|
989
1020
|
// The poster attaches to the twitter-harness Chrome over CDP. The cron
|
|
990
1021
|
// pipeline exports this from skill/lib/twitter-backend.sh; the MCP path
|
|
@@ -1576,7 +1607,8 @@ tool("engagement_mode", {
|
|
|
1576
1607
|
const bothOn = personalBrand && promotion;
|
|
1577
1608
|
const next_step = promotion
|
|
1578
1609
|
? (bothOn
|
|
1579
|
-
? "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 " +
|
|
1580
1612
|
"is provisioned + topic-seeded. "
|
|
1581
1613
|
: "Product promotion is on and the persona is provisioned. ") +
|
|
1582
1614
|
"NOW CONTINUE SETUP: configure the product project with project_config (research the product " +
|
|
@@ -1907,7 +1939,8 @@ tool("project_config", {
|
|
|
1907
1939
|
"THIRD (engagement lanes — ASK THE USER, do not infer): the PERSONAL BRAND lane (organic, " +
|
|
1908
1940
|
"link-free engagement in their own voice) is ON by default, so ask the ONE question — do they " +
|
|
1909
1941
|
"ALSO want to PROMOTE a PRODUCT (the marketing lane, link replies)? Both lanes can run (the " +
|
|
1910
|
-
"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, " +
|
|
1911
1944
|
"promotion:true|false AND the voice/description/search_topics you synthesized PLUS the raw " +
|
|
1912
1945
|
"dictation transcript as content_corpus (this provisions the persona and seeds topics). Only " +
|
|
1913
1946
|
"NOW are topics seeded — postponed until the dictation is in. " +
|
|
@@ -1946,6 +1979,7 @@ tool("project_config", {
|
|
|
1946
1979
|
update_available: !!snap.update_available,
|
|
1947
1980
|
mode: snap.mode,
|
|
1948
1981
|
flags: snap.flags,
|
|
1982
|
+
personal_brand_share: snap.personal_brand_share,
|
|
1949
1983
|
update_hint: snap.update_available
|
|
1950
1984
|
? `A newer version (${snap.latest_version}) is available — you're on ${snap.version}. ` +
|
|
1951
1985
|
`Tell the user and offer to run the \`runtime\` tool with action:'update' ` +
|
|
@@ -2459,6 +2493,44 @@ tool("restart_menubar", {
|
|
|
2459
2493
|
menubar_running: running,
|
|
2460
2494
|
});
|
|
2461
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
|
+
});
|
|
2462
2534
|
function runtimeSnapshot() {
|
|
2463
2535
|
const rt = readRuntime();
|
|
2464
2536
|
const progress = readProgress();
|
|
@@ -2634,8 +2706,19 @@ async function autopilotLoaded() {
|
|
|
2634
2706
|
// fires every minute, claims ONE job, runs the pipeline's own prompt as its
|
|
2635
2707
|
// Claude turn, writes the result back, and stops.
|
|
2636
2708
|
// ===========================================================================
|
|
2637
|
-
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.
|
|
2638
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;
|
|
2639
2722
|
// One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
|
|
2640
2723
|
const QUEUE_WORKERS = [
|
|
2641
2724
|
{ taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
|
|
@@ -2688,9 +2771,9 @@ function autopilotStalled() {
|
|
|
2688
2771
|
for (const sub of fs.readdirSync(pendRoot, { withFileTypes: true })) {
|
|
2689
2772
|
if (!sub.isDirectory())
|
|
2690
2773
|
continue;
|
|
2691
|
-
// feedback-digest jobs are latency-insensitive (
|
|
2692
|
-
// forever) and may legitimately queue behind a multi-minute
|
|
2693
|
-
// 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.
|
|
2694
2777
|
if (sub.name === "feedback-digest")
|
|
2695
2778
|
continue;
|
|
2696
2779
|
const subPath = path.join(pendRoot, sub.name);
|
|
@@ -2759,17 +2842,24 @@ function queueWorkerBody(spec) {
|
|
|
2759
2842
|
`other tool, or trying to "investigate", STALLS it forever.`,
|
|
2760
2843
|
``,
|
|
2761
2844
|
`PACING — CRITICAL: this unattended session is terminated ~90 seconds after ` +
|
|
2762
|
-
`your LAST tool call (a host inactivity timeout).
|
|
2763
|
-
`
|
|
2764
|
-
`
|
|
2765
|
-
`
|
|
2766
|
-
`
|
|
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.`,
|
|
2767
2853
|
``,
|
|
2768
2854
|
`Steps:`,
|
|
2769
|
-
`1.
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
`
|
|
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.`,
|
|
2773
2863
|
`2. Otherwise it prints {"job_id":"...","prompt_file":"...","schema_file":...}. ` +
|
|
2774
2864
|
`Use the Read tool to read prompt_file; it is the complete, self-contained ` +
|
|
2775
2865
|
`instruction the pipeline wrote for you. If the Read result says it is partial ` +
|
|
@@ -3269,12 +3359,19 @@ async function ensureClaudeReaperInstalled() {
|
|
|
3269
3359
|
}
|
|
3270
3360
|
}
|
|
3271
3361
|
// ---- launchd feedback digest: card decisions -> learned_preferences ---------
|
|
3272
|
-
//
|
|
3273
|
-
//
|
|
3274
|
-
//
|
|
3275
|
-
//
|
|
3276
|
-
//
|
|
3277
|
-
|
|
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;
|
|
3278
3375
|
async function ensureFeedbackDigestInstalled() {
|
|
3279
3376
|
try {
|
|
3280
3377
|
if (process.platform !== "darwin")
|
|
@@ -3290,7 +3387,7 @@ async function ensureFeedbackDigestInstalled() {
|
|
|
3290
3387
|
label: FEEDBACK_DIGEST_LABEL,
|
|
3291
3388
|
programArgs: ["/usr/bin/python3", path.join(repoDir(), "scripts", "feedback_digest.py")],
|
|
3292
3389
|
intervalSecs: FEEDBACK_DIGEST_INTERVAL_SECS,
|
|
3293
|
-
runAtLoad: false, // no boot-time Claude runs; the
|
|
3390
|
+
runAtLoad: false, // no boot-time Claude runs; the every-minute tick is enough
|
|
3294
3391
|
stdoutLog: path.join(logDir, "launchd-feedback-digest-stdout.log"),
|
|
3295
3392
|
stderrLog: path.join(logDir, "launchd-feedback-digest-stderr.log"),
|
|
3296
3393
|
});
|
|
@@ -3507,7 +3604,7 @@ async function scheduleState() {
|
|
|
3507
3604
|
try {
|
|
3508
3605
|
const res = await runPython("scripts/schedule_state.py", [], { timeoutMs: 15_000 });
|
|
3509
3606
|
const state = JSON.parse(res.stdout.trim()).state;
|
|
3510
|
-
if (state === "ok" || state === "disabled")
|
|
3607
|
+
if (state === "ok" || state === "disabled" || state === "stalled")
|
|
3511
3608
|
return state;
|
|
3512
3609
|
return "missing";
|
|
3513
3610
|
}
|
|
@@ -3741,23 +3838,81 @@ function startLocalPanel() {
|
|
|
3741
3838
|
});
|
|
3742
3839
|
});
|
|
3743
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
|
+
}
|
|
3744
3861
|
// Publish the loopback URL to stable files so out-of-process readers can find
|
|
3745
3862
|
// the ephemeral port without scraping `lsof`:
|
|
3746
3863
|
// - panel-url plain text, for the Claude Code side-panel reverse proxy.
|
|
3747
3864
|
// - panel-endpoint.json richer (url + version + pid), for the menu bar app,
|
|
3748
3865
|
// which POSTs /tool/<name> here for live data.
|
|
3749
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.
|
|
3750
3879
|
function writePanelUrl(url) {
|
|
3751
3880
|
try {
|
|
3752
3881
|
const dir = path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp");
|
|
3753
3882
|
fs.mkdirSync(dir, { recursive: true });
|
|
3754
3883
|
fs.writeFileSync(path.join(dir, "panel-url"), url, "utf-8");
|
|
3755
|
-
|
|
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");
|
|
3756
3894
|
}
|
|
3757
3895
|
catch (e) {
|
|
3758
3896
|
console.error("[social-autoposter-mcp] writePanelUrl failed:", e?.message || e);
|
|
3759
3897
|
}
|
|
3760
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
|
+
});
|
|
3761
3916
|
// The owned state dir, honoring S4L_STATE_DIR (matches menubar/s4l_state.py).
|
|
3762
3917
|
function s4lStateDir() {
|
|
3763
3918
|
return (process.env.S4L_STATE_DIR ||
|
|
@@ -4149,14 +4304,27 @@ function scheduleShellLockRelease() {
|
|
|
4149
4304
|
releaseShellBrowserLock();
|
|
4150
4305
|
}, SHELL_LOCK_GRACE_MS);
|
|
4151
4306
|
}
|
|
4152
|
-
// SIGKILL
|
|
4153
|
-
// 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.
|
|
4154
4321
|
function preemptScanHoldingBrowser() {
|
|
4155
4322
|
try {
|
|
4156
4323
|
const pid = shellLockHolderPid();
|
|
4157
|
-
if (pid && pidAlive(pid)
|
|
4158
|
-
|
|
4159
|
-
|
|
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}`);
|
|
4160
4328
|
sigkillScanTree(pid);
|
|
4161
4329
|
}
|
|
4162
4330
|
}
|
|
@@ -4164,9 +4332,31 @@ function preemptScanHoldingBrowser() {
|
|
|
4164
4332
|
/* best effort */
|
|
4165
4333
|
}
|
|
4166
4334
|
}
|
|
4167
|
-
// Take (or extend) the shell browser lock for the batch
|
|
4168
|
-
//
|
|
4169
|
-
//
|
|
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.
|
|
4170
4360
|
async function acquireShellBrowserLock() {
|
|
4171
4361
|
// A new post cancels any pending grace-release and EXTENDS the existing hold.
|
|
4172
4362
|
cancelScheduledShellLockRelease();
|
|
@@ -4190,25 +4380,22 @@ async function acquireShellBrowserLock() {
|
|
|
4190
4380
|
// Write the pid IMMEDIATELY (sync) so the dir is never observably pid-less.
|
|
4191
4381
|
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
|
|
4192
4382
|
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
|
|
4193
|
-
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`);
|
|
4194
4384
|
return true;
|
|
4195
4385
|
}
|
|
4196
4386
|
catch {
|
|
4197
|
-
// Dir exists. Reclaim if the holder is dead; SIGKILL-preempt
|
|
4198
|
-
// otherwise
|
|
4387
|
+
// Dir exists. Reclaim if the holder is dead; SIGKILL-preempt unconditionally
|
|
4388
|
+
// otherwise — scan or not, posting always wins.
|
|
4199
4389
|
const pid = shellLockHolderPid();
|
|
4200
4390
|
if (!pid || !pidAlive(pid)) {
|
|
4201
4391
|
rmShellLockDir();
|
|
4202
4392
|
}
|
|
4203
|
-
else
|
|
4204
|
-
logPostEvent(`
|
|
4205
|
-
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
|
|
4206
4396
|
await sleepMs(300);
|
|
4207
4397
|
rmShellLockDir();
|
|
4208
4398
|
}
|
|
4209
|
-
else {
|
|
4210
|
-
return false; // a real peer holds it — don't steal; proceed
|
|
4211
|
-
}
|
|
4212
4399
|
await sleepMs(200);
|
|
4213
4400
|
}
|
|
4214
4401
|
}
|