@m13v/s4l 1.6.203-rc.8 → 1.6.203

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 (58) hide show
  1. package/mcp/dist/index.js +118 -87
  2. package/mcp/dist/panel.html +1 -1
  3. package/mcp/dist/product-link.html +9 -9
  4. package/mcp/dist/repo.js +1 -1
  5. package/mcp/dist/runtime.js +7 -13
  6. package/mcp/dist/setup.js +3 -3
  7. package/mcp/dist/telemetry.js +6 -8
  8. package/mcp/dist/version.js +1 -1
  9. package/mcp/dist/version.json +2 -2
  10. package/mcp/install.mjs +2 -2
  11. package/mcp/manifest.json +1 -1
  12. package/mcp/menubar/dashboard_server.py +3 -6
  13. package/mcp/menubar/s4l_card.py +171 -42
  14. package/mcp/menubar/s4l_menubar.py +80 -28
  15. package/mcp/menubar/s4l_state.py +23 -5
  16. package/mcp/package.json +2 -2
  17. package/mcp/shared/doctor.cjs +0 -2
  18. package/mcp/shared/onboarding-ledger.cjs +0 -1
  19. package/mcp-servers/browser-harness/server.py +2 -2
  20. package/package.json +1 -1
  21. package/requirements.txt +3 -3
  22. package/scripts/active_experiments.py +112 -0
  23. package/scripts/apply_onboarding_selections.py +5 -5
  24. package/scripts/build_persona.py +5 -5
  25. package/scripts/claude_job.py +16 -3
  26. package/scripts/copy_browser_cookies.py +1 -1
  27. package/scripts/dev_dashboard_server.py +1 -1
  28. package/scripts/feedback_digest.py +18 -1
  29. package/scripts/harness_overlay.py +17 -17
  30. package/scripts/log_draft.py +1 -1
  31. package/scripts/log_post.py +3 -3
  32. package/scripts/memory_snapshot.py +3 -3
  33. package/scripts/merge_review_queue.py +6 -0
  34. package/scripts/reap_stale_claude_sessions.py +3 -3
  35. package/scripts/relay_provider_log.py +186 -0
  36. package/scripts/s4l_activity.py +126 -0
  37. package/scripts/s4l_mode.py +414 -0
  38. package/scripts/salvage_orphaned_prep_results.py +178 -0
  39. package/scripts/saps_activity.py +10 -118
  40. package/scripts/saps_mode.py +8 -387
  41. package/scripts/setup_twitter_auth.py +1 -1
  42. package/scripts/snapshot.py +1 -1
  43. package/scripts/twitter_post_plan.py +88 -13
  44. package/scripts/watchdog_hung_runs.py +12 -12
  45. package/skill/engage-linkedin.sh +4 -4
  46. package/skill/engage-twitter.sh +4 -4
  47. package/skill/lib/linkedin-backend.sh +1 -1
  48. package/skill/run-draft-and-publish.sh +83 -34
  49. package/skill/run-instagram-render.sh +3 -3
  50. package/skill/run-linkedin.sh +5 -5
  51. package/skill/run-reddit-threads.sh +5 -5
  52. package/skill/run-twitter-cycle.sh +91 -50
  53. package/skill/social-autoposter-update.sh +3 -1
  54. package/skill/styles.sh +10 -10
  55. package/skill/topics.sh +8 -8
  56. package/skill/run-cycle-update-guard.sh +0 -44
  57. package/skill/run-twitter-cycle-launchd.sh +0 -63
  58. package/skill/run-twitter-cycle-singleton.sh +0 -62
package/mcp/dist/index.js CHANGED
@@ -156,22 +156,6 @@ function pipelinePath() {
156
156
  function launchdPath() {
157
157
  return [...ownedBinDirs(), LAUNCHD_PATH].join(":");
158
158
  }
159
- // Brand rename 2026-07-03 (SAPS_ -> S4L_): duplicate every S4L_* key under its
160
- // legacy SAPS_* name when emitting env into child processes / plists, so an
161
- // old pipeline-script version still on disk during a partial update keeps
162
- // resolving its env. Never overwrites an explicitly-set legacy key. Remove
163
- // once no pre-rename scripts remain in the field.
164
- function withSapsEnvCompat(env) {
165
- const out = { ...env };
166
- for (const [k, v] of Object.entries(env)) {
167
- if (k.startsWith("S4L_")) {
168
- const legacy = "SAPS_" + k.slice(4);
169
- if (!(legacy in out))
170
- out[legacy] = v;
171
- }
172
- }
173
- return out;
174
- }
175
159
  function plistXml(opts) {
176
160
  const args = opts.programArgs.map((a) => `\t\t<string>${a}</string>`).join("\n");
177
161
  const schedule = opts.keepAlive
@@ -187,10 +171,8 @@ function plistXml(opts) {
187
171
  : "";
188
172
  // Caller-supplied env (e.g. the queue kicker's DRAFT_ONLY / S4L_CLAUDE_PROVIDER).
189
173
  // Rendered after the baked-in vars so a caller can also override S4L_STATE_DIR.
190
- // Dual-named (S4L_* + legacy SAPS_*) so a freshly-written plist still works
191
- // with any pre-rename script version on disk during a partial update.
192
174
  const extraEnv = opts.extraEnv
193
- ? Object.entries(withSapsEnvCompat(opts.extraEnv))
175
+ ? Object.entries(opts.extraEnv)
194
176
  .map(([k, v]) => `\n\t\t<key>${k}</key>\n\t\t<string>${v}</string>`)
195
177
  .join("")
196
178
  : "";
@@ -217,11 +199,7 @@ ${schedule}
217
199
  \t\t<string>${os.homedir()}</string>
218
200
  \t\t<key>S4L_REPO_DIR</key>
219
201
  \t\t<string>${repoDir()}</string>
220
- \t\t<key>SAPS_REPO_DIR</key>
221
- \t\t<string>${repoDir()}</string>
222
202
  \t\t<key>S4L_PYTHON</key>
223
- \t\t<string>${resolvePython()}</string>
224
- \t\t<key>SAPS_PYTHON</key>
225
203
  \t\t<string>${resolvePython()}</string>${chromeEnv}${extraEnv}
226
204
  \t</dict>
227
205
  \t<key>RunAtLoad</key>
@@ -408,8 +386,8 @@ function toolActivityLabel(name, args) {
408
386
  const fallback = TOOL_ACTIVITY[name];
409
387
  if (!fallback)
410
388
  return null;
411
- const override = typeof args?.__saps_activity_label === "string"
412
- ? args.__saps_activity_label.replace(/\s+/g, " ").trim().slice(0, 80)
389
+ const override = typeof args?.__s4l_activity_label === "string"
390
+ ? args.__s4l_activity_label.replace(/\s+/g, " ").trim().slice(0, 80)
413
391
  : "";
414
392
  return override || fallback;
415
393
  }
@@ -572,7 +550,7 @@ async function ensureOverlayWatch() {
572
550
  try {
573
551
  await run("bash", ["skill/run-overlay-watch.sh"], {
574
552
  timeoutMs: 20_000,
575
- env: withSapsEnvCompat({
553
+ env: ({
576
554
  S4L_PYTHON: resolvePython(),
577
555
  S4L_LOG_DIR: path.join(repoDir(), "skill", "logs"),
578
556
  TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
@@ -655,7 +633,7 @@ async function produceDrafts(project, onProgress) {
655
633
  // (switched in onLine below). Cleared before every return.
656
634
  writeActivity("scanning", "scanning X");
657
635
  const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
658
- env: withSapsEnvCompat(env),
636
+ env: env,
659
637
  timeoutMs: 900_000, // scan+draft can take several minutes
660
638
  // Fan every cycle line out to THREE sinks so progress is never a black box:
661
639
  // 1. draft_cycle-mcp.log — the stable, documented, host-independent file.
@@ -810,6 +788,16 @@ function parsePostCandidateResults(stdout) {
810
788
  upsert(m[1], "skipped", "empty_reply_text");
811
789
  continue;
812
790
  }
791
+ m = /\[post\] candidate (\d+) log_post\.py did not return post_id/.exec(line);
792
+ if (m) {
793
+ // The reply IS live on X; only the posts-row INSERT failed (e.g. the
794
+ // 2026-07-06 draft_prompt_variant validation 400). Mark the card POSTED
795
+ // so the drain never re-attempts a live reply — re-posting slams into
796
+ // X's duplicate guard at best and double-posts at worst. The missing
797
+ // posts row is recoverable from the post-*.log (reply_url + final_text).
798
+ upsert(m[1], "posted", "log_post_no_id");
799
+ continue;
800
+ }
813
801
  m = /\[post\] candidate (\d+) crashed:/.exec(line);
814
802
  if (m)
815
803
  upsert(m[1], "failed", "exception");
@@ -845,7 +833,7 @@ async function ensurePostingHandle() {
845
833
  try {
846
834
  await runPython("scripts/setup_twitter_auth.py", ["resolve-handle"], {
847
835
  timeoutMs: 60_000,
848
- env: withSapsEnvCompat({ S4L_REPO_DIR: repoDir(), PATH: pipelinePath() }),
836
+ env: ({ S4L_REPO_DIR: repoDir(), PATH: pipelinePath() }),
849
837
  });
850
838
  }
851
839
  catch {
@@ -864,7 +852,7 @@ async function ensureTwitterBrowserForPost() {
864
852
  env.BH_CHROME_BIN = chrome;
865
853
  return run("bash", ["-lc", ". skill/lib/twitter-backend.sh && ensure_twitter_browser_for_backend"], {
866
854
  timeoutMs: 90_000,
867
- env: withSapsEnvCompat(env),
855
+ env: env,
868
856
  onLine: (line) => {
869
857
  const t = line.replace(/\s+$/, "");
870
858
  if (t.trim())
@@ -940,7 +928,7 @@ async function postApproved(batchId, plan) {
940
928
  }
941
929
  return await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
942
930
  timeoutMs: 900_000,
943
- env: withSapsEnvCompat({
931
+ env: ({
944
932
  S4L_SKIP_CAMPAIGN_SUFFIX: "1",
945
933
  // Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
946
934
  // runs TWITTER_TAIL_LINK_RATE=0.9 (from .env) so ~10% of autopilot posts
@@ -1202,7 +1190,7 @@ async function seedSearchQueriesForProject(project, rawQueries) {
1202
1190
  };
1203
1191
  }
1204
1192
  try {
1205
- const qfile = path.join(os.tmpdir(), `saps-queries-${project}-${Date.now()}.json`);
1193
+ const qfile = path.join(os.tmpdir(), `s4l-queries-${project}-${Date.now()}.json`);
1206
1194
  fs.writeFileSync(qfile, JSON.stringify({ queries: agentQueries.map((q) => ({ query: q, topic: "" })) }));
1207
1195
  seedInFlight.set(project, Date.now());
1208
1196
  // Fire-and-forget: runPython keeps the output on the repo.ts tee (so the
@@ -1247,7 +1235,7 @@ async function seedSearchQueriesForProject(project, rawQueries) {
1247
1235
  // ---- engagement_mode: choose personal-brand vs product (setup-time) --------
1248
1236
  // Part of onboarding: AFTER X connect + profile_scan, BEFORE product config, the
1249
1237
  // agent asks the user which mode they want and calls this. It persists the mode
1250
- // (scripts/saps_mode.py, the single source of truth the cycle reads) and
1238
+ // (scripts/s4l_mode.py, the single source of truth the cycle reads) and
1251
1239
  // provisions the persona project (grounded in the profile scan), then the agent
1252
1240
  // continues to product setup — a product is always configured regardless of mode.
1253
1241
  tool("engagement_mode", {
@@ -1323,7 +1311,7 @@ tool("engagement_mode", {
1323
1311
  }, async (args) => {
1324
1312
  const action = args.action || "get";
1325
1313
  const readFlags = async () => {
1326
- const cur = await runPython("scripts/saps_mode.py", ["flags"], { timeoutMs: 15_000 });
1314
+ const cur = await runPython("scripts/s4l_mode.py", ["flags"], { timeoutMs: 15_000 });
1327
1315
  try {
1328
1316
  const f = JSON.parse((cur.stdout || "").trim());
1329
1317
  return { personal_brand: !!f.personal_brand, promotion: !!f.promotion };
@@ -1339,11 +1327,11 @@ tool("engagement_mode", {
1339
1327
  return jsonContent({ flags, mode, persona: persona ? persona.name : null });
1340
1328
  }
1341
1329
  // Lightweight flip of ONE lane (the dashboard/menu-bar quick toggle): just
1342
- // rewrite mode.json via saps_mode.py — NO persona provisioning. Mirrors the
1330
+ // rewrite mode.json via s4l_mode.py — NO persona provisioning. Mirrors the
1343
1331
  // menu bar's pure-local _toggle_lane so flipping from either surface is cheap.
1344
1332
  if (action === "toggle") {
1345
1333
  const lane = args.lane === "promotion" ? "promotion" : "personal_brand";
1346
- const res = await runPython("scripts/saps_mode.py", ["toggle", lane], { timeoutMs: 15_000 });
1334
+ const res = await runPython("scripts/s4l_mode.py", ["toggle", lane], { timeoutMs: 15_000 });
1347
1335
  if (res.code !== 0) {
1348
1336
  const tail = (res.stderr || res.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
1349
1337
  return textContent(`Could not switch lane: ${tail}`);
@@ -1373,16 +1361,16 @@ tool("engagement_mode", {
1373
1361
  }
1374
1362
  const mode = personalBrand ? "personal_brand" : "promotion";
1375
1363
  recordOnboardingAttempt("mode_chosen", { personal_brand: personalBrand, promotion });
1376
- const setRes = await runPython("scripts/saps_mode.py", ["set-flags", personalBrand ? "1" : "0", promotion ? "1" : "0"], { timeoutMs: 15_000 });
1364
+ const setRes = await runPython("scripts/s4l_mode.py", ["set-flags", personalBrand ? "1" : "0", promotion ? "1" : "0"], { timeoutMs: 15_000 });
1377
1365
  if (setRes.code !== 0) {
1378
1366
  const tail = (setRes.stderr || setRes.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
1379
1367
  blockOnboardingMilestone("mode_chosen", "mode_set_failed", tail, { personal_brand: personalBrand, promotion });
1380
1368
  return textContent(`Couldn't save the engagement lanes: ${tail}`);
1381
1369
  }
1382
- // NOTE (2026-07-06): the autopilot flag (mode.json {"autopilot": true} —
1370
+ // NOTE (2026-07-06): the draft-only flag (mode.json {"draft_only": false} —
1383
1371
  // promotion cycles POST autonomously instead of drafting cards) is
1384
1372
  // DELIBERATELY NOT exposed on this tool or any user surface. It is an
1385
- // operator-only switch, set via `scripts/saps_mode.py autopilot on|off`.
1373
+ // operator-only switch, set via `scripts/s4l_mode.py draft-only on|off`.
1386
1374
  // run-draft-and-publish.sh reads it per cycle. Do not add a tool param,
1387
1375
  // menubar toggle, or onboarding step for it.
1388
1376
  // Provision the persona (grounded from the scan when supplied) regardless of
@@ -2502,7 +2490,7 @@ async function autopilotLoaded() {
2502
2490
  // Claude turn, writes the result back, and stops.
2503
2491
  // ===========================================================================
2504
2492
  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.
2505
- const QUEUE_WORKER_PROMPT_MARKER = "saps_queue_worker_prompt_version";
2493
+ const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
2506
2494
  // One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
2507
2495
  const QUEUE_WORKERS = [
2508
2496
  { taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
@@ -2519,7 +2507,7 @@ function scheduledTaskSkillPath(taskId) {
2519
2507
  // The queue dir the worker reads/writes. MUST equal what the launchd kicker sets
2520
2508
  // (kickerEnv below) and what claude_job.py uses, so both ends meet on one path.
2521
2509
  function queueDir() {
2522
- return path.join(sapsStateDir(), "claude-queue");
2510
+ return path.join(s4lStateDir(), "claude-queue");
2523
2511
  }
2524
2512
  // A draft job left unclaimed in pending/ this long (ms) means no scheduled-task
2525
2513
  // routine is draining the queue — the worker would claim within a minute if it
@@ -2614,7 +2602,7 @@ function queueWorkerCwd() {
2614
2602
  function queueWorkerBody(spec) {
2615
2603
  const py = resolvePython();
2616
2604
  const job = path.join(repoDir(), "scripts", "claude_job.py");
2617
- const sd = sapsStateDir();
2605
+ const sd = s4lStateDir();
2618
2606
  const outDir = queueDir();
2619
2607
  return [
2620
2608
  `You are the S4L queue worker. Run ONE iteration, then STOP.`,
@@ -2774,13 +2762,6 @@ function queueWorkerAllowedTools() {
2774
2762
  "mcp__S4L__project_config",
2775
2763
  "mcp__S4L__get_stats",
2776
2764
  "mcp__S4L__dashboard",
2777
- // Legacy "SAPS" protocol-name namespace (pre-2026-07 brand rename): old
2778
- // registrations still resolve tool ids under it, keep the allow-rules.
2779
- "mcp__SAPS__queue_setup",
2780
- "mcp__SAPS__post_drafts",
2781
- "mcp__SAPS__project_config",
2782
- "mcp__SAPS__get_stats",
2783
- "mcp__SAPS__dashboard",
2784
2765
  ];
2785
2766
  }
2786
2767
  // Merge a list of allow-rules into ~/.claude/settings.json. Returns count added.
@@ -2911,7 +2892,7 @@ function ensureWorkerFolderTrusted() {
2911
2892
  // S4L_COWORK_MCP=0.
2912
2893
  function ensureCoworkMcpRegistered() {
2913
2894
  try {
2914
- if ((process.env.S4L_COWORK_MCP ?? process.env.SAPS_COWORK_MCP) === "0")
2895
+ if ((process.env.S4L_COWORK_MCP) === "0")
2915
2896
  return;
2916
2897
  const home = process.env.HOME || os.homedir();
2917
2898
  const cfgPath = path.join(home, ".claude.json");
@@ -2958,11 +2939,11 @@ function ensureCoworkMcpRegistered() {
2958
2939
  // `claude -p` steps route through the job queue (S4L_CLAUDE_PROVIDER=queue) for
2959
2940
  // the scheduled-task workers to service. The per-cycle DRAFT_ONLY value is NOT
2960
2941
  // baked here anymore: run-draft-and-publish.sh decides it from mode.json
2961
- // (autopilot flag, 2026-07-06) — draft cycles stop before posting and merge into
2962
- // review cards; autopilot promotion cycles post autonomously behind the virality
2963
- // bar. The DRAFT_ONLY=1 below is only the safe baseline an OLD wrapper (which
2942
+ // (draft-only flag, 2026-07-06) — while draft-only is ON (default) cycles stop
2943
+ // before posting and merge into review cards; with draft-only OFF (operator
2944
+ // opt-out) promotion cycles post autonomously behind the virality bar. The DRAFT_ONLY=1 below is only the safe baseline an OLD wrapper (which
2964
2945
  // never overrides it) inherits.
2965
- // 60s tick (2026-07-06): parity with the retired claude -p autopilot plist.
2946
+ // 60s tick (2026-07-06): parity with the retired claude -p twitter-cycle plist.
2966
2947
  // The preflight slot gate (max-1) makes this safe — a tick that fires while a
2967
2948
  // cycle is still running skips in milliseconds — so the effective cadence is
2968
2949
  // back-to-back ~10-min cycles with ≤1 min idle, not one cycle per minute.
@@ -2971,23 +2952,19 @@ function kickerEnv() {
2971
2952
  return {
2972
2953
  DRAFT_ONLY: "1",
2973
2954
  S4L_CLAUDE_PROVIDER: "queue",
2974
- S4L_STATE_DIR: sapsStateDir(),
2955
+ S4L_STATE_DIR: s4lStateDir(),
2975
2956
  TWITTER_PAGE_GEN_RATE: "0",
2976
2957
  // Thread-media context for the drafter (2026-07-06): parity with the
2977
- // retired CLI autopilot plist. Without it the prep step drafts blind to
2958
+ // retired CLI twitter-cycle plist. Without it the prep step drafts blind to
2978
2959
  // images in the candidate threads.
2979
2960
  S4L_TWITTER_CAPTURE_MEDIA: "1",
2980
2961
  // NOTE: TWITTER_TAIL_LINK_RATE is deliberately NOT set here (2026-07-06).
2981
- // The persona lane exports =0 per cycle via saps_mode.py env (link-free
2962
+ // The persona lane exports =0 per cycle via s4l_mode.py env (link-free
2982
2963
  // organic replies); promotion cycles keep the script's own default so
2983
- // autopilot posts carry the project link per the A/B gate, and card posts
2964
+ // draft-only-OFF posts carry the project link per the A/B gate, and card posts
2984
2965
  // still force =1.0 inside post_drafts.
2985
- // Rolling virality bar percentile (2026-07-03): applies to BOTH lanes in
2986
- // the cycle script below-bar picks never become review cards, and in
2987
- // autopilot mode never post. p0.95 (not the script's 0.97 default) keeps
2988
- // volume useful while cutting bottom-of-pool drafts; cold start
2989
- // (sample_count < 200) still shows brand-new installs every draft.
2990
- S4L_TWITTER_VIRALITY_PCTILE: "0.95",
2966
+ // Virality bar percentile is NOT set here: it is hardcoded to 0.90 in
2967
+ // skill/run-twitter-cycle.sh (single source of truth, no env dependency).
2991
2968
  };
2992
2969
  }
2993
2970
  async function ensureQueueKickerInstalled() {
@@ -3073,7 +3050,7 @@ async function ensureQueueKickerInstalled() {
3073
3050
  // every later cycle runs the standard 24h + top-1 logic. Best-effort: a
3074
3051
  // failed write just means a standard first cycle.
3075
3052
  try {
3076
- const stateDir = sapsStateDir();
3053
+ const stateDir = s4lStateDir();
3077
3054
  fs.mkdirSync(stateDir, { recursive: true });
3078
3055
  fs.writeFileSync(path.join(stateDir, "first-run-boost.json"), JSON.stringify({ created_at: new Date().toISOString() }) + "\n", "utf-8");
3079
3056
  }
@@ -3205,7 +3182,7 @@ async function ensureStallWatchInstalled() {
3205
3182
  try {
3206
3183
  if (process.platform !== "darwin")
3207
3184
  return { ok: false, detail: "not macOS" };
3208
- if ((process.env.S4L_STALL_WATCH ?? process.env.SAPS_STALL_WATCH) === "0")
3185
+ if ((process.env.S4L_STALL_WATCH) === "0")
3209
3186
  return { ok: false, detail: "disabled (S4L_STALL_WATCH=0)" };
3210
3187
  const logDir = path.join(repoDir(), "skill", "logs");
3211
3188
  try {
@@ -3254,7 +3231,7 @@ async function ensureMemorySnapshotInstalled() {
3254
3231
  try {
3255
3232
  if (process.platform !== "darwin")
3256
3233
  return { ok: false, detail: "not macOS" };
3257
- if ((process.env.S4L_MEMORY_SNAPSHOT ?? process.env.SAPS_MEMORY_SNAPSHOT) === "0")
3234
+ if ((process.env.S4L_MEMORY_SNAPSHOT) === "0")
3258
3235
  return { ok: false, detail: "disabled (S4L_MEMORY_SNAPSHOT=0)" };
3259
3236
  const logDir = path.join(repoDir(), "skill", "logs");
3260
3237
  try {
@@ -3316,7 +3293,7 @@ async function ensureOverlayWatchInstalled() {
3316
3293
  try {
3317
3294
  if (process.platform !== "darwin")
3318
3295
  return { ok: false, detail: "not macOS" };
3319
- if ((process.env.S4L_OVERLAY_WATCH ?? process.env.SAPS_OVERLAY_WATCH) === "0")
3296
+ if ((process.env.S4L_OVERLAY_WATCH) === "0")
3320
3297
  return { ok: false, detail: "disabled (S4L_OVERLAY_WATCH=0)" };
3321
3298
  const logDir = path.join(repoDir(), "skill", "logs");
3322
3299
  try {
@@ -3501,7 +3478,7 @@ async function healTopicsSeeded(snap) {
3501
3478
  // ---- dashboard localhost fallback -----------------------------------------
3502
3479
  // When the connected host doesn't support MCP Apps UI (Claude Code / Cowork
3503
3480
  // today), serve the SAME dist/panel.html from a loopback HTTP server. The page
3504
- // detects it's running over HTTP (window.__SAPS_BRIDGE__) and routes every
3481
+ // detects it's running over HTTP (window.__S4L_BRIDGE__) and routes every
3505
3482
  // app.callServerTool through POST /tool/<name>, which replays the exact captured
3506
3483
  // handler in TOOL_HANDLERS. No pipeline or front-end logic is duplicated.
3507
3484
  // True if the host advertised it can render our ui:// HTML resource inline.
@@ -3521,7 +3498,7 @@ let localPanel = null;
3521
3498
  // minus the postMessage host (there's none over loopback).
3522
3499
  function widgetHtmlForHttp(file) {
3523
3500
  const html = fs.readFileSync(path.join(DIST_DIR, file), "utf-8");
3524
- const inject = `<script>window.__SAPS_BRIDGE__=${JSON.stringify("http")};</script>`;
3501
+ const inject = `<script>window.__S4L_BRIDGE__=${JSON.stringify("http")};</script>`;
3525
3502
  if (html.includes("</head>"))
3526
3503
  return html.replace("</head>", inject + "</head>");
3527
3504
  return inject + html;
@@ -3607,7 +3584,7 @@ function startLocalPanel() {
3607
3584
  srv.on("error", reject);
3608
3585
  // Optional fixed port (S4L_PANEL_PORT) for deterministic addressing; default
3609
3586
  // is an OS-assigned ephemeral port.
3610
- const wantPort = Number(process.env.S4L_PANEL_PORT ?? process.env.SAPS_PANEL_PORT) || 0;
3587
+ const wantPort = Number(process.env.S4L_PANEL_PORT) || 0;
3611
3588
  srv.listen(wantPort, "127.0.0.1", () => {
3612
3589
  const addr = srv.address();
3613
3590
  const port = typeof addr === "object" && addr ? addr.port : 0;
@@ -3635,29 +3612,28 @@ function writePanelUrl(url) {
3635
3612
  }
3636
3613
  }
3637
3614
  // The owned state dir, honoring S4L_STATE_DIR (matches menubar/s4l_state.py).
3638
- function sapsStateDir() {
3615
+ function s4lStateDir() {
3639
3616
  return (process.env.S4L_STATE_DIR ||
3640
- process.env.SAPS_STATE_DIR ||
3641
3617
  path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp"));
3642
3618
  }
3643
3619
  // Has the user explicitly chosen an engagement mode? mode.json is written by the
3644
3620
  // engagement_mode tool (setup) and the menu-bar toggle. Used to complete the
3645
- // mode_chosen onboarding milestone. (Source of truth: scripts/saps_mode.py.)
3621
+ // mode_chosen onboarding milestone. (Source of truth: scripts/s4l_mode.py.)
3646
3622
  function modeChosen() {
3647
3623
  try {
3648
- return fs.existsSync(path.join(sapsStateDir(), "mode.json"));
3624
+ return fs.existsSync(path.join(s4lStateDir(), "mode.json"));
3649
3625
  }
3650
3626
  catch {
3651
3627
  return false;
3652
3628
  }
3653
3629
  }
3654
3630
  // The current engagement lane flags, surfaced in the snapshot so the dashboard
3655
- // AND menu bar read them from ONE place (mode.json, the same file saps_mode.py
3656
- // writes). Mirrors saps_mode.py get_flags(): explicit flag keys win; else map a
3631
+ // AND menu bar read them from ONE place (mode.json, the same file s4l_mode.py
3632
+ // writes). Mirrors s4l_mode.py get_flags(): explicit flag keys win; else map a
3657
3633
  // legacy {"mode": ...} string; else default personal ON / promotion OFF.
3658
3634
  function currentFlags() {
3659
3635
  try {
3660
- const d = JSON.parse(fs.readFileSync(path.join(sapsStateDir(), "mode.json"), "utf-8"));
3636
+ const d = JSON.parse(fs.readFileSync(path.join(s4lStateDir(), "mode.json"), "utf-8"));
3661
3637
  if ("personal_brand" in d || "promotion" in d) {
3662
3638
  return { personal_brand: !!d.personal_brand, promotion: !!d.promotion };
3663
3639
  }
@@ -3688,19 +3664,30 @@ function currentMode() {
3688
3664
  const POSTING_FLAG_TTL_MS = 45_000;
3689
3665
  let postingFlagHeartbeat = null;
3690
3666
  function postingFlagPath() {
3691
- return path.join(sapsStateDir(), "posting-active.json");
3667
+ return path.join(s4lStateDir(), "posting-active.json");
3692
3668
  }
3693
3669
  function writePostingFlag() {
3694
3670
  try {
3695
- fs.mkdirSync(sapsStateDir(), { recursive: true });
3671
+ fs.mkdirSync(s4lStateDir(), { recursive: true });
3696
3672
  fs.writeFileSync(postingFlagPath(), JSON.stringify({ pid: process.pid, expires_at: Date.now() + POSTING_FLAG_TTL_MS }) + "\n", "utf-8");
3697
3673
  }
3698
- catch {
3699
- /* best effort */
3674
+ catch (e) {
3675
+ // The 2026-06-23 saga flagged "posting-active.json never writes" as open,
3676
+ // and this silent catch is why nobody could tell. Say it, both places.
3677
+ console.error(`[post] posting-active flag write FAILED: ${String(e)}`);
3678
+ logPostEvent(`posting_flag_write_failed err=${String(e)}`);
3700
3679
  }
3701
3680
  }
3702
3681
  function startPostingFlagHeartbeat() {
3703
3682
  writePostingFlag();
3683
+ try {
3684
+ if (!fs.existsSync(postingFlagPath())) {
3685
+ logPostEvent(`posting_flag_missing_after_write path=${postingFlagPath()}`);
3686
+ }
3687
+ }
3688
+ catch {
3689
+ /* best effort */
3690
+ }
3704
3691
  if (postingFlagHeartbeat)
3705
3692
  return;
3706
3693
  // Refresh well within the TTL so a long batch stays flagged, but a dead poster
@@ -3742,7 +3729,7 @@ let _activityLast = null;
3742
3729
  let _activityHb = null;
3743
3730
  function _writeActivityFile(state, label) {
3744
3731
  try {
3745
- const dir = sapsStateDir();
3732
+ const dir = s4lStateDir();
3746
3733
  fs.mkdirSync(dir, { recursive: true });
3747
3734
  fs.writeFileSync(path.join(dir, "activity.json"), JSON.stringify({ state, label, since: new Date().toISOString() }) + "\n", "utf-8");
3748
3735
  }
@@ -3776,7 +3763,7 @@ function clearActivity() {
3776
3763
  _activityHb = null;
3777
3764
  }
3778
3765
  try {
3779
- fs.rmSync(path.join(sapsStateDir(), "activity.json"), { force: true });
3766
+ fs.rmSync(path.join(s4lStateDir(), "activity.json"), { force: true });
3780
3767
  }
3781
3768
  catch {
3782
3769
  /* best effort */
@@ -3789,7 +3776,7 @@ function clearActivity() {
3789
3776
  // Written atomically so a 1s poll never sees a half-written file.
3790
3777
  function persistStatusSummary(snap) {
3791
3778
  try {
3792
- const dir = sapsStateDir();
3779
+ const dir = s4lStateDir();
3793
3780
  fs.mkdirSync(dir, { recursive: true });
3794
3781
  const tmp = path.join(dir, `status-summary.json.${process.pid}.tmp`);
3795
3782
  fs.writeFileSync(tmp, JSON.stringify({ ...snap, written_at: new Date().toISOString() }) + "\n", "utf-8");
@@ -3807,7 +3794,7 @@ function persistStatusSummary(snap) {
3807
3794
  // just means no pop-ups this batch (chat review still works).
3808
3795
  function writeReviewRequest(req) {
3809
3796
  try {
3810
- const dir = sapsStateDir();
3797
+ const dir = s4lStateDir();
3811
3798
  fs.mkdirSync(dir, { recursive: true });
3812
3799
  fs.writeFileSync(path.join(dir, "review-request.json"), JSON.stringify(req, null, 2) + "\n", "utf-8");
3813
3800
  }
@@ -3822,7 +3809,7 @@ function writeReviewRequest(req) {
3822
3809
  // S4L_PANEL_OPEN_BROWSER=1 to restore the old auto-open behavior. (The URL is
3823
3810
  // always returned to the caller regardless, so nothing is lost when we don't open.)
3824
3811
  async function openInBrowser(url) {
3825
- if (!(process.env.S4L_PANEL_OPEN_BROWSER ?? process.env.SAPS_PANEL_OPEN_BROWSER))
3812
+ if (!(process.env.S4L_PANEL_OPEN_BROWSER))
3826
3813
  return;
3827
3814
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
3828
3815
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
@@ -3904,7 +3891,23 @@ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
3904
3891
  // autopilot tick stacks another on top (the zombie pileup that stale-reclaimed the
3905
3892
  // lock mid-post). SIGKILL can't be trapped. Kill children first so the harness CDP
3906
3893
  // driver lets go of Chrome immediately.
3894
+ // Timestamped on-disk trail for every scan preemption and posting-flag failure.
3895
+ // The console.error lines land in Claude Desktop's per-profile MCP log, which has
3896
+ // proven hard to even LOCATE during incidents (2026-07-06 forensics had to infer
3897
+ // two of six scan kills from bash job-control lines). This file lives next to the
3898
+ // post-*.log dumps so one directory tells the whole posting story. Best-effort.
3899
+ function logPostEvent(msg) {
3900
+ try {
3901
+ const dir = path.join(repoDir(), "skill", "logs");
3902
+ fs.mkdirSync(dir, { recursive: true });
3903
+ fs.appendFileSync(path.join(dir, "post-preempt-events.log"), `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);
3904
+ }
3905
+ catch {
3906
+ /* best effort */
3907
+ }
3908
+ }
3907
3909
  function sigkillScanTree(pid) {
3910
+ logPostEvent(`sigkill_scan_tree target=${pid}`);
3908
3911
  try {
3909
3912
  const out = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf-8", timeout: 4000 });
3910
3913
  for (const cstr of out.split(/\s+/)) {
@@ -3957,7 +3960,7 @@ function sigkillAllScans() {
3957
3960
  // BETWEEN every card that a parked scan stale-reclaimed (the hijack). Instead we
3958
3961
  // keep the lock and only release it after SHELL_LOCK_GRACE_MS of no posting, so the
3959
3962
  // hold EXPANDS as more cards get approved and there is never a gap between cards.
3960
- const SHELL_LOCK_GRACE_MS = Number(process.env.S4L_POST_LOCK_GRACE_MS ?? process.env.SAPS_POST_LOCK_GRACE_MS) || 60_000;
3963
+ const SHELL_LOCK_GRACE_MS = Number(process.env.S4L_POST_LOCK_GRACE_MS) || 60_000;
3961
3964
  let shellLockReleaseTimer = null;
3962
3965
  // True from the start of a post batch until SHELL_LOCK_GRACE_MS after the last
3963
3966
  // card. The draft-cycle scan checks this and DEFERS launching a scan while it's set —
@@ -3990,6 +3993,7 @@ function preemptScanHoldingBrowser() {
3990
3993
  const pid = shellLockHolderPid();
3991
3994
  if (pid && pidAlive(pid) && pidIsScan(pid)) {
3992
3995
  console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid}) — SIGKILL tree`);
3996
+ logPostEvent(`preempt_scan_holding_browser scan_pid=${pid}`);
3993
3997
  sigkillScanTree(pid);
3994
3998
  }
3995
3999
  }
@@ -4034,6 +4038,7 @@ async function acquireShellBrowserLock() {
4034
4038
  rmShellLockDir();
4035
4039
  }
4036
4040
  else if (pidIsScan(pid)) {
4041
+ logPostEvent(`preempt_scan_on_lock_acquire scan_pid=${pid} attempt=${attempt}`);
4037
4042
  sigkillScanTree(pid); // SIGKILL — scans trap SIGTERM and survive it
4038
4043
  await sleepMs(300);
4039
4044
  rmShellLockDir();
@@ -4443,6 +4448,32 @@ async function main() {
4443
4448
  const tr = setInterval(relayTranscripts, 5 * 60_000);
4444
4449
  tr.unref();
4445
4450
  }
4451
+ // Relay the queue producer/consumer log (claude-queue/provider.log) so a
4452
+ // stranded/orphaned draft batch — a producer that died after its worker wrote
4453
+ // the result but before consuming it — is visible in Cloud Logging remotely,
4454
+ // not just on the box (context="queue-provider"). Incremental (byte offset),
4455
+ // self-locking, forward-only baseline. Best-effort; opt out S4L_PROVIDER_LOG_RELAY=0.
4456
+ if ((process.env.S4L_PROVIDER_LOG_RELAY ?? "1") !== "0") {
4457
+ let providerRelayRunning = false;
4458
+ const relayProviderLog = () => {
4459
+ if (providerRelayRunning)
4460
+ return;
4461
+ providerRelayRunning = true;
4462
+ runPython("scripts/relay_provider_log.py", ["--max-lines", "500"], {
4463
+ timeoutMs: 120_000,
4464
+ })
4465
+ .catch((e) => {
4466
+ console.error("[social-autoposter-mcp] provider-log relay failed:", e?.message || e);
4467
+ })
4468
+ .finally(() => {
4469
+ providerRelayRunning = false;
4470
+ });
4471
+ };
4472
+ const prBoot = setTimeout(relayProviderLog, 95_000); // off the boot hot path
4473
+ prBoot.unref();
4474
+ const pr = setInterval(relayProviderLog, 5 * 60_000);
4475
+ pr.unref();
4476
+ }
4446
4477
  // Sync the install's configuration state (config.json, persona corpus, mode,
4447
4478
  // queues, onboarding ledger) to the backend. Hash-gated on the interval, so
4448
4479
  // the recurring tick only POSTs when something actually changed; setup.ts