@m13v/s4l 1.7.6-rc.39 → 1.7.6-rc.40

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
@@ -4715,15 +4715,15 @@ async function ensureMemorySnapshotInstalled() {
4715
4715
  }
4716
4716
  // ---- pause/resume: stop drafting/posting without touching Claude Desktop ---
4717
4717
  // Unlike Quit (which kills + relaunches Claude Desktop and deletes the draft
4718
- // schedule), Pause only unloads the launchd jobs that actually DO work — the
4719
- // kicker that scans/drafts/posts (TWITTER_AUTOPILOT_LABEL) plus its reaper,
4720
- // stall-watch, and memory-snapshot support daemons — while leaving Claude
4721
- // Desktop, the tray, and the Claude-native s4l-worker scheduled task alone.
4722
- // The scheduled task still fires on its own cadence; claude_job.py's `next`
4723
- // checks the same flag file and reports "no work" instantly instead of
4718
+ // schedule), Pause only unloads the launchd jobs that actually DO work — every
4719
+ // pipeline kicker (X cycle, Reddit discovery) plus the reaper, stall-watch, and
4720
+ // memory-snapshot support daemons — while leaving Claude Desktop, the tray, and
4721
+ // the Claude-native s4l-worker scheduled task alone. The scheduled task still
4722
+ // fires on its own cadence; claude_job.py's `next` checks the same flag file
4723
+ // (at entry AND on every poll iteration) and reports "no work" instead of
4724
4724
  // draining the queue, so nothing drafts or posts while paused even if a
4725
4725
  // worker session wakes up mid-pause. A flag file (nothing is deleted) is what
4726
- // makes Resume a plain reinstall of the same 4 daemons — see resumeS4L.
4726
+ // makes Resume a plain reinstall of the same daemons — see resumeS4L.
4727
4727
  function pauseFlagPath() {
4728
4728
  return path.join(s4lStateDir(), "paused.flag");
4729
4729
  }
@@ -4735,15 +4735,38 @@ function isPaused() {
4735
4735
  return false;
4736
4736
  }
4737
4737
  }
4738
+ // THE single registry of pausable pipeline daemons. Pause unloads exactly this
4739
+ // set, Resume reinstalls exactly this set (each ensure re-applies its own
4740
+ // install gates: draftable lane, reddit connected, etc.), boot skips exactly
4741
+ // this set while paused, and the label list is mirrored to
4742
+ // <state>/pause-targets.json so the menubar's Python pause button
4743
+ // (mcp/menubar/s4l_state.py) acts on the same set without a second hardcoded
4744
+ // list. Add a new pipeline daemon HERE and every pause surface picks it up.
4745
+ // Feedback-digest is deliberately absent: it only distills past review
4746
+ // decisions, not drafting/posting, so it keeps running while paused.
4738
4747
  const PAUSE_TARGETS = [
4739
- { label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST },
4740
- { label: REAPER_LABEL, plist: REAPER_PLIST },
4741
- { label: STALL_WATCH_LABEL, plist: STALL_WATCH_PLIST },
4742
- { label: MEMORY_SNAPSHOT_LABEL, plist: MEMORY_SNAPSHOT_PLIST },
4748
+ { tag: "queue-worker", label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST, ensure: ensureQueueKickerInstalled },
4749
+ { tag: "reddit-kicker", label: REDDIT_SEARCH_LABEL, plist: REDDIT_SEARCH_PLIST, ensure: ensureRedditKickerInstalled },
4750
+ { tag: "claude-reaper", label: REAPER_LABEL, plist: REAPER_PLIST, ensure: ensureClaudeReaperInstalled },
4751
+ { tag: "stall-watch", label: STALL_WATCH_LABEL, plist: STALL_WATCH_PLIST, ensure: ensureStallWatchInstalled },
4752
+ { tag: "memory-snapshot", label: MEMORY_SNAPSHOT_LABEL, plist: MEMORY_SNAPSHOT_PLIST, ensure: ensureMemorySnapshotInstalled },
4743
4753
  ];
4754
+ // Mirror the pausable label set into the state dir for the menubar's Python
4755
+ // pause/resume (which can't import this module). Written at boot and on every
4756
+ // pause/resume so the two surfaces can't drift.
4757
+ function writePauseTargetsManifest() {
4758
+ try {
4759
+ fs.mkdirSync(s4lStateDir(), { recursive: true });
4760
+ fs.writeFileSync(path.join(s4lStateDir(), "pause-targets.json"), JSON.stringify({ labels: PAUSE_TARGETS.map((t) => t.label) }) + "\n", "utf-8");
4761
+ }
4762
+ catch {
4763
+ /* best-effort */
4764
+ }
4765
+ }
4744
4766
  async function pauseS4L() {
4745
4767
  if (isPaused())
4746
4768
  return { ok: true, detail: "already paused" };
4769
+ writePauseTargetsManifest();
4747
4770
  try {
4748
4771
  fs.mkdirSync(path.dirname(pauseFlagPath()), { recursive: true });
4749
4772
  fs.writeFileSync(pauseFlagPath(), `paused at ${new Date().toISOString()}\n`, "utf-8");
@@ -4771,17 +4794,18 @@ async function resumeS4L() {
4771
4794
  catch {
4772
4795
  /* best-effort */
4773
4796
  }
4774
- const kicker = await ensureQueueKickerInstalled();
4775
- const reaper = await ensureClaudeReaperInstalled();
4776
- const stall = await ensureStallWatchInstalled();
4777
- const mem = await ensureMemorySnapshotInstalled();
4778
- const detail = [
4779
- `kicker: ${kicker.ok ? "ok" : "skip"} (${kicker.detail})`,
4780
- `reaper: ${reaper.ok ? "ok" : "skip"} (${reaper.detail})`,
4781
- `stall-watch: ${stall.ok ? "ok" : "skip"} (${stall.detail})`,
4782
- `memory-snapshot: ${mem.ok ? "ok" : "skip"} (${mem.detail})`,
4783
- ].join("; ");
4784
- return { ok: true, detail };
4797
+ writePauseTargetsManifest();
4798
+ const results = [];
4799
+ for (const t of PAUSE_TARGETS) {
4800
+ try {
4801
+ const r = await t.ensure();
4802
+ results.push(`${t.tag}: ${r.ok ? "ok" : "skip"} (${r.detail})`);
4803
+ }
4804
+ catch (e) {
4805
+ results.push(`${t.tag}: ${e?.message || e}`);
4806
+ }
4807
+ }
4808
+ return { ok: true, detail: results.join("; ") };
4785
4809
  }
4786
4810
  // Install/refresh the on-screen overlay watcher launchd job. Promotes the
4787
4811
  // harness status overlay from a best-effort, fired-from-other-tools nicety to a
@@ -5822,7 +5846,7 @@ appTool("dashboard", {
5822
5846
  (snap.update_available && snap.latest_version ? ` (update to ${snap.latest_version})` : "") +
5823
5847
  ` — projects ${snap.projects_ready}/${snap.projects_total} ready, ` +
5824
5848
  `X ${snap.x_connected ? "connected" : "not connected"}, ` +
5825
- `autopilot ${snap.autopilot_on ? "on" : "off"}.`;
5849
+ `autopilot ${snap.paused ? "PAUSED" : snap.autopilot_on ? "on" : "off"}.`;
5826
5850
  const base = {
5827
5851
  content: [{ type: "text", text: human }],
5828
5852
  structuredContent: { snapshot: JSON.stringify(snap) },
@@ -6074,73 +6098,34 @@ async function main() {
6074
6098
  catch (e) {
6075
6099
  console.error(`[queue-worker] could not create worker folder: ${e?.message || e}`);
6076
6100
  }
6077
- // The 4 pipeline daemons (kicker, reaper, stall-watch, memory-snapshot) are
6078
- // skipped at boot while paused.flag is present, so a Claude Desktop restart
6101
+ // Pausable pipeline daemons — one loop over the SAME registry pause/resume
6102
+ // use (PAUSE_TARGETS: X kicker, reddit kicker, reaper, stall-watch,
6103
+ // memory-snapshot), so boot can never install a daemon Pause doesn't cover.
6104
+ // Each is skipped while paused.flag is present, so a Claude Desktop restart
6079
6105
  // during a Pause doesn't silently un-pause the pipeline — see pauseS4L/
6080
- // resumeS4L. Feedback-digest is NOT gated: it only distills past review
6081
- // decisions, not drafting/posting, so it's harmless to keep running.
6082
- if (!isPaused()) {
6083
- void ensureQueueKickerInstalled()
6084
- .then((r) => console.error(`[queue-worker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
6085
- .catch((e) => console.error("[queue-worker] kicker install failed:", e?.message || e));
6086
- }
6087
- else {
6088
- console.error("[queue-worker] launchd kicker: skip (paused)");
6089
- }
6090
- // Reddit discovery kicker (optional platform): installs only when reddit is
6091
- // connected AND a draftable lane exists (product ready OR active persona, via
6092
- // the shared draftableLaneStatus). A box that never connected reddit is a cheap
6093
- // no-op skip. This boot-time call also SELF-HEALS installs broken by the old
6094
- // product-only gate: once the gate passes, the plist lands on the next boot
6095
- // (worker sessions boot ~1/min). Paused-gated like the X kicker (produces drafts).
6096
- if (!isPaused()) {
6097
- void ensureRedditKickerInstalled()
6098
- .then((r) => console.error(`[reddit-kicker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
6099
- .catch((e) => console.error("[reddit-kicker] kicker install failed:", e?.message || e));
6100
- }
6101
- else {
6102
- console.error("[reddit-kicker] launchd kicker: skip (paused)");
6103
- }
6104
- // Self-healing reaper for the agent-mode session leak the queue autopilot
6105
- // produces (finished `claude` worker sessions Desktop never tears down). A
6106
- // standalone guardrail; install unconditionally so it caps memory even on a
6107
- // box whose project isn't ready yet. Best-effort; must never block boot.
6108
- if (!isPaused()) {
6109
- void ensureClaudeReaperInstalled()
6110
- .then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
6111
- .catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
6112
- }
6113
- else {
6114
- console.error("[claude-reaper] launchd reaper: skip (paused)");
6106
+ // resumeS4L. Each ensure keeps its own install gates (draftable lane, reddit
6107
+ // connected, etc.); a box that never connected reddit is a cheap no-op skip.
6108
+ // The boot-time call also SELF-HEALS installs broken by older gates: once a
6109
+ // gate passes, the plist lands on the next boot. All best-effort; never block
6110
+ // boot.
6111
+ writePauseTargetsManifest();
6112
+ for (const t of PAUSE_TARGETS) {
6113
+ if (isPaused()) {
6114
+ console.error(`[${t.tag}] launchd ${t.label}: skip (paused)`);
6115
+ continue;
6116
+ }
6117
+ void t.ensure()
6118
+ .then((r) => console.error(`[${t.tag}] launchd: ${r.ok ? "ok" : "skip"} (${r.detail})`))
6119
+ .catch((e) => console.error(`[${t.tag}] launchd install failed:`, e?.message || e));
6115
6120
  }
6116
6121
  // Feedback digest: hourly distillation of the user's card approve/reject
6117
6122
  // decisions into learned_preferences (see scripts/feedback_digest.py).
6118
- // Best-effort; a box with no review events runs a no-op.
6123
+ // Best-effort; a box with no review events runs a no-op. NOT pause-gated (and
6124
+ // not in PAUSE_TARGETS): it only distills past review decisions, not
6125
+ // drafting/posting, so it's harmless to keep running.
6119
6126
  void ensureFeedbackDigestInstalled()
6120
6127
  .then((r) => console.error(`[feedback-digest] launchd digest: ${r.ok ? "ok" : "skip"} (${r.detail})`))
6121
6128
  .catch((e) => console.error("[feedback-digest] digest install failed:", e?.message || e));
6122
- // Autopilot stall watchdog: fleet-side Sentry alert when the draft routines stop
6123
- // draining (most often an account switch orphaning them). The menu bar shows the
6124
- // user the Re-arm action; this is the part we see. Best-effort; never blocks boot.
6125
- if (!isPaused()) {
6126
- void ensureStallWatchInstalled()
6127
- .then((r) => console.error(`[stall-watch] launchd watchdog: ${r.ok ? "ok" : "skip"} (${r.detail})`))
6128
- .catch((e) => console.error("[stall-watch] watchdog install failed:", e?.message || e));
6129
- }
6130
- else {
6131
- console.error("[stall-watch] launchd watchdog: skip (paused)");
6132
- }
6133
- // Periodic host-resource sampler (memory/process snapshot -> local JSONL). Gives
6134
- // us per-box resource history to diagnose RAM blowups (e.g. the agent-mode
6135
- // session leak). Best-effort; never blocks boot. Disable with S4L_MEMORY_SNAPSHOT=0.
6136
- if (!isPaused()) {
6137
- void ensureMemorySnapshotInstalled()
6138
- .then((r) => console.error(`[memory-snapshot] launchd sampler: ${r.ok ? "ok" : "skip"} (${r.detail})`))
6139
- .catch((e) => console.error("[memory-snapshot] sampler install failed:", e?.message || e));
6140
- }
6141
- else {
6142
- console.error("[memory-snapshot] launchd sampler: skip (paused)");
6143
- }
6144
6129
  // On-screen overlay watcher supervisor. The harness status overlay only renders
6145
6130
  // while the watcher process is alive, and that watcher had no supervisor — when
6146
6131
  // it died nothing respawned it and the overlay silently vanished. Install it as
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.39",
3
- "installedAt": "2026-07-18T17:11:26.296Z"
2
+ "version": "1.7.6-rc.40",
3
+ "installedAt": "2026-07-18T17:26:43.722Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.6-rc.39",
5
+ "version": "1.7.6-rc.40",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
@@ -90,22 +90,42 @@ def state_dir() -> str:
90
90
  # to the MCP: this needs to work from BOTH the menu bar (s4l_menubar.py) and
91
91
  # the Claude-independent dashboard_server.py, which share this process but
92
92
  # are a different process from the MCP server, so there's no way to delegate
93
- # to mcp/src/index.ts's pauseS4L/resumeS4L here. Mirrors those functions
94
- # (same 4 labels, same flag path) for the common pause->resume case. What it
95
- # deliberately does NOT do: mcp/src/index.ts's resumeS4L also re-runs the
96
- # readiness-gated install logic (persona/project/X-verified checks, content-
97
- # aware plist rewrite) via ensure*Installed() — that only matters for a box
98
- # that was never fully set up before Pause, an edge case the next Claude
99
- # boot / project_config call naturally heals once unpaused. This resume just
100
- # reloads the exact plists Pause unloaded (Pause never deletes anything).
93
+ # to mcp/src/index.ts's pauseS4L/resumeS4L here. The label SET is not
94
+ # duplicated though: index.ts's PAUSE_TARGETS registry is the single source of
95
+ # truth, mirrored to <state>/pause-targets.json at every MCP boot and
96
+ # pause/resume; pause_target_labels() reads that manifest, so a pipeline daemon
97
+ # added to the registry reaches this button without touching this file.
98
+ # PAUSE_TARGET_LABELS below is only the fallback for a box whose MCP has never
99
+ # written the manifest. What this resume deliberately does NOT do:
100
+ # mcp/src/index.ts's resumeS4L also re-runs the readiness-gated install logic
101
+ # (persona/project/X-verified checks, content-aware plist rewrite) via
102
+ # ensure*Installed() — that only matters for a box that was never fully set up
103
+ # before Pause, an edge case the next Claude boot / project_config call
104
+ # naturally heals once unpaused. This resume just reloads the exact plists
105
+ # Pause unloaded (Pause never deletes anything).
101
106
  PAUSE_TARGET_LABELS = (
102
107
  AUTOPILOT_LABEL,
108
+ "com.m13v.social-reddit-search",
103
109
  "com.m13v.social-claude-reaper",
104
110
  "com.m13v.social-autopilot-stall-watch",
105
111
  "com.m13v.social-memory-snapshot",
106
112
  )
107
113
 
108
114
 
115
+ def pause_target_labels() -> tuple:
116
+ try:
117
+ data = json.loads((Path(state_dir()) / "pause-targets.json").read_text())
118
+ labels = tuple(
119
+ l for l in data.get("labels", [])
120
+ if isinstance(l, str) and l.startswith("com.m13v.")
121
+ )
122
+ if labels:
123
+ return labels
124
+ except Exception:
125
+ pass
126
+ return PAUSE_TARGET_LABELS
127
+
128
+
109
129
  def pause_flag_path() -> str:
110
130
  return os.path.join(state_dir(), "paused.flag")
111
131
 
@@ -125,7 +145,7 @@ def pause_s4l() -> dict:
125
145
  return {"ok": False, "paused": is_paused(), "detail": f"could not write pause flag: {e}"}
126
146
  uid = os.getuid()
127
147
  results = []
128
- for label in PAUSE_TARGET_LABELS:
148
+ for label in pause_target_labels():
129
149
  try:
130
150
  subprocess.run(
131
151
  ["launchctl", "bootout", f"gui/{uid}/{label}"],
@@ -147,7 +167,7 @@ def resume_s4l() -> dict:
147
167
  uid = os.getuid()
148
168
  la_dir = str(Path.home() / "Library" / "LaunchAgents")
149
169
  results = []
150
- for label in PAUSE_TARGET_LABELS:
170
+ for label in pause_target_labels():
151
171
  plist = os.path.join(la_dir, f"{label}.plist")
152
172
  if not os.path.exists(plist):
153
173
  results.append(f"{label}: no plist, skip")
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.39",
3
+ "version": "1.7.6-rc.40",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.6-rc.39",
3
+ "version": "1.7.6-rc.40",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -608,14 +608,26 @@ def _atomic_write_text(path: str, text: str) -> None:
608
608
 
609
609
 
610
610
  def _sweep_stale() -> int:
611
- """Remove pending/running job files older than STALE_TTL_S. Returns count."""
611
+ """GC job files orphaned by dead producers. Returns count removed.
612
+
613
+ Two different TTLs on purpose:
614
+ - pending/: a job unclaimed past the producer's whole wait window
615
+ (DEFAULT_TIMEOUT_S + margin) has no live producer left to consume its
616
+ result — drafting it later only burns a worker turn on a stale prompt.
617
+ This is the leak signature of a producer that was SIGKILLed by its
618
+ external wrapper before its own timeout-cleanup ran (2026-07-18: three
619
+ orphaned reddit-draft jobs piled up while paused).
620
+ - running/: a claimed job may legitimately still be drafting, so it gets
621
+ the longer STALE_TTL_S before we conclude the worker died too.
622
+ """
612
623
  removed = 0
613
624
  now = time.time()
614
- roots = [running_dir()]
625
+ pending_ttl = DEFAULT_TIMEOUT_S + 120
626
+ roots = [(running_dir(), STALE_TTL_S)]
615
627
  pend = os.path.join(queue_root(), "pending")
616
628
  if os.path.isdir(pend):
617
- roots += [os.path.join(pend, d) for d in os.listdir(pend)]
618
- for d in roots:
629
+ roots += [(os.path.join(pend, d), pending_ttl) for d in os.listdir(pend)]
630
+ for d, ttl in roots:
619
631
  if not os.path.isdir(d):
620
632
  continue
621
633
  for name in os.listdir(d):
@@ -625,7 +637,7 @@ def _sweep_stale() -> int:
625
637
  try:
626
638
  with open(fp) as f:
627
639
  created = json.load(f).get("created_at", 0)
628
- if now - float(created) > STALE_TTL_S:
640
+ if now - float(created) > ttl:
629
641
  os.remove(fp)
630
642
  removed += 1
631
643
  except Exception:
@@ -719,6 +731,13 @@ def cmd_provider(ns) -> int:
719
731
  % (_before_len - len(prompt))
720
732
  )
721
733
 
734
+ # S4L paused: refuse to enqueue at all. Workers won't claim while paused, so
735
+ # an enqueued job would just sit in pending/ while this producer burns its
736
+ # whole wait window "drafting" and then dies to its external timeout.
737
+ if _is_paused():
738
+ _plog(f"S4L paused; refusing to enqueue {qtype} job")
739
+ return 1
740
+
722
741
  job_id = uuid.uuid4().hex
723
742
  created = time.time()
724
743
  # Cycle batch id (run-twitter-cycle.sh exports BATCH_ID) so every provider.log
@@ -749,11 +768,31 @@ def cmd_provider(ns) -> int:
749
768
  _act_write(qtype)
750
769
 
751
770
  res_path = os.path.join(result_dir(), f"{job_id}.json")
752
- deadline = created + ns.timeout
771
+ # Give up before ns.timeout, not at it: external wrappers (post_reddit's
772
+ # subprocess timeout, gtimeout in shell lanes) start their clocks at
773
+ # process start — before stdin read + enqueue — so an internal deadline of
774
+ # exactly created + timeout LOSES that race and the producer gets SIGKILLed
775
+ # before the cleanup below ever runs (2026-07-18 deathwatch "unexpected
776
+ # death" at enqueue+1800s, leaking pending files and a stuck "drafting"
777
+ # label). The 60s margin guarantees the graceful path wins.
778
+ deadline = created + max(60, ns.timeout - 60)
753
779
  last_hb = created # last menu-bar heartbeat (see below)
754
780
  claimed_at = None # set the moment a worker moves the job pending/ -> running/
755
781
  while time.time() < deadline:
756
782
  now = time.time()
783
+ # Pause landed mid-wait: abandon immediately with the same cleanup the
784
+ # timeout path does, instead of blocking the full wait window against a
785
+ # queue no worker will drain while paused.
786
+ if _is_paused():
787
+ _plog(f"job {job_id} abandoned: S4L paused mid-wait; cleaning up")
788
+ for p in (pending_path, running_path):
789
+ try:
790
+ os.remove(p)
791
+ except OSError:
792
+ pass
793
+ _act_clear()
794
+ _disarm_deathwatch(job_id)
795
+ return 1
757
796
  # A worker claims a job by atomically renaming pending/ -> running/, so the
758
797
  # pending file vanishing is our signal that drafting actually STARTED (vs.
759
798
  # the job still sitting unclaimed). Latch the claim time once so the label
@@ -972,12 +1011,26 @@ def _attempt_claim(ns, qtype: str) -> bool:
972
1011
  return False
973
1012
 
974
1013
 
1014
+ def _is_paused() -> bool:
1015
+ # S4L paused (mcp/src/index.ts pauseS4L / the menubar pause button): the
1016
+ # one flag file every pipeline surface honors.
1017
+ return os.path.exists(os.path.join(state_dir(), "paused.flag"))
1018
+
1019
+
975
1020
  def cmd_next(ns) -> int:
976
1021
  _apply_state_dir_override(ns)
977
- # S4L paused (mcp/src/index.ts pauseAutopilot): report no work instead of
978
- # claiming a job, so an already-queued draft can't drain/post while paused
979
- # even though the scheduled task worker still fires on its own cadence.
980
- if os.path.exists(os.path.join(state_dir(), "paused.flag")):
1022
+ # GC jobs orphaned by dead/killed producers BEFORE claiming (and before the
1023
+ # pause check, so orphans left behind by a pause don't sit until the next
1024
+ # enqueue-side sweep — while paused nothing enqueues, so this is the only
1025
+ # sweeper running). Without this, a resume would hand workers stale jobs
1026
+ # whose producer died long ago.
1027
+ swept = _sweep_stale()
1028
+ if swept:
1029
+ _plog(f"swept {swept} stale queue job file(s) before claim pass")
1030
+ # S4L paused: report no work instead of claiming a job, so an
1031
+ # already-queued draft can't drain/post while paused even though the
1032
+ # scheduled task worker still fires on its own cadence.
1033
+ if _is_paused():
981
1034
  print(json.dumps({}))
982
1035
  return 0
983
1036
  qtype = ns.type
@@ -997,6 +1050,13 @@ def cmd_next(ns) -> int:
997
1050
  start = time.time()
998
1051
  attempt = 0
999
1052
  while True:
1053
+ # Re-check pause on every iteration, not just at entry: a worker
1054
+ # already inside its wait window when Pause is clicked must stop
1055
+ # claiming immediately (observed 2026-07-18: pause at 12:24, a
1056
+ # pre-pause poller claimed a reddit-draft job at 12:29 anyway).
1057
+ if _is_paused():
1058
+ _plog(f"polling {qtype}: paused.flag appeared mid-wait; exiting without claiming")
1059
+ break
1000
1060
  if _attempt_claim(ns, qtype):
1001
1061
  return 0
1002
1062
  attempt += 1
@@ -1,7 +1,7 @@
1
1
  #!/bin/bash
2
2
  # preflight.sh — sourced helper for launchd-fired run-*.sh wrappers.
3
3
  #
4
- # Three checks, each emits a `[skipped: <reason>]` stderr line and exits 0
4
+ # Four checks, each emits a `[skipped: <reason>]` stderr line and exits 0
5
5
  # (so launchd treats the slot as cleanly consumed and fires the next one
6
6
  # on schedule, rather than thinking the job is broken):
7
7
  #
@@ -28,7 +28,16 @@
28
28
  # still recovering automatically within 10 min of the underlying
29
29
  # cap being lifted.
30
30
  #
31
- # 3. preflight_acquire_slot_or_skip <pool_name> [max_slots=4]
31
+ # 3. preflight_skip_if_paused
32
+ # Reads <state>/paused.flag (S4L Pause: written by mcp/src/index.ts
33
+ # pauseS4L and the menubar's pause button). Pause boots the pipeline
34
+ # launchd jobs out, but that only covers jobs loaded at pause time —
35
+ # this guard is the shared runtime backstop every pipeline wrapper
36
+ # calls, so a job that was mid-flight, re-loaded by a Claude boot,
37
+ # or added later can never draft/post while paused. One guard, all
38
+ # pipelines: add a new run-*.sh wrapper and call this, done.
39
+ #
40
+ # 4. preflight_acquire_slot_or_skip <pool_name> [max_slots=4]
32
41
  # Slot-pool admission control via mkdir on
33
42
  # /tmp/sa-${pool_name}-slot-{1..max_slots}.lock. If all slots are
34
43
  # held by live PIDs, skips. Stale slots (PID dead) are GC'd before
@@ -146,7 +155,18 @@ PY
146
155
  }
147
156
 
148
157
  # ---------------------------------------------------------------------------
149
- # 3. Slot-pool admission (parallel-cycle cap)
158
+ # 3. S4L Pause preflight
159
+ # ---------------------------------------------------------------------------
160
+ preflight_skip_if_paused() {
161
+ local flag="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/paused.flag"
162
+ [ -e "$flag" ] || return 0
163
+ local script_tag="${SA_PREFLIGHT_SCRIPT:-${SCRIPT_TAG:-$(basename "$0")}}"
164
+ echo "[skipped: paused flag=$flag script=$script_tag] $(date)" >&2
165
+ exit 0
166
+ }
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # 4. Slot-pool admission (parallel-cycle cap)
150
170
  # ---------------------------------------------------------------------------
151
171
  preflight_acquire_slot_or_skip() {
152
172
  local pool_name="$1"
@@ -28,6 +28,13 @@ EOF_ENV
28
28
  REPO_DIR="${S4L_REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
29
29
  PY="${S4L_PYTHON:-python3}"
30
30
 
31
+ # Shared pause guard (scripts/preflight.sh): every pipeline kicker honors the
32
+ # same paused.flag, so Pause stops the X lane the same way it stops Reddit —
33
+ # even if launchd re-fires this wrapper while the flag is up.
34
+ SA_PREFLIGHT_SCRIPT="run-draft-and-publish"
35
+ source "$REPO_DIR/scripts/preflight.sh"
36
+ preflight_skip_if_paused
37
+
31
38
  # Salvage twitter-prep drafts orphaned when a producer cycle died after its worker
32
39
  # wrote the result but before consuming it (a consumed result is deleted, so a
33
40
  # surviving one in claude-queue/result/ = never consumed). Best-effort, dedup-safe
@@ -40,6 +40,7 @@ ERR="$LOG_DIR/launchd-reddit-search-stderr.log"
40
40
  # See scripts/preflight.sh for full design.
41
41
  SA_PREFLIGHT_SCRIPT="run-reddit-search"
42
42
  source "$REPO_DIR/scripts/preflight.sh"
43
+ preflight_skip_if_paused
43
44
  preflight_skip_if_claude_blocked
44
45
  preflight_skip_if_jetsam_pressure
45
46