@m13v/s4l 1.7.1-rc.13 → 1.7.1-rc.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/dist/index.js CHANGED
@@ -1181,10 +1181,21 @@ async function postApproved(batchId, plan) {
1181
1181
  }
1182
1182
  // Post failures are HANDLED in the pipeline (it returns a count, never throws),
1183
1183
  // 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) {
1184
+ // the run exited non-zero OR a REAL failure happened. This is the only
1185
+ // telemetry channel that reaches a customer .mcpb install (their cycle log
1186
+ // lives on their machine). install_id/hostname are auto-tagged.
1187
+ //
1188
+ // Gated on failure_reasons (not just realPosted < approved.length): the
1189
+ // Python pipeline already reports every shortfall to Sentry itself
1190
+ // (twitter_post_plan.py's capture_message), including benign skips like a
1191
+ // deleted target tweet. Re-reporting the SAME benign skip here as a second,
1192
+ // independently-fingerprinted `Error` (this capture group) doubled every
1193
+ // such event into two distinct Sentry issues. Only add this Node-side
1194
+ // capture when there's a real failure_reasons entry or a non-zero exit,
1195
+ // i.e. something the Python capture wouldn't already have flagged as an
1196
+ // actionable error on its own.
1197
+ const hasRealFailure = res.code !== 0 || Boolean(summObj?.failure_reasons);
1198
+ if (hasRealFailure) {
1188
1199
  captureError(new Error(`post_drafts: ${realPosted}/${approved.length} posted (exit=${res.code})`), {
1189
1200
  component: "post",
1190
1201
  exit_code: String(res.code),
@@ -1594,7 +1605,10 @@ tool("engagement_mode", {
1594
1605
  // persona is seeded. (2026-06-30) Skipped when promotion-only, since the
1595
1606
  // product project isn't configured yet (it stays gated until project_config).
1596
1607
  let kickerInstall = null;
1597
- if (personalBrand) {
1608
+ if (personalBrand && isPaused()) {
1609
+ kickerInstall = { ok: false, detail: "skip (paused)" };
1610
+ }
1611
+ else if (personalBrand) {
1598
1612
  try {
1599
1613
  kickerInstall = await ensureQueueKickerInstalled();
1600
1614
  console.error(`[engagement_mode] launchd kicker: ${kickerInstall.ok ? "ok" : "skip"} (${kickerInstall.detail})`);
@@ -2088,7 +2102,10 @@ tool("project_config", {
2088
2102
  // persona-aware, so calling it from both setup paths is safe. Best-effort:
2089
2103
  // a kicker hiccup never fails setup. (2026-06-30)
2090
2104
  let kickerInstall = null;
2091
- if (result.ready) {
2105
+ if (result.ready && isPaused()) {
2106
+ kickerInstall = { ok: false, detail: "skip (paused)" };
2107
+ }
2108
+ else if (result.ready) {
2092
2109
  try {
2093
2110
  kickerInstall = await ensureQueueKickerInstalled();
2094
2111
  console.error(`[project_config] launchd kicker: ${kickerInstall.ok ? "ok" : "skip"} (${kickerInstall.detail})`);
@@ -2484,6 +2501,7 @@ tool("runtime", {
2484
2501
  return jsonContent({
2485
2502
  ...snapshot,
2486
2503
  menubar_running: await menubarRunning(),
2504
+ paused: isPaused(),
2487
2505
  onboarding: onboardingSnapshot(),
2488
2506
  });
2489
2507
  });
@@ -2514,6 +2532,37 @@ tool("restart_menubar", {
2514
2532
  menubar_running: running,
2515
2533
  });
2516
2534
  });
2535
+ // ---- pause_s4l: temporarily stop drafting/posting, reversibly --------------
2536
+ // The lighter alternative to Quit: unloads only the launchd jobs that scan,
2537
+ // draft, and post (plus their support daemons), leaving Claude Desktop, the
2538
+ // S4L tray, X connection, and the draft schedule registration untouched.
2539
+ // Fully reversible — action:'resume' reinstalls the exact same daemons via the
2540
+ // same idempotent ensure*Installed() functions boot uses. NOT "autopilot": S4L
2541
+ // is draft-first (a human approves every post), so this pauses/resumes the
2542
+ // draft pipeline itself, not some autonomous posting mode.
2543
+ tool("pause_s4l", {
2544
+ title: "Pause or resume S4L",
2545
+ description: "Pause temporarily stops S4L's own draft pipeline (the launchd kicker that scans/drafts/posts, " +
2546
+ "plus its reaper/stall-watch/memory-snapshot support jobs) WITHOUT touching Claude Desktop, the " +
2547
+ "S4L tray, your X connection, or the draft schedule registration — nothing is deleted, so it " +
2548
+ "survives a Claude Desktop restart and Resume brings it right back. Use when the user asks to " +
2549
+ "pause, stop, or temporarily disable S4L, or to resume/unpause it. This is NOT the same as Quit " +
2550
+ "(which kills and restarts Claude Desktop and removes the draft schedule) — Pause is the lighter, " +
2551
+ "fully reversible option. action:'status' (default) reports whether it's currently paused.",
2552
+ inputSchema: {
2553
+ action: z.enum(["status", "pause", "resume"]).optional(),
2554
+ },
2555
+ }, async ({ action }) => {
2556
+ if (action === "pause") {
2557
+ const res = await pauseS4L();
2558
+ return jsonContent({ action: "pause", paused: isPaused(), ...res });
2559
+ }
2560
+ if (action === "resume") {
2561
+ const res = await resumeS4L();
2562
+ return jsonContent({ action: "resume", paused: isPaused(), ...res });
2563
+ }
2564
+ return jsonContent({ action: "status", paused: isPaused() });
2565
+ });
2517
2566
  // ---- report_diagnosis: ship a field diagnosis to the developers -------------
2518
2567
  // First-class MCP wrapper over scripts/send_diagnostic_report.py (the same
2519
2568
  // Sentry lane the menubar "Diagnose & fix" prompt uses). Before this existed
@@ -3576,6 +3625,76 @@ async function ensureMemorySnapshotInstalled() {
3576
3625
  return { ok: false, detail: e?.message || String(e) };
3577
3626
  }
3578
3627
  }
3628
+ // ---- pause/resume: stop drafting/posting without touching Claude Desktop ---
3629
+ // Unlike Quit (which kills + relaunches Claude Desktop and deletes the draft
3630
+ // schedule), Pause only unloads the launchd jobs that actually DO work — the
3631
+ // kicker that scans/drafts/posts (TWITTER_AUTOPILOT_LABEL) plus its reaper,
3632
+ // stall-watch, and memory-snapshot support daemons — while leaving Claude
3633
+ // Desktop, the tray, and the Claude-native s4l-worker scheduled task alone.
3634
+ // The scheduled task still fires on its own cadence; claude_job.py's `next`
3635
+ // checks the same flag file and reports "no work" instantly instead of
3636
+ // draining the queue, so nothing drafts or posts while paused even if a
3637
+ // worker session wakes up mid-pause. A flag file (nothing is deleted) is what
3638
+ // makes Resume a plain reinstall of the same 4 daemons — see resumeS4L.
3639
+ function pauseFlagPath() {
3640
+ return path.join(s4lStateDir(), "paused.flag");
3641
+ }
3642
+ function isPaused() {
3643
+ try {
3644
+ return fs.existsSync(pauseFlagPath());
3645
+ }
3646
+ catch {
3647
+ return false;
3648
+ }
3649
+ }
3650
+ const PAUSE_TARGETS = [
3651
+ { label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST },
3652
+ { label: REAPER_LABEL, plist: REAPER_PLIST },
3653
+ { label: STALL_WATCH_LABEL, plist: STALL_WATCH_PLIST },
3654
+ { label: MEMORY_SNAPSHOT_LABEL, plist: MEMORY_SNAPSHOT_PLIST },
3655
+ ];
3656
+ async function pauseS4L() {
3657
+ if (isPaused())
3658
+ return { ok: true, detail: "already paused" };
3659
+ try {
3660
+ fs.mkdirSync(path.dirname(pauseFlagPath()), { recursive: true });
3661
+ fs.writeFileSync(pauseFlagPath(), `paused at ${new Date().toISOString()}\n`, "utf-8");
3662
+ }
3663
+ catch (e) {
3664
+ return { ok: false, detail: `could not write pause flag: ${e?.message || e}` };
3665
+ }
3666
+ const uid = process.getuid ? process.getuid() : 0;
3667
+ const results = [];
3668
+ for (const { label, plist } of PAUSE_TARGETS) {
3669
+ try {
3670
+ const res = await unloadPlist(label, plist, uid);
3671
+ results.push(`${label}: unloaded (rc=${res.code})`);
3672
+ }
3673
+ catch (e) {
3674
+ results.push(`${label}: ${e?.message || e}`);
3675
+ }
3676
+ }
3677
+ return { ok: true, detail: results.join("; ") };
3678
+ }
3679
+ async function resumeS4L() {
3680
+ try {
3681
+ fs.rmSync(pauseFlagPath(), { force: true });
3682
+ }
3683
+ catch {
3684
+ /* best-effort */
3685
+ }
3686
+ const kicker = await ensureQueueKickerInstalled();
3687
+ const reaper = await ensureClaudeReaperInstalled();
3688
+ const stall = await ensureStallWatchInstalled();
3689
+ const mem = await ensureMemorySnapshotInstalled();
3690
+ const detail = [
3691
+ `kicker: ${kicker.ok ? "ok" : "skip"} (${kicker.detail})`,
3692
+ `reaper: ${reaper.ok ? "ok" : "skip"} (${reaper.detail})`,
3693
+ `stall-watch: ${stall.ok ? "ok" : "skip"} (${stall.detail})`,
3694
+ `memory-snapshot: ${mem.ok ? "ok" : "skip"} (${mem.detail})`,
3695
+ ].join("; ");
3696
+ return { ok: true, detail };
3697
+ }
3579
3698
  // Install/refresh the on-screen overlay watcher launchd job. Promotes the
3580
3699
  // harness status overlay from a best-effort, fired-from-other-tools nicety to a
3581
3700
  // first-class self-healing job. We run `harness_overlay.py watch` directly in
@@ -3771,6 +3890,7 @@ async function buildSnapshot() {
3771
3890
  // (it's a launchctl check the Node side owns), so layer it on here. The panel
3772
3891
  // uses it to offer a one-click "restart menu bar" when the tray was quit.
3773
3892
  snap.menubar_running = await menubarRunning();
3893
+ snap.paused = isPaused();
3774
3894
  await ensureDoctorPhase(snap.x_connected ? "full" : "pre_connect");
3775
3895
  if (snap.runtime_ready)
3776
3896
  completeOnboardingMilestone("runtime_ready");
@@ -4806,16 +4926,31 @@ async function main() {
4806
4926
  catch (e) {
4807
4927
  console.error(`[queue-worker] could not create worker folder: ${e?.message || e}`);
4808
4928
  }
4809
- void ensureQueueKickerInstalled()
4810
- .then((r) => console.error(`[queue-worker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4811
- .catch((e) => console.error("[queue-worker] kicker install failed:", e?.message || e));
4929
+ // The 4 pipeline daemons (kicker, reaper, stall-watch, memory-snapshot) are
4930
+ // skipped at boot while paused.flag is present, so a Claude Desktop restart
4931
+ // during a Pause doesn't silently un-pause the pipeline — see pauseS4L/
4932
+ // resumeS4L. Feedback-digest is NOT gated: it only distills past review
4933
+ // decisions, not drafting/posting, so it's harmless to keep running.
4934
+ if (!isPaused()) {
4935
+ void ensureQueueKickerInstalled()
4936
+ .then((r) => console.error(`[queue-worker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4937
+ .catch((e) => console.error("[queue-worker] kicker install failed:", e?.message || e));
4938
+ }
4939
+ else {
4940
+ console.error("[queue-worker] launchd kicker: skip (paused)");
4941
+ }
4812
4942
  // Self-healing reaper for the agent-mode session leak the queue autopilot
4813
4943
  // produces (finished `claude` worker sessions Desktop never tears down). A
4814
4944
  // standalone guardrail; install unconditionally so it caps memory even on a
4815
4945
  // box whose project isn't ready yet. Best-effort; must never block boot.
4816
- void ensureClaudeReaperInstalled()
4817
- .then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4818
- .catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
4946
+ if (!isPaused()) {
4947
+ void ensureClaudeReaperInstalled()
4948
+ .then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4949
+ .catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
4950
+ }
4951
+ else {
4952
+ console.error("[claude-reaper] launchd reaper: skip (paused)");
4953
+ }
4819
4954
  // Feedback digest: hourly distillation of the user's card approve/reject
4820
4955
  // decisions into learned_preferences (see scripts/feedback_digest.py).
4821
4956
  // Best-effort; a box with no review events runs a no-op.
@@ -4825,15 +4960,25 @@ async function main() {
4825
4960
  // Autopilot stall watchdog: fleet-side Sentry alert when the draft routines stop
4826
4961
  // draining (most often an account switch orphaning them). The menu bar shows the
4827
4962
  // user the Re-arm action; this is the part we see. Best-effort; never blocks boot.
4828
- void ensureStallWatchInstalled()
4829
- .then((r) => console.error(`[stall-watch] launchd watchdog: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4830
- .catch((e) => console.error("[stall-watch] watchdog install failed:", e?.message || e));
4963
+ if (!isPaused()) {
4964
+ void ensureStallWatchInstalled()
4965
+ .then((r) => console.error(`[stall-watch] launchd watchdog: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4966
+ .catch((e) => console.error("[stall-watch] watchdog install failed:", e?.message || e));
4967
+ }
4968
+ else {
4969
+ console.error("[stall-watch] launchd watchdog: skip (paused)");
4970
+ }
4831
4971
  // Periodic host-resource sampler (memory/process snapshot -> local JSONL). Gives
4832
4972
  // us per-box resource history to diagnose RAM blowups (e.g. the agent-mode
4833
4973
  // session leak). Best-effort; never blocks boot. Disable with S4L_MEMORY_SNAPSHOT=0.
4834
- void ensureMemorySnapshotInstalled()
4835
- .then((r) => console.error(`[memory-snapshot] launchd sampler: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4836
- .catch((e) => console.error("[memory-snapshot] sampler install failed:", e?.message || e));
4974
+ if (!isPaused()) {
4975
+ void ensureMemorySnapshotInstalled()
4976
+ .then((r) => console.error(`[memory-snapshot] launchd sampler: ${r.ok ? "ok" : "skip"} (${r.detail})`))
4977
+ .catch((e) => console.error("[memory-snapshot] sampler install failed:", e?.message || e));
4978
+ }
4979
+ else {
4980
+ console.error("[memory-snapshot] launchd sampler: skip (paused)");
4981
+ }
4837
4982
  // On-screen overlay watcher supervisor. The harness status overlay only renders
4838
4983
  // while the watcher process is alive, and that watcher had no supervisor — when
4839
4984
  // it died nothing respawned it and the overlay silently vanished. Install it as