@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.
Files changed (42) hide show
  1. package/mcp/dist/index.js +397 -67
  2. package/mcp/dist/panel.html +27 -17
  3. package/mcp/dist/product-link.html +1 -1
  4. package/mcp/dist/version.json +2 -2
  5. package/mcp/manifest.json +5 -1
  6. package/mcp/menubar/dashboard_server.py +39 -9
  7. package/mcp/menubar/s4l_card.py +105 -18
  8. package/mcp/menubar/s4l_menubar.py +290 -42
  9. package/mcp/menubar/s4l_state.py +55 -3
  10. package/mcp/package.json +1 -1
  11. package/package.json +1 -1
  12. package/scripts/autopilot_stall_watch.py +18 -6
  13. package/scripts/claude_job.py +92 -14
  14. package/scripts/feedback_digest.py +53 -9
  15. package/scripts/invent_topics.py +261 -223
  16. package/scripts/link_tail.py +44 -1
  17. package/scripts/preview_translated_card.py +30 -0
  18. package/scripts/reap_stale_claude_sessions.py +20 -25
  19. package/scripts/s4l_mode.py +87 -11
  20. package/scripts/schedule_state.py +16 -4
  21. package/scripts/scheduled_tasks_snapshot.py +43 -0
  22. package/scripts/sentry_init.py +11 -4
  23. package/scripts/snapshot.py +13 -0
  24. package/scripts/twitter_browser.py +20 -35
  25. package/scripts/twitter_gen_links.py +138 -0
  26. package/scripts/twitter_post_plan.py +70 -57
  27. package/setup/SKILL.md +14 -6
  28. package/skill/audit.sh +29 -1
  29. package/skill/dm-outreach-linkedin.sh +23 -2
  30. package/skill/engage-dm-replies.sh +28 -2
  31. package/skill/engage-linkedin.sh +23 -2
  32. package/skill/invent-supply-test.sh +3 -1
  33. package/skill/invent-topics.sh +18 -12
  34. package/skill/lib/linkedin-backend.sh +40 -13
  35. package/skill/linkedin-presence.sh +7 -0
  36. package/skill/linkedin-recovery.sh +8 -1
  37. package/skill/lock.sh +52 -12
  38. package/skill/run-draft-and-publish.sh +58 -3
  39. package/skill/run-linkedin.sh +23 -2
  40. package/skill/run-twitter-cycle.sh +3 -1
  41. package/skill/stats-linkedin.sh +11 -1
  42. 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: "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);
@@ -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
- writePlan(batchId, plan);
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
- /* best effort */
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 50/50): PERSONAL BRAND (organic, link-free engagement in " +
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({ flags, mode, persona: persona ? persona.name : null });
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 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 " +
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): tell the user to answer ALL of the " +
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). Ask verbatim, as a single numbered " +
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 the user " +
1779
- "declines or gives nothing usable, fall back to scan-derived topics. " +
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 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, " +
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 = 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.
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 (hourly kicker, retried
2565
- // forever) and may legitimately queue behind a multi-minute draft job;
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). Make your first tool call ` +
2636
- `promptly, and if the job's prompt gives you per-item persist commands to run ` +
2637
- `(its own quick Bash calls), run them as you complete each item instead of ` +
2638
- `working silently those calls are what keep the session alive. The prompt ` +
2639
- `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.`,
2640
2853
  ``,
2641
2854
  `Steps:`,
2642
- `1. Claim the next job. Run this EXACT Bash command:`,
2643
- ` ${py} ${job} next --type any --prompt-file --state-dir ${sd}`,
2644
- ` It prints one line of JSON. If it prints "{}" (empty), there is NO work — ` +
2645
- `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.`,
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
- // Hourly, stdlib-only under SYSTEM python (http_api + learned_preferences use
3146
- // urllib/json only; run_claude.sh resolves the claude CLI itself). A run with
3147
- // no unprocessed review_events for this install is a cheap no-op, so the job
3148
- // is installed unconditionally like the reaper. Content-aware install so an
3149
- // already-installed box picks up changed args on the next Claude boot.
3150
- 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;
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 hourly tick is enough
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
- 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");
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 a live scan holding the shell browser lock so the post takes the browser
4010
- // 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.
4011
4321
  function preemptScanHoldingBrowser() {
4012
4322
  try {
4013
4323
  const pid = shellLockHolderPid();
4014
- if (pid && pidAlive(pid) && pidIsScan(pid)) {
4015
- console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid}) SIGKILL tree`);
4016
- 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}`);
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. Preempts a scan holder
4025
- // with SIGKILL; never steals from a live non-scan holder (a peer poster) there
4026
- // 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.
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} — scans queue behind the post`);
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 if it's a scan;
4055
- // 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.
4056
4389
  const pid = shellLockHolderPid();
4057
4390
  if (!pid || !pidAlive(pid)) {
4058
4391
  rmShellLockDir();
4059
4392
  }
4060
- else if (pidIsScan(pid)) {
4061
- logPostEvent(`preempt_scan_on_lock_acquire scan_pid=${pid} attempt=${attempt}`);
4062
- 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
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
  }