@m13v/s4l 1.7.1-rc.3 → 1.7.1-rc.30

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 (49) hide show
  1. package/bin/cli.js +23 -0
  2. package/mcp/dist/index.js +390 -27
  3. package/mcp/dist/onboarding.js +3 -10
  4. package/mcp/dist/panel.html +42 -31
  5. package/mcp/dist/product-link.html +1 -1
  6. package/mcp/dist/runtime.js +101 -1
  7. package/mcp/dist/telemetry.js +80 -3
  8. package/mcp/dist/version.json +2 -2
  9. package/mcp/manifest.json +9 -1
  10. package/mcp/menubar/dashboard_server.py +12 -0
  11. package/mcp/menubar/s4l_card.py +539 -80
  12. package/mcp/menubar/s4l_menubar.py +365 -213
  13. package/mcp/menubar/s4l_state.py +228 -33
  14. package/mcp/package.json +1 -1
  15. package/mcp/shared/doctor.cjs +8 -0
  16. package/mcp/shared/onboarding-ledger.cjs +1 -0
  17. package/package.json +4 -1
  18. package/scripts/active_users.py +3 -6
  19. package/scripts/autopilot_stall_watch.py +200 -16
  20. package/scripts/claude_job.py +6 -0
  21. package/scripts/feedback_digest.py +34 -6
  22. package/scripts/get-latest-staging-mcpb.sh +50 -0
  23. package/scripts/harness_overlay.py +45 -0
  24. package/scripts/identity.py +26 -0
  25. package/scripts/learned_preferences.py +241 -32
  26. package/scripts/link_tail.py +167 -91
  27. package/scripts/mark_event.py +128 -0
  28. package/scripts/memory_snapshot.py +27 -0
  29. package/scripts/merge_review_queue.py +42 -16
  30. package/scripts/pick_twitter_thread_target.py +1 -1
  31. package/scripts/release-mcpb.sh +48 -0
  32. package/scripts/reset-test-machine.sh +85 -3
  33. package/scripts/s4l_mode.py +7 -5
  34. package/scripts/scan_pii.py +9 -1
  35. package/scripts/schedule_state.py +146 -69
  36. package/scripts/scheduled_task_selfheal.py +276 -0
  37. package/scripts/scheduled_tasks_snapshot.py +107 -0
  38. package/scripts/send_gmail_report.py +76 -0
  39. package/scripts/sentry_digest.py +81 -189
  40. package/scripts/sentry_init.py +43 -15
  41. package/scripts/setup_twitter_auth.py +76 -38
  42. package/scripts/snapshot.py +20 -0
  43. package/scripts/twitter_post_plan.py +39 -13
  44. package/skill/dm-outreach-twitter.sh +19 -0
  45. package/skill/engage-twitter.sh +27 -0
  46. package/skill/run-draft-and-publish.sh +13 -12
  47. package/skill/run-linkedin.sh +9 -1
  48. package/skill/run-twitter-cycle.sh +199 -27
  49. package/skill/sentry-digest.sh +125 -12
package/bin/cli.js CHANGED
@@ -823,6 +823,29 @@ function installMcp() {
823
823
  } catch (e) {
824
824
  console.warn(' WARNING: could not stamp MCP version:', e && e.message);
825
825
  }
826
+ // Auto-opt a fresh install into the staging channel when the version being
827
+ // installed is itself a pre-release (-rc.N) — e.g. `npx social-autoposter@
828
+ // X.Y.Z-rc.N init`. Without this, channel.json stays absent, which
829
+ // scripts/s4l_channel.py's fail-safe default reads as "stable", so the box
830
+ // would install this one rc and then silently stop tracking staging (never
831
+ // pick up the next rc). Mirrors the same one-time write in
832
+ // mcp/src/runtime.ts's provision() for the .mcpb (Desktop) install path.
833
+ // Only writes when no channel marker exists yet — never overrides an
834
+ // existing preference (e.g. a user who already opted back out).
835
+ try {
836
+ const pkgVersion = require('../package.json').version;
837
+ if (pkgVersion.includes('-rc.')) {
838
+ const stateDir = process.env.S4L_STATE_DIR || path.join(os.homedir(), '.social-autoposter-mcp');
839
+ const channelPath = path.join(stateDir, 'channel.json');
840
+ if (!fs.existsSync(channelPath)) {
841
+ fs.mkdirSync(stateDir, { recursive: true });
842
+ fs.writeFileSync(channelPath, JSON.stringify({ channel: 'staging' }, null, 2) + '\n');
843
+ console.log(' pre-release install detected -> opted into the staging channel');
844
+ }
845
+ }
846
+ } catch (e) {
847
+ console.warn(' WARNING: could not set staging channel:', e && e.message);
848
+ }
826
849
  console.log(' installing MCP runtime deps (npm install --omit=dev in mcp/)');
827
850
  const npmRes = spawnSync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], {
828
851
  cwd: mcpDest,
package/mcp/dist/index.js CHANGED
@@ -22,10 +22,10 @@ import fs from "node:fs";
22
22
  import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
23
23
  import { applySetup, resolveProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, normalizeStringList, } from "./setup.js";
24
24
  import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
25
- import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, } from "./runtime.js";
25
+ import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, retryProvisionIfStalled, } from "./runtime.js";
26
26
  import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
27
27
  import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
28
- import { initSentry, sendHeartbeat, sendStateSnapshot, captureError, flushSentry, startLogStreaming, flushLogs, logLine } from "./telemetry.js";
28
+ import { initSentry, sendHeartbeat, sendStateSnapshot, captureError, captureMessage, flushSentry, startLogStreaming, flushLogs, logLine, checkVersionChange } from "./telemetry.js";
29
29
  import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import http from "node:http";
@@ -885,11 +885,24 @@ async function postApproved(batchId, plan) {
885
885
  {
886
886
  const gateDeadline = Date.now() + 8 * 60_000;
887
887
  let waited = false;
888
+ let waitStartedAt = 0;
888
889
  while ((postingActive || isPeerDrainActive()) && Date.now() < gateDeadline) {
890
+ if (!waited) {
891
+ waitStartedAt = Date.now();
892
+ // This loop was previously silent end-to-end (2026-07-08 forensics: a
893
+ // ~1:45 gap in a user's approval batch had zero trace anywhere). Log
894
+ // once on entry (not per 5s tick, to avoid spamming the file) so a
895
+ // later "why did card N take so long" question has a starting point.
896
+ logPostEvent(`postApproved_wait_start batch=${batchId} blocked_by=${describePostingBlocker()}`);
897
+ }
889
898
  waited = true;
890
899
  await sleepMs(5000);
891
900
  }
892
- if (postingActive || isPeerDrainActive()) {
901
+ const timedOut = postingActive || isPeerDrainActive();
902
+ if (waited) {
903
+ logPostEvent(`postApproved_wait_end batch=${batchId} waited_ms=${Date.now() - waitStartedAt} timed_out=${timedOut}`);
904
+ }
905
+ if (timedOut) {
893
906
  return {
894
907
  attempted: 0,
895
908
  exit_code: 0,
@@ -1181,10 +1194,21 @@ async function postApproved(batchId, plan) {
1181
1194
  }
1182
1195
  // Post failures are HANDLED in the pipeline (it returns a count, never throws),
1183
1196
  // so they never reach Sentry on their own. Capture an explicit event whenever
1184
- // the run exited non-zero OR fewer drafts posted than were approved. This is
1185
- // the only telemetry channel that reaches a customer .mcpb install (their cycle
1186
- // log lives on their machine). install_id/hostname are auto-tagged.
1187
- if (res.code !== 0 || realPosted < approved.length) {
1197
+ // the run exited non-zero OR a REAL failure happened. This is the only
1198
+ // telemetry channel that reaches a customer .mcpb install (their cycle log
1199
+ // lives on their machine). install_id/hostname are auto-tagged.
1200
+ //
1201
+ // Gated on failure_reasons (not just realPosted < approved.length): the
1202
+ // Python pipeline already reports every shortfall to Sentry itself
1203
+ // (twitter_post_plan.py's capture_message), including benign skips like a
1204
+ // deleted target tweet. Re-reporting the SAME benign skip here as a second,
1205
+ // independently-fingerprinted `Error` (this capture group) doubled every
1206
+ // such event into two distinct Sentry issues. Only add this Node-side
1207
+ // capture when there's a real failure_reasons entry or a non-zero exit,
1208
+ // i.e. something the Python capture wouldn't already have flagged as an
1209
+ // actionable error on its own.
1210
+ const hasRealFailure = res.code !== 0 || Boolean(summObj?.failure_reasons);
1211
+ if (hasRealFailure) {
1188
1212
  captureError(new Error(`post_drafts: ${realPosted}/${approved.length} posted (exit=${res.code})`), {
1189
1213
  component: "post",
1190
1214
  exit_code: String(res.code),
@@ -1594,7 +1618,10 @@ tool("engagement_mode", {
1594
1618
  // persona is seeded. (2026-06-30) Skipped when promotion-only, since the
1595
1619
  // product project isn't configured yet (it stays gated until project_config).
1596
1620
  let kickerInstall = null;
1597
- if (personalBrand) {
1621
+ if (personalBrand && isPaused()) {
1622
+ kickerInstall = { ok: false, detail: "skip (paused)" };
1623
+ }
1624
+ else if (personalBrand) {
1598
1625
  try {
1599
1626
  kickerInstall = await ensureQueueKickerInstalled();
1600
1627
  console.error(`[engagement_mode] launchd kicker: ${kickerInstall.ok ? "ok" : "skip"} (${kickerInstall.detail})`);
@@ -1835,6 +1862,18 @@ tool("project_config", {
1835
1862
  // as expected. Once connect_x succeeds, run the full phase immediately
1836
1863
  // to verify persistence, CDP, and the durable cookie mirror.
1837
1864
  doctorReport = await runDoctorPhase("full");
1865
+ // Only mark x_verified once doctor confirms all X checks pass (session valid,
1866
+ // cookies persisted, CDP responding, keychain accessible). This prevents the
1867
+ // autopilot from starting if X connection didn't actually persist.
1868
+ if (doctorReport?.ok) {
1869
+ completeOnboardingMilestone("x_verified", {
1870
+ x_session: doctorReport.checks?.find(c => c.id === "x_session")?.status,
1871
+ x_cookies: doctorReport.checks?.find(c => c.id === "x_cookie_sqlite")?.status,
1872
+ });
1873
+ }
1874
+ else {
1875
+ recordOnboardingAttempt("x_verified", { doctor_ok: false });
1876
+ }
1838
1877
  }
1839
1878
  else {
1840
1879
  blockOnboardingMilestone("x_connected", `x_${r.state || "not_connected"}`, r.error || r.note || summarizeXAuth(r), { state: r.state || "not_connected" });
@@ -2076,7 +2115,10 @@ tool("project_config", {
2076
2115
  // persona-aware, so calling it from both setup paths is safe. Best-effort:
2077
2116
  // a kicker hiccup never fails setup. (2026-06-30)
2078
2117
  let kickerInstall = null;
2079
- if (result.ready) {
2118
+ if (result.ready && isPaused()) {
2119
+ kickerInstall = { ok: false, detail: "skip (paused)" };
2120
+ }
2121
+ else if (result.ready) {
2080
2122
  try {
2081
2123
  kickerInstall = await ensureQueueKickerInstalled();
2082
2124
  console.error(`[project_config] launchd kicker: ${kickerInstall.ok ? "ok" : "skip"} (${kickerInstall.detail})`);
@@ -2154,7 +2196,16 @@ tool("post_drafts", {
2154
2196
  .optional()
2155
2197
  .describe("1-based draft numbers to post as drafted, e.g. [1, 3, 5]."),
2156
2198
  edits: z
2157
- .array(z.object({ n: z.number().int().positive(), text: z.string() }))
2199
+ .array(z.object({
2200
+ n: z.number().int().positive(),
2201
+ text: z.string(),
2202
+ variant: z
2203
+ .enum(["a", "b"])
2204
+ .optional()
2205
+ .describe("Two-draft cards only: which of candidate n's `drafts` entries this text came " +
2206
+ "from, so the candidate's style/assigned_style/assigned_mode are switched to " +
2207
+ "match (otherwise engagement_style stats would be attributed to the wrong style)."),
2208
+ }))
2158
2209
  .optional()
2159
2210
  .describe("Rewrites: each {n, text} replaces draft n's wording, then posts it."),
2160
2211
  post_all: z.boolean().optional().describe("Post every draft in the batch."),
@@ -2210,7 +2261,22 @@ tool("post_drafts", {
2210
2261
  warnings.push(`ignored empty edit for #${e.n}`);
2211
2262
  return;
2212
2263
  }
2213
- candidates[e.n - 1].reply_text = text;
2264
+ const c = candidates[e.n - 1];
2265
+ c.reply_text = text;
2266
+ // Two-draft cards: the human switched to the OTHER draft (with or
2267
+ // without further hand-editing its text). Carry that draft's own
2268
+ // style/assigned_style/assigned_mode onto the candidate so
2269
+ // twitter_post_plan.py's per-candidate drift-coercion posts under (and
2270
+ // logs) the style that's ACTUALLY posting, not whichever draft was
2271
+ // recommended at plan-write time.
2272
+ if (e.variant && Array.isArray(c.drafts)) {
2273
+ const chosen = c.drafts.find((d) => d.variant === e.variant);
2274
+ if (chosen) {
2275
+ c.engagement_style = chosen.style ?? c.engagement_style;
2276
+ c.assigned_style = chosen.assigned_style ?? null;
2277
+ c.assigned_mode = chosen.assigned_mode ?? c.assigned_mode;
2278
+ }
2279
+ }
2214
2280
  approve.add(e.n);
2215
2281
  editedCount++;
2216
2282
  });
@@ -2453,16 +2519,26 @@ tool("runtime", {
2453
2519
  });
2454
2520
  }
2455
2521
  // ---- status (default): runtime install snapshot -----------------------
2456
- const snapshot = runtimeSnapshot();
2522
+ let snapshot = runtimeSnapshot();
2457
2523
  if (snapshot.runtime_ready) {
2458
2524
  completeOnboardingMilestone("runtime_ready");
2459
2525
  }
2460
2526
  else if (snapshot.progress?.done && !snapshot.progress.ok) {
2461
- blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
2527
+ // A prior provision failed. Kick a bounded auto-retry so status polling
2528
+ // self-heals a transient failure instead of parking until the next boot;
2529
+ // the provisioner cleans its own partial artifacts, so a retry is safe.
2530
+ // Only surface the failure to the onboarding ledger once retries are spent.
2531
+ if (retryProvisionIfStalled()) {
2532
+ snapshot = runtimeSnapshot(); // reflect the restarted, in-flight run
2533
+ }
2534
+ else {
2535
+ blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
2536
+ }
2462
2537
  }
2463
2538
  return jsonContent({
2464
2539
  ...snapshot,
2465
2540
  menubar_running: await menubarRunning(),
2541
+ paused: isPaused(),
2466
2542
  onboarding: onboardingSnapshot(),
2467
2543
  });
2468
2544
  });
@@ -2493,6 +2569,37 @@ tool("restart_menubar", {
2493
2569
  menubar_running: running,
2494
2570
  });
2495
2571
  });
2572
+ // ---- pause_s4l: temporarily stop drafting/posting, reversibly --------------
2573
+ // The lighter alternative to Quit: unloads only the launchd jobs that scan,
2574
+ // draft, and post (plus their support daemons), leaving Claude Desktop, the
2575
+ // S4L tray, X connection, and the draft schedule registration untouched.
2576
+ // Fully reversible — action:'resume' reinstalls the exact same daemons via the
2577
+ // same idempotent ensure*Installed() functions boot uses. NOT "autopilot": S4L
2578
+ // is draft-first (a human approves every post), so this pauses/resumes the
2579
+ // draft pipeline itself, not some autonomous posting mode.
2580
+ tool("pause_s4l", {
2581
+ title: "Pause or resume S4L",
2582
+ description: "Pause temporarily stops S4L's own draft pipeline (the launchd kicker that scans/drafts/posts, " +
2583
+ "plus its reaper/stall-watch/memory-snapshot support jobs) WITHOUT touching Claude Desktop, the " +
2584
+ "S4L tray, your X connection, or the draft schedule registration — nothing is deleted, so it " +
2585
+ "survives a Claude Desktop restart and Resume brings it right back. Use when the user asks to " +
2586
+ "pause, stop, or temporarily disable S4L, or to resume/unpause it. This is NOT the same as Quit " +
2587
+ "(which kills and restarts Claude Desktop and removes the draft schedule) — Pause is the lighter, " +
2588
+ "fully reversible option. action:'status' (default) reports whether it's currently paused.",
2589
+ inputSchema: {
2590
+ action: z.enum(["status", "pause", "resume"]).optional(),
2591
+ },
2592
+ }, async ({ action }) => {
2593
+ if (action === "pause") {
2594
+ const res = await pauseS4L();
2595
+ return jsonContent({ action: "pause", paused: isPaused(), ...res });
2596
+ }
2597
+ if (action === "resume") {
2598
+ const res = await resumeS4L();
2599
+ return jsonContent({ action: "resume", paused: isPaused(), ...res });
2600
+ }
2601
+ return jsonContent({ action: "status", paused: isPaused() });
2602
+ });
2496
2603
  // ---- report_diagnosis: ship a field diagnosis to the developers -------------
2497
2604
  // First-class MCP wrapper over scripts/send_diagnostic_report.py (the same
2498
2605
  // Sentry lane the menubar "Diagnose & fix" prompt uses). Before this existed
@@ -2531,6 +2638,29 @@ tool("report_diagnosis", {
2531
2638
  return jsonContent({ ok: false, detail: String(e?.message || e).slice(0, 300) });
2532
2639
  }
2533
2640
  });
2641
+ // ---- client_event: lightweight UI telemetry ping from the dashboard panel --
2642
+ // The panel iframe is a browser context with no Sentry SDK and no server-side
2643
+ // telemetry access of its own. Before this, a panel button click (e.g. "Set up
2644
+ // draft schedule") had NO record anywhere — report_diagnosis needs a full
2645
+ // markdown report from an agent turn, which doesn't fit a plain click, so the
2646
+ // panel's rearm button silently had zero telemetry while its menu-bar sibling
2647
+ // did (see s4l_menubar.py _capture_msg). Not for agent use: the panel calls
2648
+ // this directly via app.callServerTool, never through chat.
2649
+ tool("client_event", {
2650
+ title: "Log a lightweight client UI event",
2651
+ description: "Internal telemetry hook for the dashboard panel to report a UI event (e.g. a button click). " +
2652
+ "Not intended for the agent to call from chat.",
2653
+ inputSchema: {
2654
+ event: z.string().describe("Short event name, e.g. rearm_clicked"),
2655
+ surface: z.string().optional().describe("UI surface the event came from, e.g. panel"),
2656
+ },
2657
+ }, async ({ event, surface }) => {
2658
+ captureMessage(`S4L client event: ${event}`, {
2659
+ level: "info",
2660
+ tags: { component: "panel", event, surface: surface || "panel" },
2661
+ });
2662
+ return jsonContent({ ok: true });
2663
+ });
2534
2664
  function runtimeSnapshot() {
2535
2665
  const rt = readRuntime();
2536
2666
  const progress = readProgress();
@@ -3225,6 +3355,18 @@ async function ensureQueueKickerInstalled() {
3225
3355
  detail: "no ready project or active persona yet",
3226
3356
  };
3227
3357
  }
3358
+ // Additional gate: X must be connected and verified before autopilot starts.
3359
+ // This prevents wasted cycles against a logged-out x.com if X connection didn't
3360
+ // persist after import/login. x_verified milestone only completes after doctor
3361
+ // confirms X session and cookies are valid.
3362
+ const onboardingState = onboardingSnapshot();
3363
+ const xVerified = onboardingState?.milestones?.some((m) => m.id === "x_verified" && m.status === "complete");
3364
+ if (!xVerified) {
3365
+ return {
3366
+ ok: false,
3367
+ detail: "X not yet verified (awaiting explicit pre-flight confirmation)",
3368
+ };
3369
+ }
3228
3370
  const logDir = path.join(repoDir(), "skill", "logs");
3229
3371
  try {
3230
3372
  fs.mkdirSync(logDir, { recursive: true });
@@ -3520,6 +3662,76 @@ async function ensureMemorySnapshotInstalled() {
3520
3662
  return { ok: false, detail: e?.message || String(e) };
3521
3663
  }
3522
3664
  }
3665
+ // ---- pause/resume: stop drafting/posting without touching Claude Desktop ---
3666
+ // Unlike Quit (which kills + relaunches Claude Desktop and deletes the draft
3667
+ // schedule), Pause only unloads the launchd jobs that actually DO work — the
3668
+ // kicker that scans/drafts/posts (TWITTER_AUTOPILOT_LABEL) plus its reaper,
3669
+ // stall-watch, and memory-snapshot support daemons — while leaving Claude
3670
+ // Desktop, the tray, and the Claude-native s4l-worker scheduled task alone.
3671
+ // The scheduled task still fires on its own cadence; claude_job.py's `next`
3672
+ // checks the same flag file and reports "no work" instantly instead of
3673
+ // draining the queue, so nothing drafts or posts while paused even if a
3674
+ // worker session wakes up mid-pause. A flag file (nothing is deleted) is what
3675
+ // makes Resume a plain reinstall of the same 4 daemons — see resumeS4L.
3676
+ function pauseFlagPath() {
3677
+ return path.join(s4lStateDir(), "paused.flag");
3678
+ }
3679
+ function isPaused() {
3680
+ try {
3681
+ return fs.existsSync(pauseFlagPath());
3682
+ }
3683
+ catch {
3684
+ return false;
3685
+ }
3686
+ }
3687
+ const PAUSE_TARGETS = [
3688
+ { label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST },
3689
+ { label: REAPER_LABEL, plist: REAPER_PLIST },
3690
+ { label: STALL_WATCH_LABEL, plist: STALL_WATCH_PLIST },
3691
+ { label: MEMORY_SNAPSHOT_LABEL, plist: MEMORY_SNAPSHOT_PLIST },
3692
+ ];
3693
+ async function pauseS4L() {
3694
+ if (isPaused())
3695
+ return { ok: true, detail: "already paused" };
3696
+ try {
3697
+ fs.mkdirSync(path.dirname(pauseFlagPath()), { recursive: true });
3698
+ fs.writeFileSync(pauseFlagPath(), `paused at ${new Date().toISOString()}\n`, "utf-8");
3699
+ }
3700
+ catch (e) {
3701
+ return { ok: false, detail: `could not write pause flag: ${e?.message || e}` };
3702
+ }
3703
+ const uid = process.getuid ? process.getuid() : 0;
3704
+ const results = [];
3705
+ for (const { label, plist } of PAUSE_TARGETS) {
3706
+ try {
3707
+ const res = await unloadPlist(label, plist, uid);
3708
+ results.push(`${label}: unloaded (rc=${res.code})`);
3709
+ }
3710
+ catch (e) {
3711
+ results.push(`${label}: ${e?.message || e}`);
3712
+ }
3713
+ }
3714
+ return { ok: true, detail: results.join("; ") };
3715
+ }
3716
+ async function resumeS4L() {
3717
+ try {
3718
+ fs.rmSync(pauseFlagPath(), { force: true });
3719
+ }
3720
+ catch {
3721
+ /* best-effort */
3722
+ }
3723
+ const kicker = await ensureQueueKickerInstalled();
3724
+ const reaper = await ensureClaudeReaperInstalled();
3725
+ const stall = await ensureStallWatchInstalled();
3726
+ const mem = await ensureMemorySnapshotInstalled();
3727
+ const detail = [
3728
+ `kicker: ${kicker.ok ? "ok" : "skip"} (${kicker.detail})`,
3729
+ `reaper: ${reaper.ok ? "ok" : "skip"} (${reaper.detail})`,
3730
+ `stall-watch: ${stall.ok ? "ok" : "skip"} (${stall.detail})`,
3731
+ `memory-snapshot: ${mem.ok ? "ok" : "skip"} (${mem.detail})`,
3732
+ ].join("; ");
3733
+ return { ok: true, detail };
3734
+ }
3523
3735
  // Install/refresh the on-screen overlay watcher launchd job. Promotes the
3524
3736
  // harness status overlay from a best-effort, fired-from-other-tools nicety to a
3525
3737
  // first-class self-healing job. We run `harness_overlay.py watch` directly in
@@ -3587,13 +3799,80 @@ async function ensureOverlayWatchInstalled() {
3587
3799
  return { ok: false, detail: e?.message || String(e) };
3588
3800
  }
3589
3801
  }
3802
+ // Install/refresh the daily self-updater launchd job. This used to be bundled
3803
+ // into the now-deleted `autopilot` MCP tool (removed 2026-06-19, 88bd1cb9):
3804
+ // calling `autopilot enable` installed this job as a side effect, so removing
3805
+ // the tool silently orphaned it — UPDATER_LABEL/UPDATER_PLIST and the
3806
+ // auto_update_on status check survived (buildSnapshot still reports them), but
3807
+ // nothing has installed the plist since, on ANY box provisioned after that
3808
+ // commit (auto_update_on reads false forever). Restored here as its own
3809
+ // deterministic boot-time job, same pattern as its five siblings above.
3810
+ //
3811
+ // Points at scripts/s4l_box_update.sh, NOT skill/social-autoposter-update.sh:
3812
+ // the latter is the npm-lane updater (npm view + npx update) and is a silent
3813
+ // no-op on a .mcpb box, which has no npm/npx on PATH (see version.ts). The
3814
+ // .mcpb-lane equivalent downloads the .mcpb directly from the channel-resolved
3815
+ // GitHub release and unpacks it over the extension dir — see that script's own
3816
+ // header for the channel/no-downgrade/retry guards. Default mode there
3817
+ // downloads + unpacks + restarts Claude Desktop with NO human in the loop,
3818
+ // matching the original bundled updater's intent ("keeps a headless install
3819
+ // current"); RunAtLoad so a box that boots already-behind checks promptly.
3820
+ async function ensureUpdaterInstalled() {
3821
+ try {
3822
+ if (process.platform !== "darwin")
3823
+ return { ok: false, detail: "not macOS" };
3824
+ if ((process.env.S4L_AUTO_UPDATE) === "0")
3825
+ return { ok: false, detail: "disabled (S4L_AUTO_UPDATE=0)" };
3826
+ const logDir = path.join(repoDir(), "skill", "logs");
3827
+ try {
3828
+ fs.mkdirSync(logDir, { recursive: true });
3829
+ }
3830
+ catch {
3831
+ /* best-effort */
3832
+ }
3833
+ const xml = plistXml({
3834
+ label: UPDATER_LABEL,
3835
+ programArgs: ["/bin/bash", path.join(repoDir(), "scripts", "s4l_box_update.sh")],
3836
+ intervalSecs: 86_400,
3837
+ runAtLoad: true,
3838
+ stdoutLog: path.join(logDir, "launchd-self-update-stdout.log"),
3839
+ stderrLog: path.join(logDir, "launchd-self-update-stderr.log"),
3840
+ });
3841
+ const uid = process.getuid ? process.getuid() : 0;
3842
+ let cur = null;
3843
+ try {
3844
+ cur = fs.readFileSync(UPDATER_PLIST, "utf-8");
3845
+ }
3846
+ catch {
3847
+ cur = null;
3848
+ }
3849
+ let detail;
3850
+ if (cur === xml) {
3851
+ const res = await loadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
3852
+ detail = `current (load rc=${res.code})`;
3853
+ }
3854
+ else {
3855
+ if (cur !== null) {
3856
+ await unloadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
3857
+ }
3858
+ fs.mkdirSync(path.dirname(UPDATER_PLIST), { recursive: true });
3859
+ fs.writeFileSync(UPDATER_PLIST, xml, "utf-8");
3860
+ const res = await loadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
3861
+ detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
3862
+ }
3863
+ return { ok: true, detail };
3864
+ }
3865
+ catch (e) {
3866
+ return { ok: false, detail: e?.message || String(e) };
3867
+ }
3868
+ }
3590
3869
  // Is the draft schedule registered AND running for the LIVE account?
3591
3870
  // 'ok' — worker tasks present+enabled and FIRING (host actively running).
3592
3871
  // 'disabled' — present but a worker task is disabled.
3593
3872
  // 'missing' — not firing anywhere (orphaned / not registered for the live
3594
3873
  // account) -> dashboard offers "Set up draft schedule".
3595
- // The algorithm (live-account detection by freshest lastRunAt, firing window,
3596
- // etc.) lives in ONE place: scripts/schedule_state.py. The Python menu bar imports
3874
+ // The algorithm (live-account detection via config.json's lastKnownAccountUuid,
3875
+ // firing window, etc.) lives in ONE place: scripts/schedule_state.py. The Python menu bar imports
3597
3876
  // that module in-process; we shell out to it here. Keeping a single implementation
3598
3877
  // is the whole point — the two surfaces can no longer drift. The script is
3599
3878
  // stdlib-only and resolvePython() falls back to system python3, so this works even
@@ -3648,6 +3927,7 @@ async function buildSnapshot() {
3648
3927
  // (it's a launchctl check the Node side owns), so layer it on here. The panel
3649
3928
  // uses it to offer a one-click "restart menu bar" when the tray was quit.
3650
3929
  snap.menubar_running = await menubarRunning();
3930
+ snap.paused = isPaused();
3651
3931
  await ensureDoctorPhase(snap.x_connected ? "full" : "pre_connect");
3652
3932
  if (snap.runtime_ready)
3653
3933
  completeOnboardingMilestone("runtime_ready");
@@ -3888,8 +4168,12 @@ function writePanelUrl(url) {
3888
4168
  // and fully usable via `url` for anything that already has it (e.g. this
3889
4169
  // process's own Code side-panel proxy); we just don't publish ourselves
3890
4170
  // as THE shared menu-bar target.
4171
+ logPanelEvent(`panel_cede existing_pid=${existing.pid}`);
3891
4172
  return;
3892
4173
  }
4174
+ logPanelEvent(existing?.pid
4175
+ ? `panel_takeover dead_pid=${existing.pid}`
4176
+ : `panel_claim previous=none`);
3893
4177
  fs.writeFileSync(panelEndpointPath(), JSON.stringify({ url, pid: process.pid, version: VERSION, started_at: new Date().toISOString() }, null, 2) + "\n", "utf-8");
3894
4178
  }
3895
4179
  catch (e) {
@@ -4040,6 +4324,25 @@ function isPeerDrainActive() {
4040
4324
  return false;
4041
4325
  }
4042
4326
  }
4327
+ // Human-readable reason for the postApproved wait gate, used only for the
4328
+ // wait_start log line — distinguishes "our own drain hasn't hit its grace
4329
+ // release yet" from "a peer MCP instance (different pid, e.g. a separate
4330
+ // Claude session/queue-worker) is mid-drain," and names the peer pid + how
4331
+ // much longer its flag has left so a log reader doesn't have to guess.
4332
+ function describePostingBlocker() {
4333
+ if (postingActive)
4334
+ return "own_in_process_flag";
4335
+ try {
4336
+ const j = JSON.parse(fs.readFileSync(postingFlagPath(), "utf-8"));
4337
+ if (typeof j?.expires_at === "number" && j.expires_at > Date.now() && typeof j?.pid === "number") {
4338
+ return `peer_pid=${j.pid} expires_in_ms=${j.expires_at - Date.now()}`;
4339
+ }
4340
+ }
4341
+ catch {
4342
+ /* best effort */
4343
+ }
4344
+ return "unknown";
4345
+ }
4043
4346
  // activity.json: a tiny "what's running right now" signal the menu bar reads to
4044
4347
  // show a loading spinner + label (scanning / drafting / posting / …). Written by
4045
4348
  // long-running tools, cleared when they finish. Best-effort; absence == idle.
@@ -4224,6 +4527,25 @@ function logPostEvent(msg) {
4224
4527
  /* best effort */
4225
4528
  }
4226
4529
  }
4530
+ // Timestamped on-disk trail of panel-endpoint.json ownership handoffs. Mirrors
4531
+ // logPostEvent's rationale (per-process console.error is hard to even locate
4532
+ // across ephemeral, per-host-session MCP instances), for the panel-election
4533
+ // side instead of the posting side. writePanelUrl() runs on every process's
4534
+ // eager startup, so this also captures the mundane "claimed with nothing
4535
+ // registered" and "ceded to an already-alive owner" cases, not just handoffs
4536
+ // — that's what makes it possible to tell "a fresh instance took over from a
4537
+ // dead registrant" apart from "an approve click found nothing registered at
4538
+ // all" after the fact, instead of inferring it from timing alone.
4539
+ function logPanelEvent(msg) {
4540
+ try {
4541
+ const dir = path.join(repoDir(), "skill", "logs");
4542
+ fs.mkdirSync(dir, { recursive: true });
4543
+ fs.appendFileSync(path.join(dir, "panel-events.log"), `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);
4544
+ }
4545
+ catch {
4546
+ /* best effort */
4547
+ }
4548
+ }
4227
4549
  function sigkillScanTree(pid) {
4228
4550
  logPostEvent(`sigkill_scan_tree target=${pid}`);
4229
4551
  try {
@@ -4633,6 +4955,15 @@ async function drainApprovedBacklog() {
4633
4955
  }
4634
4956
  async function main() {
4635
4957
  initSentry();
4958
+ // Detect a self-update (old_version -> new_version) as the very first thing
4959
+ // after Sentry is up, before anything else that could restart/exit. See
4960
+ // checkVersionChange's own docstring for why this exists.
4961
+ try {
4962
+ checkVersionChange();
4963
+ }
4964
+ catch (e) {
4965
+ console.error("[social-autoposter-mcp] version-change check failed:", e?.message || e);
4966
+ }
4636
4967
  // Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
4637
4968
  // Cloud Run relay (-> Cloud Logging) so we can troubleshoot/rescue any user
4638
4969
  // scenario (silent stalls, partial onboarding) without asking them to ship a
@@ -4674,16 +5005,31 @@ async function main() {
4674
5005
  catch (e) {
4675
5006
  console.error(`[queue-worker] could not create worker folder: ${e?.message || e}`);
4676
5007
  }
4677
- void ensureQueueKickerInstalled()
4678
- .then((r) => console.error(`[queue-worker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4679
- .catch((e) => console.error("[queue-worker] kicker install failed:", e?.message || e));
5008
+ // The 4 pipeline daemons (kicker, reaper, stall-watch, memory-snapshot) are
5009
+ // skipped at boot while paused.flag is present, so a Claude Desktop restart
5010
+ // during a Pause doesn't silently un-pause the pipeline — see pauseS4L/
5011
+ // resumeS4L. Feedback-digest is NOT gated: it only distills past review
5012
+ // decisions, not drafting/posting, so it's harmless to keep running.
5013
+ if (!isPaused()) {
5014
+ void ensureQueueKickerInstalled()
5015
+ .then((r) => console.error(`[queue-worker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
5016
+ .catch((e) => console.error("[queue-worker] kicker install failed:", e?.message || e));
5017
+ }
5018
+ else {
5019
+ console.error("[queue-worker] launchd kicker: skip (paused)");
5020
+ }
4680
5021
  // Self-healing reaper for the agent-mode session leak the queue autopilot
4681
5022
  // produces (finished `claude` worker sessions Desktop never tears down). A
4682
5023
  // standalone guardrail; install unconditionally so it caps memory even on a
4683
5024
  // box whose project isn't ready yet. Best-effort; must never block boot.
4684
- void ensureClaudeReaperInstalled()
4685
- .then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4686
- .catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
5025
+ if (!isPaused()) {
5026
+ void ensureClaudeReaperInstalled()
5027
+ .then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
5028
+ .catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
5029
+ }
5030
+ else {
5031
+ console.error("[claude-reaper] launchd reaper: skip (paused)");
5032
+ }
4687
5033
  // Feedback digest: hourly distillation of the user's card approve/reject
4688
5034
  // decisions into learned_preferences (see scripts/feedback_digest.py).
4689
5035
  // Best-effort; a box with no review events runs a no-op.
@@ -4693,15 +5039,25 @@ async function main() {
4693
5039
  // Autopilot stall watchdog: fleet-side Sentry alert when the draft routines stop
4694
5040
  // draining (most often an account switch orphaning them). The menu bar shows the
4695
5041
  // user the Re-arm action; this is the part we see. Best-effort; never blocks boot.
4696
- void ensureStallWatchInstalled()
4697
- .then((r) => console.error(`[stall-watch] launchd watchdog: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4698
- .catch((e) => console.error("[stall-watch] watchdog install failed:", e?.message || e));
5042
+ if (!isPaused()) {
5043
+ void ensureStallWatchInstalled()
5044
+ .then((r) => console.error(`[stall-watch] launchd watchdog: ${r.ok ? "ok" : "skip"} (${r.detail})`))
5045
+ .catch((e) => console.error("[stall-watch] watchdog install failed:", e?.message || e));
5046
+ }
5047
+ else {
5048
+ console.error("[stall-watch] launchd watchdog: skip (paused)");
5049
+ }
4699
5050
  // Periodic host-resource sampler (memory/process snapshot -> local JSONL). Gives
4700
5051
  // us per-box resource history to diagnose RAM blowups (e.g. the agent-mode
4701
5052
  // session leak). Best-effort; never blocks boot. Disable with S4L_MEMORY_SNAPSHOT=0.
4702
- void ensureMemorySnapshotInstalled()
4703
- .then((r) => console.error(`[memory-snapshot] launchd sampler: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4704
- .catch((e) => console.error("[memory-snapshot] sampler install failed:", e?.message || e));
5053
+ if (!isPaused()) {
5054
+ void ensureMemorySnapshotInstalled()
5055
+ .then((r) => console.error(`[memory-snapshot] launchd sampler: ${r.ok ? "ok" : "skip"} (${r.detail})`))
5056
+ .catch((e) => console.error("[memory-snapshot] sampler install failed:", e?.message || e));
5057
+ }
5058
+ else {
5059
+ console.error("[memory-snapshot] launchd sampler: skip (paused)");
5060
+ }
4705
5061
  // On-screen overlay watcher supervisor. The harness status overlay only renders
4706
5062
  // while the watcher process is alive, and that watcher had no supervisor — when
4707
5063
  // it died nothing respawned it and the overlay silently vanished. Install it as
@@ -4710,6 +5066,13 @@ async function main() {
4710
5066
  void ensureOverlayWatchInstalled()
4711
5067
  .then((r) => console.error(`[overlay-watch] launchd supervisor: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4712
5068
  .catch((e) => console.error("[overlay-watch] supervisor install failed:", e?.message || e));
5069
+ // Daily self-updater: restored 2026-07-08 after 88bd1cb9 ("Remove autopilot
5070
+ // tool") silently dropped its only install path (it used to be bundled into
5071
+ // the deleted `autopilot enable` action). Best-effort; never blocks boot.
5072
+ // Disable with S4L_AUTO_UPDATE=0.
5073
+ void ensureUpdaterInstalled()
5074
+ .then((r) => console.error(`[self-update] launchd updater: ${r.ok ? "ok" : "skip"} (${r.detail})`))
5075
+ .catch((e) => console.error("[self-update] updater install failed:", e?.message || e));
4713
5076
  // Heal installs onboarded before short_links_live defaulted to false: such a
4714
5077
  // project wraps short links against the customer's own domain, which has no
4715
5078
  // /r/[code] resolver, so every minted link 404s. Re-point them at the s4l.ai
@@ -10,18 +10,11 @@ import { createRequire } from "node:module";
10
10
  import { repoDir, run } from "./repo.js";
11
11
  import { resolvePython } from "./runtime.js";
12
12
  const require = createRequire(import.meta.url);
13
- export const ONBOARDING_MILESTONES = [
14
- "environment_checked",
15
- "runtime_ready",
16
- "x_connected",
17
- "profile_scanned",
18
- "mode_chosen",
19
- "project_ready",
20
- "topics_seeded",
21
- "tasks_scheduled",
22
- ];
23
13
  const ledgerApi = require("../shared/onboarding-ledger.cjs");
24
14
  const doctorApi = require("../shared/doctor.cjs");
15
+ // Re-export of the ONE runtime array (see the comment above the imports): this
16
+ // is not a second list, just a typed alias for ledgerApi.MILESTONES.
17
+ export const ONBOARDING_MILESTONES = ledgerApi.MILESTONES;
25
18
  export function onboardingSnapshot() {
26
19
  return ledgerApi.publicSnapshot();
27
20
  }