@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 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: "loading 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 `scanning X · N/M · kept K` instead
609
- // of a static "scanning X". Best-effort: missing total falls back to a plain
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", "scanning X");
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", `scanning X · ${prog} · kept ${scanKept}`);
673
+ writeActivity("scanning", `scan ${prog} +${scanKept}`);
669
674
  }
670
675
  if (/Phase 2b-prep/.test(t))
671
- writeActivity("drafting", "drafting replies");
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 50/50), and the persona " +
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 action:'set' with personal_brand:true, " +
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 = 7; // 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.
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 (hourly kicker, retried
2692
- // forever) and may legitimately queue behind a multi-minute draft job;
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). Make your first tool call ` +
2763
- `promptly, and if the job's prompt gives you per-item persist commands to run ` +
2764
- `(its own quick Bash calls), run them as you complete each item instead of ` +
2765
- `working silently those calls are what keep the session alive. The prompt ` +
2766
- `file may begin with a WORKER EXECUTION NOTES header; follow it exactly.`,
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. Claim the next job. Run this EXACT Bash command:`,
2770
- ` ${py} ${job} next --type any --prompt-file --state-dir ${sd}`,
2771
- ` It prints one line of JSON. If it prints "{}" (empty), there is NO work — ` +
2772
- `report "no jobs" in one line and STOP. You are done.`,
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
- // Hourly, stdlib-only under SYSTEM python (http_api + learned_preferences use
3273
- // urllib/json only; run_claude.sh resolves the claude CLI itself). A run with
3274
- // no unprocessed review_events for this install is a cheap no-op, so the job
3275
- // is installed unconditionally like the reaper. Content-aware install so an
3276
- // already-installed box picks up changed args on the next Claude boot.
3277
- const FEEDBACK_DIGEST_INTERVAL_SECS = 3600;
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 hourly tick is enough
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
- fs.writeFileSync(path.join(dir, "panel-endpoint.json"), JSON.stringify({ url, pid: process.pid, version: VERSION, started_at: new Date().toISOString() }, null, 2) + "\n", "utf-8");
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 a live scan holding the shell browser lock so the post takes the browser
4153
- // at once. Best-effort; only ever targets a run-twitter-cycle.sh.
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) && pidIsScan(pid)) {
4158
- console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid}) SIGKILL tree`);
4159
- logPostEvent(`preempt_scan_holding_browser scan_pid=${pid}`);
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. Preempts a scan holder
4168
- // with SIGKILL; never steals from a live non-scan holder (a peer poster) there
4169
- // it returns false and posting proceeds unguarded (no worse than before).
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} — scans queue behind the post`);
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 if it's a scan;
4198
- // otherwise (a live peer poster) leave it and post unguarded.
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 if (pidIsScan(pid)) {
4204
- logPostEvent(`preempt_scan_on_lock_acquire scan_pid=${pid} attempt=${attempt}`);
4205
- sigkillScanTree(pid); // SIGKILL — scans trap SIGTERM and survive it
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
  }