social-autoposter 1.6.152 → 1.6.154

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
@@ -2713,96 +2713,43 @@ async function scheduleState() {
2713
2713
  // version). Resilient: any probe that throws degrades to a safe default rather
2714
2714
  // than failing the whole snapshot.
2715
2715
  async function buildSnapshot() {
2716
- const projects = listManagedProjectStatus().map((p) => ({
2717
- name: p.name,
2718
- ready: p.ready,
2719
- missing_required: p.missing_required,
2720
- }));
2721
- const rtReady = runtimeReady();
2722
- const [x, ap, ver] = await Promise.all([
2723
- rtReady
2724
- ? xStatus().catch(() => ({ connected: false, state: "" }))
2725
- : Promise.resolve({ connected: false, state: "runtime_not_ready" }),
2726
- autopilotLoaded(),
2727
- versionStatus().catch(() => ({ installed: VERSION, latest: null, update_available: false })),
2728
- ]);
2729
- await ensureDoctorPhase(x.connected ? "full" : "pre_connect");
2730
- if (rtReady)
2731
- completeOnboardingMilestone("runtime_ready");
2732
- if (x.connected) {
2733
- completeOnboardingMilestone("x_connected", { state: x.state || "connected" });
2716
+ // Single source of truth: scripts/snapshot.py computes the snapshot PURELY from
2717
+ // the stateful files (the SAME module the always-on menu bar imports directly,
2718
+ // so the two surfaces can't diverge — and the menu bar no longer depends on this
2719
+ // Node process being up). We shell out for the data, then layer on the MCP-only
2720
+ // side effects snapshot.py deliberately omits (it is a pure reader): the doctor
2721
+ // phase, onboarding-milestone telemetry, and persistence.
2722
+ let snap;
2723
+ try {
2724
+ const res = await runPython("scripts/snapshot.py", [], { timeoutMs: 95_000 });
2725
+ snap = JSON.parse(res.stdout.trim().split("\n").slice(-50).join("\n"));
2726
+ if (snap && snap._error)
2727
+ throw new Error(String(snap._error));
2734
2728
  }
2735
- if (projects.some((project) => project.ready)) {
2736
- completeOnboardingMilestone("project_ready", { missing_count: 0 });
2729
+ catch {
2730
+ // Never fail the whole panel: fall back to a minimal locally-derived snapshot.
2731
+ snap = {
2732
+ projects: [], projects_total: 0, projects_ready: 0,
2733
+ x_connected: false, x_state: "", x_handle: null,
2734
+ autopilot_on: false, autopilot_stalled: false, schedule_state: "missing",
2735
+ auto_update_on: false, version: VERSION, latest_version: null,
2736
+ update_available: false, runtime_ready: runtimeReady(),
2737
+ runtime_provisioning: isProvisioning(), setup_complete: false,
2738
+ mode: "promotion", onboarding: onboardingSnapshot(),
2739
+ };
2737
2740
  }
2738
- // The ONE authoritative "is this install set up" rule: runtime provisioned, at
2739
- // least one ready project, and X connected. Computed here so there is a single
2740
- // definition the menu bar must NOT re-derive its own (it used to, from the
2741
- // milestone-ledger count, which disagreed with this whenever a milestone was
2742
- // added late or never recorded, causing the 7/8-vs-"set up" flip-flop).
2743
- const setupComplete = rtReady && projects.some((p) => p.ready) && !!x.connected;
2744
- // Read the REAL schedule state at all times (during setup AND while running)
2745
- // no setup_complete fallback that lied "ok". Source of truth: schedule_state.py.
2746
- // The re-arm WARNING stays gated on setup_complete below (we don't nag the user
2747
- // mid-onboarding); only the value here is always honest.
2748
- const schedule_state = await scheduleState();
2749
- // The two draft-autopilot tasks are registered AND firing -> the terminal
2750
- // onboarding milestone is satisfied (this replaced the removed draft_verified,
2751
- // which depended on the deleted run_draft_cycle tool and could never complete).
2752
- if (schedule_state === "ok") {
2741
+ // MCP-only side effects (snapshot.py is a pure reader and does none of these):
2742
+ // the onboarding LEDGER writes here are telemetry/history; the live DISPLAY
2743
+ // statuses already come from snapshot.py's overlay.
2744
+ await ensureDoctorPhase(snap.x_connected ? "full" : "pre_connect");
2745
+ if (snap.runtime_ready)
2746
+ completeOnboardingMilestone("runtime_ready");
2747
+ if (snap.x_connected)
2748
+ completeOnboardingMilestone("x_connected", { state: snap.x_state || "connected" });
2749
+ if ((snap.projects_ready || 0) > 0)
2750
+ completeOnboardingMilestone("project_ready", { missing_count: 0 });
2751
+ if (snap.schedule_state === "ok")
2753
2752
  completeOnboardingMilestone("tasks_scheduled");
2754
- }
2755
- // ---- Live-truthful onboarding checklist -------------------------------------
2756
- // The ledger (onboarding-progress.json) is append-only HISTORY: a completed
2757
- // milestone never reverts, which is right for telemetry (attempts/blockers/
2758
- // funnel) but WRONG as a live "is it set up now?" checklist. So for the
2759
- // milestones that have a cheap live signal, overlay their CURRENT state for
2760
- // DISPLAY (the dashboard + menu bar render this); the ledger file itself stays
2761
- // untouched for telemetry. Milestones with no cheap live signal
2762
- // (environment_checked, profile_scanned, topics_seeded) keep their ledger value.
2763
- const liveMilestoneStatus = {
2764
- runtime_ready: rtReady ? "complete" : "pending",
2765
- x_connected: x.connected ? "complete" : "pending",
2766
- mode_chosen: modeChosen() ? "complete" : "pending",
2767
- project_ready: projects.some((p) => p.ready) ? "complete" : "pending",
2768
- tasks_scheduled: schedule_state === "ok" ? "complete" : "pending",
2769
- };
2770
- const ledgerSnapshot = onboardingSnapshot();
2771
- const liveMilestones = (ledgerSnapshot.milestones || []).map((m) => liveMilestoneStatus[m.id] ? { ...m, status: liveMilestoneStatus[m.id] } : m);
2772
- const onboardingLive = {
2773
- ...ledgerSnapshot,
2774
- milestones: liveMilestones,
2775
- complete: liveMilestones.every((m) => m.status === "complete"),
2776
- };
2777
- const snap = {
2778
- projects,
2779
- projects_total: projects.length,
2780
- projects_ready: projects.filter((p) => p.ready).length,
2781
- x_connected: !!x.connected,
2782
- x_state: x.state || "",
2783
- x_handle: x.handle ?? null,
2784
- autopilot_on: ap.autopilot_on,
2785
- // Liveness, not just presence: the routines can be registered (SKILL.md on
2786
- // disk -> autopilot_on true) yet not firing after a Claude account switch.
2787
- // autopilot_stalled is the true "drafts aren't being produced" signal.
2788
- autopilot_stalled: setupComplete && autopilotStalled(),
2789
- // The ACTUAL schedule state for the CURRENT account ('missing'/'disabled'/
2790
- // 'ok'). Always the real registry reading; drives the dashboard's "Set up
2791
- // draft schedule" button (gated on setup_complete in the consumer, not here).
2792
- schedule_state,
2793
- auto_update_on: ap.auto_update_on,
2794
- version: ver.installed || VERSION,
2795
- latest_version: ver.latest ?? null,
2796
- update_available: !!ver.update_available,
2797
- // Runtime install gate: the panel shows the Install card (and disables the
2798
- // action buttons) until the owned Python/Chromium runtime is provisioned.
2799
- runtime_ready: rtReady,
2800
- runtime_provisioning: isProvisioning(),
2801
- setup_complete: setupComplete,
2802
- // Engagement mode for display in BOTH surfaces (single source: mode.json).
2803
- mode: currentMode(),
2804
- onboarding: onboardingLive,
2805
- };
2806
2753
  // Persist this snapshot so the menu bar can answer "set up?" the SAME way when
2807
2754
  // the loopback server is unreachable (Claude Desktop closed or mid-restart)
2808
2755
  // instead of falling back to a divergent local rule. Refreshed on every
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.152",
3
- "installedAt": "2026-06-29T22:42:31.815Z"
2
+ "version": "1.6.154",
3
+ "installedAt": "2026-06-29T23:14:44.360Z"
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.6.152",
5
+ "version": "1.6.154",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "The disclaimer above is generic Claude boilerplate. S4L 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 end to end**\n\n2\\. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
8
8
  "author": {
@@ -143,6 +143,31 @@ AUTOPILOT_RUNNING_STALL_SECONDS = 900
143
143
  # truth, scripts/schedule_state.py (FIRING_WINDOW there). _schedule_state delegates
144
144
  # to it, so it is intentionally NOT redefined here.
145
145
 
146
+ # How long the producer can sit narrating "drafting replies (Nm)" before we treat
147
+ # the draft as STUCK rather than healthy. The producer writes that label the whole
148
+ # time it blocks waiting for a worker to return a result (up to its 30-min queue
149
+ # timeout). A healthy drain clears in ~1-2 min; if the label has been "drafting"
150
+ # this long, the worker keeps dying mid-run (host inactivity-kill) or never claims,
151
+ # and nothing is draining — so we flip the menu bar from a reassuring spinner to
152
+ # ⚠ instead of letting the stale "drafting (8m)" lie persist. Well above any
153
+ # healthy single drain (the worker itself dies at ~2 min today).
154
+ DRAFT_STUCK_SECONDS = 300
155
+
156
+
157
+ def _label_elapsed_secs(label):
158
+ """Parse the trailing duration the producer encodes in a drafting activity
159
+ label — 'drafting replies (8m)', '... (queued 18m)', '... (45s)' — into
160
+ seconds. Returns 0 when there's no parseable duration. _fmt_dur (claude_job.py)
161
+ only ever emits '<n>s' (<60s) or '<n>m', so this mirror stays trivial."""
162
+ if not label:
163
+ return 0
164
+ import re
165
+ matches = re.findall(r"(\d+)\s*([sm])\b", str(label))
166
+ if not matches:
167
+ return 0
168
+ n, unit = matches[-1]
169
+ return int(n) * (60 if unit == "m" else 1)
170
+
146
171
 
147
172
  def _glyph(status):
148
173
  return GLYPH.get(status, "·")
@@ -1013,6 +1038,22 @@ class S4LMenuBar(rumps.App):
1013
1038
  attention = True
1014
1039
  else:
1015
1040
  self._stall_reason_info = ("", "")
1041
+ # Draft worker stuck/killed: the producer narrates "drafting replies (Nm)"
1042
+ # the whole time it blocks waiting for a worker to return a result, with NO
1043
+ # idea the worker died. A healthy drain clears in ~1-2 min; once that label
1044
+ # has been "drafting" past DRAFT_STUCK_SECONDS the worker keeps getting
1045
+ # killed mid-run (or never claims) and nothing is draining — flip to ⚠
1046
+ # instead of leaving the reassuring "drafting (8m)" spinner up. Skip when a
1047
+ # more specific cause (rate limit) already owns the reason.
1048
+ if setup_complete and self._stall_reason_info[0] != "rate_limited":
1049
+ _act = st.read_activity()
1050
+ if (
1051
+ _act
1052
+ and _act.get("state") == "drafting"
1053
+ and _label_elapsed_secs(_act.get("label")) >= DRAFT_STUCK_SECONDS
1054
+ ):
1055
+ attention = True
1056
+ self._stall_reason_info = ("draft_stuck", _act.get("label") or "")
1016
1057
  # Drop the stale "drafting" spinner while we need attention so the ⚠ shows.
1017
1058
  self._stalled = attention
1018
1059
 
@@ -1350,6 +1391,14 @@ class S4LMenuBar(rumps.App):
1350
1391
  items.append(self._label(
1351
1392
  " " + (self._stall_reason_info[1] or "wait for reset or switch account")
1352
1393
  ))
1394
+ elif self._stall_reason_info[0] == "draft_stuck":
1395
+ # Routines fire and the producer keeps narrating "drafting" but the
1396
+ # worker keeps getting killed mid-run / never returns a result. Don't
1397
+ # offer Re-arm (routines are fine); state the real problem.
1398
+ items.append(self._label("⚠ Draft not completing — worker keeps getting killed"))
1399
+ items.append(self._label(
1400
+ " " + (self._stall_reason_info[1] or "drafting") + " — no result yet"
1401
+ ))
1353
1402
  elif schedule_state == "disabled":
1354
1403
  items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
1355
1404
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
@@ -20,6 +20,7 @@ Everything is best-effort: any failure degrades to "unknown / open Claude".
20
20
  import json
21
21
  import os
22
22
  import subprocess
23
+ import sys
23
24
  import threading
24
25
  import urllib.request
25
26
  from pathlib import Path
@@ -195,23 +196,31 @@ def loopback_tool(name: str, args=None, timeout: float = 20.0):
195
196
 
196
197
  # ---- the snapshot the menu bar renders ------------------------------------
197
198
  def snapshot():
198
- """Full live snapshot via the loopback dashboard tool, else the server's last
199
- persisted summary, else a thin file-built fallback.
199
+ """Full snapshot computed DIRECTLY from the stateful files via
200
+ scripts/snapshot.py the SAME single-source module the MCP shells out to, so
201
+ the two surfaces can't diverge. The menu bar no longer depends on the MCP /
202
+ Claude being up: there is NO loopback call here, so a restarting or closed
203
+ Claude can't freeze or stale the menu (the old tier-1 `loopback_tool` blocked
204
+ the UI thread up to 20s and was the freeze).
200
205
 
201
206
  Three tiers, in order:
202
- 1. LIVE — loopback reachable: the real dashboard snapshot, with `setup_complete`
203
- already computed by the server (the single authoritative rule).
204
- 2. SUMMARY — loopback down but the server wrote `status-summary.json` while it
205
- was last up: read that. It carries the SAME `setup_complete` the live path
206
- would, so a server restart can't flip the menu bar's "set up" answer. This
207
- is the consolidation that removes the menu bar's old divergent rule.
208
- 3. LEDGER no summary ever written (a truly fresh install that has never
209
- reached the server): derive the essentials from the onboarding ledger so
210
- onboarding progress still shows with nothing running."""
211
- snap = loopback_tool("dashboard", {})
212
- if isinstance(snap, dict) and "projects_total" in snap:
213
- snap["_live"] = True
214
- return snap
207
+ 1. LIVE — compute locally from the files (zero MCP dependency).
208
+ 2. SUMMARY the server's last persisted `status-summary.json`, if the local
209
+ compute somehow failed.
210
+ 3. LEDGER nothing else available: derive the essentials from the onboarding
211
+ ledger so progress still shows."""
212
+ try:
213
+ repo = os.environ.get("SAPS_REPO_DIR") or str(Path.home() / "social-autoposter")
214
+ scripts = os.path.join(repo, "scripts")
215
+ if scripts not in sys.path:
216
+ sys.path.insert(0, scripts)
217
+ import snapshot as _snapshot_mod # scripts/snapshot.py
218
+ snap = _snapshot_mod.compute()
219
+ if isinstance(snap, dict) and "projects_total" in snap:
220
+ snap["_live"] = True
221
+ return snap
222
+ except Exception:
223
+ pass
215
224
  summ = read_json("status-summary.json")
216
225
  if isinstance(summ, dict) and "projects_total" in summ:
217
226
  summ["_live"] = False
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.152",
3
+ "version": "1.6.154",
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": "social-autoposter",
3
- "version": "1.6.152",
3
+ "version": "1.6.154",
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"
@@ -677,6 +677,41 @@ def cmd_provider(ns) -> int:
677
677
  # --------------------------------------------------------------------------- #
678
678
  # next (consumer side, run by a scheduled task) #
679
679
  # --------------------------------------------------------------------------- #
680
+ def _agent_session_pid():
681
+ """Best-effort: the Claude agent-mode SESSION pid running THIS worker — the
682
+ exact process the stale-session reaper (reap_stale_claude_sessions.py) would
683
+ target. We climb our own process tree to the ancestor whose cmd carries the
684
+ reaper's worker signature ('claude-code/' + 'local-agent-mode-sessions') and
685
+ return its pid, so the claim can be stamped with it and the reaper can SPARE
686
+ that session for the whole drafting turn (instead of SIGTERMing it at the short
687
+ grace window — the 2026-06-29 draft-kill regression). None if not identifiable;
688
+ the reaper then falls back to its newest-spare heuristic.
689
+ """
690
+ try:
691
+ out = subprocess.run(
692
+ ["/bin/ps", "-axo", "pid=,ppid=,command="],
693
+ capture_output=True, text=True, timeout=10,
694
+ ).stdout
695
+ info = {}
696
+ for line in out.splitlines():
697
+ m = re.match(r"\s*(\d+)\s+(\d+)\s+(.*)$", line)
698
+ if m:
699
+ info[int(m.group(1))] = (int(m.group(2)), m.group(3))
700
+ pid = os.getpid()
701
+ for _ in range(16): # bounded climb up the tree
702
+ ent = info.get(pid)
703
+ if not ent or ent[0] <= 1:
704
+ break
705
+ ppid = ent[0]
706
+ pcmd = info.get(ppid, (0, ""))[1]
707
+ if ("claude-code/" in pcmd) and ("local-agent-mode-sessions" in pcmd):
708
+ return ppid
709
+ pid = ppid
710
+ except Exception:
711
+ return None
712
+ return None
713
+
714
+
680
715
  def cmd_next(ns) -> int:
681
716
  _apply_state_dir_override(ns)
682
717
  qtype = ns.type
@@ -700,6 +735,12 @@ def cmd_next(ns) -> int:
700
735
  job = json.load(f)
701
736
  except Exception:
702
737
  continue
738
+ # Stamp the agent-session pid that holds THIS claim so the reaper spares it
739
+ # for the whole drafting turn (see _agent_session_pid above).
740
+ agent_pid = _agent_session_pid()
741
+ if agent_pid:
742
+ job["claim_pid"] = agent_pid
743
+ job["claimed_at"] = time.time()
703
744
  prompt_file = None
704
745
  schema_file = None
705
746
  if ns.prompt_file:
@@ -711,7 +752,16 @@ def cmd_next(ns) -> int:
711
752
  schema_file = os.path.join(queue_root(), f"schema-{job['job_id']}.json")
712
753
  _atomic_write_text(schema_file, schema)
713
754
  job["schema_file"] = schema_file
714
- _atomic_write(dst, job)
755
+ # ALWAYS persist the claim back (claim_pid + any prompt/schema sidecars) so
756
+ # the reaper can read claim_pid; previously this only happened on the
757
+ # --prompt-file lane, leaving claim_pid unstamped for inline callers.
758
+ _atomic_write(dst, job)
759
+ _plog(
760
+ f"claimed {job.get('type') or qtype} job {job['job_id']}; "
761
+ + (f"agent-session pid={agent_pid} stamped (reaper will spare it)"
762
+ if agent_pid else
763
+ "agent-session pid NOT found (reaper falls back to newest-spare)")
764
+ )
715
765
  # Narrate the scheduled-task worker's drafting turn to the menu bar. This
716
766
  # is the lane that actually runs the LLM; it persists until cmd_result
717
767
  # clears it (or the kicker's exit trap does). Covers the box's autopilot.
@@ -40,6 +40,7 @@ even before the owned runtime is provisioned.
40
40
 
41
41
  from __future__ import annotations
42
42
 
43
+ import json
43
44
  import os
44
45
  import re
45
46
  import signal
@@ -207,6 +208,36 @@ def count_running_jobs():
207
208
  return None
208
209
 
209
210
 
211
+ def running_claim_pids():
212
+ """Set of agent-session pids that currently hold a LIVE claim. The worker stamps
213
+ its agent-session pid into <state_dir>/claude-queue/running/<job>.json the instant
214
+ it claims a job (claude_job.py::cmd_next). A session that holds a claim is, by
215
+ definition, the one doing real drafting work right now — so we spare those pids
216
+ UNCONDITIONALLY (regardless of age / group size) and only reap sessions that do
217
+ NOT hold a claim. This is what makes a multi-minute draft survive: it is no longer
218
+ confused with a leaked/done zombie just because newer empty sessions spawned on
219
+ top of it. Empty set if the dir is unreadable or nothing has been stamped (then
220
+ the caller falls back to the newest-spare heuristic, i.e. prior behaviour)."""
221
+ d = os.path.join(_state_dir(), "claude-queue", "running")
222
+ pids: set[int] = set()
223
+ try:
224
+ names = os.listdir(d)
225
+ except OSError:
226
+ return pids
227
+ for n in names:
228
+ if not n.endswith(".json") or n.endswith(".tmp"):
229
+ continue
230
+ try:
231
+ with open(os.path.join(d, n)) as f:
232
+ job = json.load(f)
233
+ pid = job.get("claim_pid")
234
+ if isinstance(pid, int) and pid > 1:
235
+ pids.add(pid)
236
+ except Exception:
237
+ continue
238
+ return pids
239
+
240
+
210
241
  def _env_int(name: str, default: int) -> int:
211
242
  try:
212
243
  return int(os.environ.get(name, default))
@@ -218,7 +249,14 @@ def main() -> int:
218
249
  dry = "--dry-run" in sys.argv
219
250
  max_age = _env_int("SAPS_REAPER_MAX_AGE_SEC", DEFAULT_MAX_AGE_SEC)
220
251
  # (1) Queue-correlated reaping knobs.
221
- grace = _env_int("SAPS_REAPER_GRACE_SEC", 90) # spawn->claim race guard
252
+ # grace: how long an UNCLAIMED session may live before it's reapable. Raised
253
+ # 90 -> 300 on 2026-06-29: at 90s the reaper was SIGTERMing actively-DRAFTING
254
+ # worker sessions (a draft legitimately runs minutes), which presented as the
255
+ # mysterious "~120s code-143 kill". Claim-holders are now spared outright via
256
+ # running_claim_pids() regardless of grace, so this only governs idle/leaked
257
+ # sessions; 5 min is a comfortable backstop for them and the count-cap still
258
+ # bounds memory if the queue signal is ever wrong.
259
+ grace = _env_int("SAPS_REAPER_GRACE_SEC", 300) # idle/leaked-session backstop
222
260
  keep_margin = _env_int("SAPS_REAPER_KEEP_MARGIN", 1) # extra newest spared beyond busy set
223
261
  # (2) Count-cap backstop: never let one uuid group hold more than this many live
224
262
  # workers, regardless of queue state. 0 disables. The default rarely fires once
@@ -227,6 +265,7 @@ def main() -> int:
227
265
  max_group = _env_int("SAPS_REAPER_MAX_GROUP", 12)
228
266
 
229
267
  inflight = count_running_jobs() # None => queue unreadable => age-gate fallback
268
+ claim_pids = running_claim_pids() # agent-session pids actively holding a claim
230
269
 
231
270
  procs, by_pid = snapshot()
232
271
 
@@ -242,31 +281,53 @@ def main() -> int:
242
281
  members.sort(key=lambda p: p["age"]) # ascending: newest first
243
282
 
244
283
  if inflight is not None:
245
- # (1) Spare the (inflight + margin) newest these cover every worker that
246
- # could still hold a live claim and reap the rest once past a short grace.
284
+ # (1) Spare any session that HOLDS a live claim (it's actively drafting),
285
+ # plus the (inflight + margin) newest as a fallback for sessions that
286
+ # claimed before this build stamped claim_pid. Reap the rest past grace.
247
287
  spare_n = max(1, inflight + keep_margin)
248
288
  for p in members[spare_n:]:
289
+ if p["pid"] in claim_pids:
290
+ continue # actively holds a claim — never reap, no matter its age
249
291
  if p["age"] >= grace:
250
292
  targets_by_pid[p["pid"]] = p
251
293
  else:
252
294
  # Fallback: queue unreadable -> legacy age gate, keep only the newest.
253
295
  for p in members[1:]:
296
+ if p["pid"] in claim_pids:
297
+ continue
254
298
  if p["age"] >= max_age:
255
299
  targets_by_pid[p["pid"]] = p
256
300
 
257
- # (2) Count-cap backstop. Never caps BELOW the queue-spared busy set, so it
258
- # can only ever add provably-idle workers — no false positives.
301
+ # (2) Count-cap backstop. Never caps BELOW the queue-spared busy set, and
302
+ # never reaps a live claim-holder, so it can only ever add provably-idle
303
+ # workers — no false positives.
259
304
  if max_group > 0:
260
305
  keep = max_group
261
306
  if inflight is not None:
262
307
  keep = max(keep, inflight + keep_margin)
263
308
  for p in members[keep:]:
309
+ if p["pid"] in claim_pids:
310
+ continue
264
311
  targets_by_pid[p["pid"]] = p
265
312
 
266
313
  targets = list(targets_by_pid.values())[:MAX_KILL_PER_RUN]
267
314
 
315
+ # Visibility (per the 2026-06-29 draft-kill investigation): whenever a draft is
316
+ # in flight, log that we SAW the claim-holder(s) and are sparing them, so a
317
+ # future "why did the draft die" check can confirm the reaper protected the
318
+ # right session — or catch it red-handed if this logic ever regresses.
319
+ if claim_pids:
320
+ live = sorted(p for p in claim_pids if p in by_pid)
321
+ dead = sorted(p for p in claim_pids if p not in by_pid)
322
+ print(
323
+ f"[claude-reaper] sparing {len(live)} live claim-holder session(s)"
324
+ f" pids={live}" + (f" (stale-claim pids={dead})" if dead else "")
325
+ + f"; inflight={inflight} grace={grace}s",
326
+ file=sys.stderr,
327
+ )
328
+
268
329
  if not targets:
269
- # Stay quiet on the common no-leak path to keep the log stream clean.
330
+ # No reapable sessions this run (common no-leak path).
270
331
  return 0
271
332
 
272
333
  freed_kb = 0
@@ -289,7 +350,8 @@ def main() -> int:
289
350
  f"{prefix} reaped {killed} stale agent-mode claude session(s)"
290
351
  f" + {disclaimers} disclaimer stub(s) across {sum(1 for g in groups.values() if len(g) > 1)}"
291
352
  f" leaked uuid group(s); mode={mode} inflight={inflight} grace={grace}s"
292
- f" max_group={max_group} (age_fallback={max_age}s)",
353
+ f" spared_claim_pids={sorted(claim_pids)} max_group={max_group}"
354
+ f" (age_fallback={max_age}s)",
293
355
  file=sys.stderr,
294
356
  )
295
357
  return 0
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env python3
2
+ """Single source of truth for the S4L status snapshot.
3
+
4
+ Produces the SAME dict as the MCP's buildSnapshot() (mcp/src/index.ts), but in
5
+ Python, reading directly from the stateful files plus two existing Python helpers
6
+ (setup_twitter_auth.py for X, schedule_state.py for the draft schedule) and npm
7
+ for the latest version.
8
+
9
+ WHY this exists: the menu bar must render with Claude / the MCP fully closed. The
10
+ MCP is a Node process tied to Claude Desktop's lifecycle, and it was the ONLY
11
+ thing computing the snapshot — so the always-on menu bar had to ask it over a
12
+ blocking loopback call, which froze the menu whenever the MCP was restarting.
13
+ Moving the compute here lets the menu bar build the snapshot itself from the
14
+ files (zero MCP dependency), while the MCP shells out to this SAME module so
15
+ there's one implementation, no divergence — the schedule_state.py pattern applied
16
+ to the whole snapshot. The source of truth is the FILES; this is just the reader.
17
+
18
+ PURE READ/COMPUTE: never writes (no onboarding-milestone telemetry, no
19
+ persistence) — the MCP keeps those side effects around this. Slow fields (X
20
+ session, npm latest) are cached per-process with a TTL so a 5s menu-bar tick that
21
+ imports this module stays cheap.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import subprocess
28
+ import sys
29
+ import time
30
+
31
+ HOME = os.path.expanduser("~")
32
+
33
+
34
+ def _state_dir() -> str:
35
+ return os.environ.get("SAPS_STATE_DIR") or os.path.join(HOME, ".social-autoposter-mcp")
36
+
37
+
38
+ def _repo_dir() -> str:
39
+ return os.environ.get("SAPS_REPO_DIR") or os.path.join(HOME, "social-autoposter")
40
+
41
+
42
+ def _claude_cfg_dir() -> str:
43
+ return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(HOME, ".claude")
44
+
45
+
46
+ def _config_path() -> str:
47
+ return os.environ.get("SAPS_CONFIG_PATH") or os.path.join(_repo_dir(), "config.json")
48
+
49
+
50
+ # Keep in sync with REQUIRED_FIELDS (mcp/src/setup.ts), QUEUE_WORKERS / UPDATER_LABEL
51
+ # / AUTOPILOT_STALL_MS (mcp/src/index.ts).
52
+ REQUIRED_FIELDS = ["name", "website", "description", "icp", "voice", "search_topics"]
53
+ WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
54
+ UPDATER_LABEL = "com.m13v.social-autoposter-update"
55
+ AUTOPILOT_STALL_MS = 180_000
56
+
57
+ # Milestones overlaid with LIVE state for display (the rest keep their ledger
58
+ # value). Mirrors the overlay in buildSnapshot().
59
+ _OVERLAY_IDS = ("runtime_ready", "x_connected", "mode_chosen", "project_ready", "tasks_scheduled")
60
+
61
+
62
+ def _read_json(path: str):
63
+ try:
64
+ with open(path) as f:
65
+ return json.load(f)
66
+ except Exception:
67
+ return None
68
+
69
+
70
+ # ---- projects (config.json + setup-state.json + REQUIRED_FIELDS) -----------
71
+ def _managed_projects():
72
+ st = _read_json(os.path.join(_state_dir(), "setup-state.json")) or {}
73
+ return st.get("projects") or []
74
+
75
+
76
+ def _project_status(name, cfg_projects):
77
+ proj = next((p for p in cfg_projects if p.get("name") == name), None)
78
+ if proj is None:
79
+ return {"name": name, "ready": False, "missing_required": list(REQUIRED_FIELDS)}
80
+ missing = []
81
+ for f in REQUIRED_FIELDS:
82
+ v = proj.get(f)
83
+ if v is None:
84
+ missing.append(f)
85
+ elif isinstance(v, str) and not v.strip():
86
+ missing.append(f)
87
+ elif isinstance(v, (list, tuple)) and len(v) == 0:
88
+ missing.append(f)
89
+ elif isinstance(v, dict) and len(v) == 0:
90
+ missing.append(f)
91
+ return {"name": name, "ready": len(missing) == 0, "missing_required": missing}
92
+
93
+
94
+ def _projects():
95
+ cfg = _read_json(_config_path()) or {}
96
+ cfg_projects = cfg.get("projects") or []
97
+ return [_project_status(n, cfg_projects) for n in _managed_projects()]
98
+
99
+
100
+ # ---- runtime / mode / autopilot (all file/launchctl) -----------------------
101
+ def _runtime_ready() -> bool:
102
+ rt = _read_json(os.path.join(_state_dir(), "runtime.json")) or {}
103
+ py = rt.get("python")
104
+ return bool(rt.get("ready") and py and os.path.exists(py))
105
+
106
+
107
+ def _runtime_provisioning() -> bool:
108
+ p = _read_json(os.path.join(_state_dir(), "install-progress.json")) or {}
109
+ return str(p.get("status") or "").lower() in ("installing", "in_progress", "running", "provisioning")
110
+
111
+
112
+ def _mode() -> str:
113
+ m = ((_read_json(os.path.join(_state_dir(), "mode.json")) or {}).get("mode") or "").strip()
114
+ return "personal_brand" if m == "personal_brand" else "promotion"
115
+
116
+
117
+ def _mode_chosen() -> bool:
118
+ return os.path.exists(os.path.join(_state_dir(), "mode.json"))
119
+
120
+
121
+ def _autopilot_on() -> bool:
122
+ base = os.path.join(_claude_cfg_dir(), "scheduled-tasks")
123
+ try:
124
+ return all(os.path.exists(os.path.join(base, t, "SKILL.md")) for t in WORKER_TASK_IDS)
125
+ except Exception:
126
+ return False
127
+
128
+
129
+ def _auto_update_on() -> bool:
130
+ try:
131
+ out = subprocess.run(["launchctl", "list"], capture_output=True, text=True, timeout=10).stdout
132
+ return any(UPDATER_LABEL in line for line in out.splitlines())
133
+ except Exception:
134
+ return False
135
+
136
+
137
+ def _autopilot_stalled() -> bool:
138
+ qdir = os.path.join(_state_dir(), "claude-queue")
139
+ ds = _read_json(os.path.join(qdir, "drain-status.json")) or {}
140
+ try:
141
+ if int(ds.get("consecutive_timeouts") or 0) >= 1:
142
+ return True
143
+ except Exception:
144
+ pass
145
+ oldest = None
146
+ try:
147
+ pend = os.path.join(qdir, "pending")
148
+ for sub in os.listdir(pend):
149
+ subp = os.path.join(pend, sub)
150
+ if not os.path.isdir(subp):
151
+ continue
152
+ for f in os.listdir(subp):
153
+ if not f.endswith(".json") or f.endswith(".tmp"):
154
+ continue
155
+ try:
156
+ m = os.stat(os.path.join(subp, f)).st_mtime * 1000.0
157
+ if oldest is None or m < oldest:
158
+ oldest = m
159
+ except Exception:
160
+ pass
161
+ except Exception:
162
+ pass
163
+ return oldest is not None and (time.time() * 1000.0 - oldest) > AUTOPILOT_STALL_MS
164
+
165
+
166
+ # ---- schedule_state (reuse the shared module) ------------------------------
167
+ def _schedule_state() -> str:
168
+ try:
169
+ scripts = os.path.join(_repo_dir(), "scripts")
170
+ if scripts not in sys.path:
171
+ sys.path.insert(0, scripts)
172
+ import schedule_state # noqa: E402
173
+ return schedule_state.compute()
174
+ except Exception:
175
+ return "missing"
176
+
177
+
178
+ # ---- X status (setup_twitter_auth.py status), cached -----------------------
179
+ _x_cache = {"at": 0.0, "val": None}
180
+ _X_TTL = 60.0
181
+
182
+
183
+ def _x_status():
184
+ now = time.time()
185
+ if _x_cache["val"] is not None and now - _x_cache["at"] < _X_TTL:
186
+ return _x_cache["val"]
187
+ val = {"connected": False, "state": "", "handle": None}
188
+ if _runtime_ready():
189
+ try:
190
+ py = os.environ.get("SAPS_PYTHON") or sys.executable or "python3"
191
+ res = subprocess.run(
192
+ [py, os.path.join(_repo_dir(), "scripts", "setup_twitter_auth.py"), "status"],
193
+ capture_output=True, text=True, timeout=90,
194
+ )
195
+ # Mirror twitterAuth.ts::parse — JSON in the last lines of stdout.
196
+ parsed = json.loads("\n".join(res.stdout.strip().splitlines()[-50:]))
197
+ val = {
198
+ "connected": bool(parsed.get("connected")),
199
+ "state": parsed.get("state") or "",
200
+ "handle": parsed.get("handle"),
201
+ }
202
+ except Exception:
203
+ val = {"connected": False, "state": "status_unavailable", "handle": None}
204
+ else:
205
+ val = {"connected": False, "state": "runtime_not_ready", "handle": None}
206
+ _x_cache.update(at=now, val=val)
207
+ return val
208
+
209
+
210
+ # ---- version (resolveVersion + npm latest + semver), cached ----------------
211
+ _ver_cache = {"at": 0.0, "latest": None}
212
+ _VER_TTL = 600.0
213
+
214
+
215
+ def _resolve_version() -> str:
216
+ for p in (
217
+ os.path.join(_repo_dir(), "mcp", "dist", "version.json"),
218
+ os.path.join(_repo_dir(), "package.json"),
219
+ os.path.join(_repo_dir(), "mcp", "package.json"),
220
+ ):
221
+ v = (_read_json(p) or {}).get("version")
222
+ if isinstance(v, str) and v:
223
+ return v
224
+ return "0.0.0-unknown"
225
+
226
+
227
+ def _latest_published():
228
+ now = time.time()
229
+ if _ver_cache["latest"] is not None and now - _ver_cache["at"] < _VER_TTL:
230
+ return _ver_cache["latest"]
231
+ latest = None
232
+ try:
233
+ res = subprocess.run(["npm", "view", "social-autoposter", "version"],
234
+ capture_output=True, text=True, timeout=8)
235
+ line = (res.stdout.strip().splitlines() or [""])[-1].strip()
236
+ if line and line[0].isdigit():
237
+ latest = line
238
+ except Exception:
239
+ latest = None
240
+ _ver_cache.update(at=now, latest=latest)
241
+ return latest
242
+
243
+
244
+ def _is_newer(latest, current) -> bool:
245
+ def norm(v):
246
+ return [int(x) if x.isdigit() else 0 for x in str(v).split("-")[0].split("+")[0].split(".")]
247
+ a, b = norm(latest), norm(current)
248
+ for i in range(max(len(a), len(b))):
249
+ x = a[i] if i < len(a) else 0
250
+ y = b[i] if i < len(b) else 0
251
+ if x != y:
252
+ return x > y
253
+ return False
254
+
255
+
256
+ # ---- onboarding ledger + live overlay --------------------------------------
257
+ def _onboarding_live(live_status):
258
+ led = _read_json(os.path.join(_state_dir(), "onboarding-progress.json")) or {}
259
+ ms = led.get("milestones")
260
+ # The ledger stores milestones as a dict id->record; the snapshot exposes a
261
+ # list. Mirror onboarding-ledger.cjs publicSnapshot() ordering via MILESTONES.
262
+ order = ["environment_checked", "runtime_ready", "x_connected", "profile_scanned",
263
+ "mode_chosen", "project_ready", "topics_seeded", "tasks_scheduled"]
264
+ out = []
265
+ if isinstance(ms, dict):
266
+ for mid in order:
267
+ rec = dict(ms.get(mid) or {"status": "pending", "attempts": 0})
268
+ rec["id"] = mid
269
+ if mid in live_status:
270
+ rec["status"] = live_status[mid]
271
+ out.append(rec)
272
+ elif isinstance(ms, list):
273
+ for rec in ms:
274
+ rec = dict(rec)
275
+ if rec.get("id") in live_status:
276
+ rec["status"] = live_status[rec["id"]]
277
+ out.append(rec)
278
+ result = dict(led)
279
+ result["milestones"] = out
280
+ result["complete"] = bool(out) and all(m.get("status") == "complete" for m in out)
281
+ return result
282
+
283
+
284
+ def compute() -> dict:
285
+ """Build the full snapshot dict (same shape as buildSnapshot())."""
286
+ projects = _projects()
287
+ rt_ready = _runtime_ready()
288
+ x = _x_status()
289
+ mode = _mode()
290
+ schedule_state = _schedule_state()
291
+ any_ready = any(p["ready"] for p in projects)
292
+ setup_complete = rt_ready and any_ready and bool(x["connected"])
293
+
294
+ installed = _resolve_version()
295
+ latest = _latest_published()
296
+ update_available = bool(latest) and _is_newer(latest, installed)
297
+
298
+ live_status = {
299
+ "runtime_ready": "complete" if rt_ready else "pending",
300
+ "x_connected": "complete" if x["connected"] else "pending",
301
+ "mode_chosen": "complete" if _mode_chosen() else "pending",
302
+ "project_ready": "complete" if any_ready else "pending",
303
+ "tasks_scheduled": "complete" if schedule_state == "ok" else "pending",
304
+ }
305
+
306
+ return {
307
+ "projects": projects,
308
+ "projects_total": len(projects),
309
+ "projects_ready": sum(1 for p in projects if p["ready"]),
310
+ "x_connected": bool(x["connected"]),
311
+ "x_state": x["state"] or "",
312
+ "x_handle": x["handle"],
313
+ "autopilot_on": _autopilot_on(),
314
+ "autopilot_stalled": setup_complete and _autopilot_stalled(),
315
+ "schedule_state": schedule_state,
316
+ "auto_update_on": _auto_update_on(),
317
+ "version": installed,
318
+ "latest_version": latest,
319
+ "update_available": update_available,
320
+ "runtime_ready": rt_ready,
321
+ "runtime_provisioning": _runtime_provisioning(),
322
+ "setup_complete": setup_complete,
323
+ "mode": mode,
324
+ "onboarding": _onboarding_live(live_status),
325
+ }
326
+
327
+
328
+ def main() -> int:
329
+ try:
330
+ print(json.dumps(compute()))
331
+ except Exception as e:
332
+ print(json.dumps({"_error": str(e)}))
333
+ return 1
334
+ return 0
335
+
336
+
337
+ if __name__ == "__main__":
338
+ sys.exit(main())