social-autoposter 1.6.176 → 1.6.178

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
@@ -1184,6 +1184,17 @@ tool("engagement_mode", {
1184
1184
  personaTopicsSeeded = true;
1185
1185
  }
1186
1186
  completeOnboardingMilestone("mode_chosen", { personal_brand: personalBrand, promotion, persona: personaName });
1187
+ // Personal-brand-only is a first-class setup path: the persona is the draftable
1188
+ // project, so seeding its topics IS the topics_seeded milestone. Without this the
1189
+ // product path (project_config) is the only place that completes it, leaving a
1190
+ // persona-only checklist stuck at "topics pending" even though topics are live.
1191
+ if (personalBrand && personaTopicsSeeded) {
1192
+ completeOnboardingMilestone("topics_seeded", {
1193
+ project: personaName,
1194
+ topic_count: personaTopicCount,
1195
+ persona: true,
1196
+ });
1197
+ }
1187
1198
  // Install/refresh the launchd kicker NOW. For a personal-brand-only setup the
1188
1199
  // persona is the only draftable project (no managed product), so nothing else
1189
1200
  // would trigger the install until a later queue-worker boot — leaving the user
package/mcp/dist/setup.js CHANGED
@@ -36,6 +36,20 @@ export const REQUIRED_FIELDS = [
36
36
  // project is NOT ready until it has at least one topic to seed. (2026-06-02)
37
37
  "search_topics",
38
38
  ];
39
+ // Required fields for a personal-brand PERSONA project (persona:true). A persona
40
+ // is a person's voice, NOT a product: it has no `website` and no target `icp` by
41
+ // design. Validating a persona against the product REQUIRED_FIELDS therefore ALWAYS
42
+ // reports it "missing website + icp" -> personaReady() could never be true, which
43
+ // silently defeated the persona-aware kicker gate (a personal-brand-only setup then
44
+ // never scheduled the autopilot and drafted nothing). So a persona is "ready" once
45
+ // it has the fields the cycle actually consumes: a name, a voice, and at least one
46
+ // seedable search topic. (2026-06-30, completing the 1.6.173 persona path)
47
+ export const PERSONA_REQUIRED_FIELDS = [
48
+ "name",
49
+ "description",
50
+ "voice",
51
+ "search_topics",
52
+ ];
39
53
  export const RECOMMENDED_FIELDS = [
40
54
  "differentiator",
41
55
  "get_started_link",
@@ -422,14 +436,14 @@ export function ensureShortLinksDefault() {
422
436
  // ---------------------------------------------------------------------------
423
437
  // Which required fields are missing on the persisted project in config.json.
424
438
  // Returns null when the named project can't be found/read.
425
- export function missingForProject(name) {
439
+ export function missingForProject(name, fields = REQUIRED_FIELDS) {
426
440
  if (!name)
427
441
  return null;
428
442
  try {
429
443
  const proj = (readConfig().projects || []).find((p) => p.name === name);
430
444
  if (!proj)
431
445
  return null;
432
- return REQUIRED_FIELDS.filter((f) => {
446
+ return fields.filter((f) => {
433
447
  const v = proj[f];
434
448
  if (v == null)
435
449
  return true;
@@ -471,7 +485,9 @@ export function personaReady() {
471
485
  const persona = findPersonaProject();
472
486
  if (!persona)
473
487
  return false;
474
- const missing = missingForProject(persona.name);
488
+ // Validate against PERSONA_REQUIRED_FIELDS, NOT the product REQUIRED_FIELDS: a
489
+ // persona has no website/icp by design, so the product set would never pass.
490
+ const missing = missingForProject(persona.name, PERSONA_REQUIRED_FIELDS);
475
491
  return missing !== null && missing.length === 0;
476
492
  }
477
493
  const SETUP_REQUIRED_MESSAGE = "No project is set up yet. Run the `project_config` tool first: collect from the user their website " +
@@ -148,8 +148,6 @@ const LOG_FLUSH_MS = 3000; // otherwise flush on this cadence
148
148
  let logNoiseRe = null;
149
149
  try {
150
150
  const extra = (process.env.SAPS_LOG_NOISE_RE || "").trim();
151
- // Default signatures: a bare `ps`/`launchctl`-style table dump row is rare in
152
- // pipeline output but floods when an agent shells one in. Keep this tight.
153
151
  const sources = [
154
152
  extra,
155
153
  ].filter(Boolean);
@@ -158,9 +156,20 @@ try {
158
156
  catch {
159
157
  logNoiseRe = null;
160
158
  }
159
+ // The X-Installation header (identity.py `header` output) is a single long base64
160
+ // blob printed on stdout. Every heartbeat + every log flush shells identity.py to
161
+ // mint it, and that stdout was being tee'd straight back into the log stream, which
162
+ // re-triggered a flush — a self-referential loop that flooded Cloud Logging with
163
+ // ~21k identical base64 lines/hour and buried real pipeline output. Karol's box was
164
+ // impossible to read through it. Drop any line that is nothing but a long run of
165
+ // base64 chars (no spaces): real pipeline output is never shaped like this, so the
166
+ // filter is safe. Kept separate from logNoiseRe so an env override can't disable it.
167
+ const BASE64_BLOB_RE = /^[A-Za-z0-9+/=_-]{120,}$/;
161
168
  function isNoise(line) {
162
169
  if (!line || !line.trim())
163
170
  return true; // blank / whitespace-only
171
+ if (BASE64_BLOB_RE.test(line.trim()))
172
+ return true; // X-Installation header echo
164
173
  if (logNoiseRe && logNoiseRe.test(line))
165
174
  return true;
166
175
  return false;
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.176",
3
- "installedAt": "2026-07-01T00:50:21.138Z"
2
+ "version": "1.6.178",
3
+ "installedAt": "2026-07-01T02:07:11.872Z"
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.176",
5
+ "version": "1.6.178",
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": {
@@ -79,6 +79,28 @@ except Exception as _import_err:
79
79
 
80
80
  import s4l_state as st # noqa: E402
81
81
 
82
+ # AppKit is available in the owned venv (PyObjC is a rumps dependency). We use it
83
+ # only to pull the accessory (LSUIElement) app to the front before showing an
84
+ # NSAlert: an agent app that isn't the active app has its rumps.alert appear
85
+ # BEHIND the frontmost window ("modal doesn't show on top"), because runModal
86
+ # doesn't activate the app for us. Guarded so a missing AppKit never breaks the
87
+ # menu bar — the alert still shows, just possibly not front-most.
88
+ try:
89
+ from AppKit import NSApplication # noqa: E402
90
+ except Exception:
91
+ NSApplication = None
92
+
93
+
94
+ def _activate_front():
95
+ """Bring this accessory app to the front so the next NSAlert (rumps.alert)
96
+ opens on top of whatever was frontmost, instead of behind it. Best-effort."""
97
+ try:
98
+ if NSApplication is not None:
99
+ NSApplication.sharedApplication().activateIgnoringOtherApps_(True)
100
+ except Exception:
101
+ pass
102
+
103
+
82
104
  CLAUDE_APP = "Claude"
83
105
  POLL_SECONDS = 5
84
106
 
@@ -349,6 +371,7 @@ class S4LMenuBar(rumps.App):
349
371
  else:
350
372
  msg = "Click OK, then open Claude and ask it to " + action_desc + "."
351
373
  try:
374
+ _activate_front()
352
375
  rumps.alert(title=title, message=msg, ok="OK")
353
376
  except Exception:
354
377
  self._notify("S4L · prompt copied" if copied else "S4L",
@@ -598,6 +621,7 @@ class S4LMenuBar(rumps.App):
598
621
  return
599
622
  # ok=1 (plugin reset, keeps X login + browser layer), other=-1 (deep wipe),
600
623
  # cancel=0. See rumps.alert: default=1, alternate=0, other=-1.
624
+ _activate_front()
601
625
  choice = rumps.alert(
602
626
  title="Reset test machine?",
603
627
  message=(
@@ -638,6 +662,7 @@ class S4LMenuBar(rumps.App):
638
662
 
639
663
  def _alert(self, title, message):
640
664
  try:
665
+ _activate_front()
641
666
  rumps.alert(title=title, message=message, ok="OK")
642
667
  except Exception:
643
668
  pass
@@ -670,6 +695,7 @@ class S4LMenuBar(rumps.App):
670
695
  disable them is to quit Claude, strip the tasks while it's down, then
671
696
  relaunch. We warn the user with a modal FIRST that Claude Desktop will
672
697
  restart, since the app window will close and reopen under them."""
698
+ _activate_front()
673
699
  choice = rumps.alert(
674
700
  title="Quit the S4L autoposter?",
675
701
  message=(
@@ -946,6 +972,7 @@ class S4LMenuBar(rumps.App):
946
972
  self._reloc_needed = False
947
973
  self._cwd_healed = True
948
974
  return
975
+ _activate_front()
949
976
  choice = rumps.alert(
950
977
  title="Tidy the autopilot history?",
951
978
  message=(
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.176",
3
+ "version": "1.6.178",
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.176",
3
+ "version": "1.6.178",
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"
@@ -168,6 +168,35 @@ def _app_version():
168
168
  return None
169
169
 
170
170
 
171
+ def _claude_desktop_version():
172
+ """CFBundleShortVersionString of the Claude Desktop app (macOS), or None.
173
+
174
+ Stamped into the install identity (and thus the X-Installation header on every
175
+ heartbeat) so the install-lane digest can correlate leaks/regressions with the
176
+ Desktop version. This is the variable we could not answer for Karol's box. Reads
177
+ Info.plist directly via plistlib; best-effort, never raises."""
178
+ if (platform.system() or "").lower() != "darwin":
179
+ return None
180
+ candidates = [
181
+ Path("/Applications/Claude.app/Contents/Info.plist"),
182
+ Path.home() / "Applications" / "Claude.app" / "Contents" / "Info.plist",
183
+ ]
184
+ for plist in candidates:
185
+ try:
186
+ if not plist.exists():
187
+ continue
188
+ import plistlib
189
+
190
+ with plist.open("rb") as f:
191
+ data = plistlib.load(f)
192
+ v = data.get("CFBundleShortVersionString") or data.get("CFBundleVersion")
193
+ if v:
194
+ return str(v).strip() or None
195
+ except Exception:
196
+ continue
197
+ return None
198
+
199
+
171
200
  def _tz():
172
201
  try:
173
202
  from datetime import datetime
@@ -192,6 +221,7 @@ def _build_fresh_identity():
192
221
  "python_version": platform.python_version() or None,
193
222
  "node_version": _node_version(),
194
223
  "app_version": _app_version(),
224
+ "claude_desktop_version": _claude_desktop_version(),
195
225
  "git_email": _git_email(),
196
226
  "tz": _tz(),
197
227
  "first_seen_at": int(time.time()),
@@ -16,6 +16,7 @@ import re
16
16
  import socket
17
17
  import subprocess
18
18
  import sys
19
+ import time
19
20
  from pathlib import Path
20
21
  from typing import Any
21
22
 
@@ -595,6 +596,8 @@ def build_snapshot(top_n: int) -> dict[str, Any]:
595
596
  "ts": dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="seconds"),
596
597
  "hostname": socket.gethostname(),
597
598
  "repo_dir": str(REPO_DIR),
599
+ "claude_desktop_version": claude_desktop_version(),
600
+ "reaper": reaper_status(),
598
601
  "memory": parse_vm_stat(),
599
602
  "process_count": len(rows),
600
603
  "top_rss": [
@@ -685,6 +688,8 @@ def build_summary() -> dict[str, Any]:
685
688
  "ts": dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="seconds"),
686
689
  "hostname": socket.gethostname(),
687
690
  "app_version": _app_version(),
691
+ "claude_desktop_version": claude_desktop_version(),
692
+ "reaper": reaper_status(),
688
693
  "process_count": len(rows),
689
694
  "mem": {
690
695
  "total_mb": total,
@@ -724,6 +729,188 @@ def _app_version() -> str | None:
724
729
  return None
725
730
 
726
731
 
732
+ def claude_desktop_version() -> str | None:
733
+ """CFBundleShortVersionString of the Claude Desktop app, or None if not found.
734
+
735
+ This is the ONE variable we could not answer for Karol: the reaper's blind spot
736
+ (a newer Claude Code changed the session-path shape so UUID_RE stopped matching)
737
+ is version-correlated, so we now stamp the Desktop version on every heartbeat +
738
+ snapshot. Reading Info.plist via plistlib is more robust than shelling `defaults`
739
+ (works headless, no user-defaults cache). Checks both the system-wide and the
740
+ per-user install locations. Best-effort: never raises."""
741
+ candidates = [
742
+ Path("/Applications/Claude.app/Contents/Info.plist"),
743
+ Path.home() / "Applications" / "Claude.app" / "Contents" / "Info.plist",
744
+ ]
745
+ for plist in candidates:
746
+ try:
747
+ if not plist.exists():
748
+ continue
749
+ import plistlib
750
+
751
+ with plist.open("rb") as f:
752
+ data = plistlib.load(f)
753
+ v = data.get("CFBundleShortVersionString") or data.get("CFBundleVersion")
754
+ if v:
755
+ return str(v).strip() or None
756
+ except Exception:
757
+ continue
758
+ return None
759
+
760
+
761
+ def reaper_status() -> dict[str, Any] | None:
762
+ """Last cycle written by reap_stale_claude_sessions.py::write_status(), or None.
763
+
764
+ The reaper is a SEPARATE launchd job (com.m13v.social-claude-reaper) whose stderr
765
+ only lands in a local file, so its outcome was invisible centrally. It now drops a
766
+ reaper-status.json each cycle; we carry it on the heartbeat so a stuck/blind reaper
767
+ (e.g. ps_timed_out, or unparsed_worker_procs climbing while it kills nothing — the
768
+ Karol failure mode) is visible in installation_resource_samples. Also surfaces
769
+ staleness: if the file has not been touched recently the reaper itself may be dead."""
770
+ path = (
771
+ Path(os.environ.get("SAPS_STATE_DIR", str(Path.home() / ".social-autoposter-mcp")))
772
+ / "claude-queue"
773
+ / "reaper-status.json"
774
+ )
775
+ try:
776
+ if not path.exists():
777
+ return None
778
+ ds = json.loads(path.read_text())
779
+ age = None
780
+ try:
781
+ age = round(time.time() - path.stat().st_mtime, 1)
782
+ except OSError:
783
+ pass
784
+ return {
785
+ "ts": ds.get("ts"),
786
+ "age_sec": age, # seconds since the reaper last wrote — >120s hints it is dead
787
+ "mode": ds.get("mode"),
788
+ "claude_killed": ds.get("claude_killed"),
789
+ "macos_mcp_killed": ds.get("macos_mcp_killed"),
790
+ "worker_probe_seen": ds.get("worker_probe_seen"),
791
+ "reapable_workers": ds.get("reapable_workers"),
792
+ "unparsed_worker_procs": ds.get("unparsed_worker_procs"),
793
+ "macos_mcp_seen": ds.get("macos_mcp_seen"),
794
+ "leaked_groups": ds.get("leaked_groups"),
795
+ "ps_timed_out": ds.get("ps_timed_out"),
796
+ "snapshot_empty": ds.get("snapshot_empty"),
797
+ }
798
+ except (OSError, ValueError, TypeError):
799
+ return None
800
+
801
+
802
+ def _tail_lines(path: Path, n: int, approx_line_bytes: int = 4096) -> list[str]:
803
+ """Return the last `n` lines of a possibly-large file without reading it all.
804
+ Reads a bounded tail window (n * approx_line_bytes) from the end. Best-effort."""
805
+ try:
806
+ size = path.stat().st_size
807
+ want = min(size, n * approx_line_bytes)
808
+ with path.open("rb") as f:
809
+ f.seek(size - want)
810
+ data = f.read()
811
+ text = data.decode("utf-8", "replace")
812
+ lines = text.splitlines()
813
+ # Drop a possibly-truncated first line when we did not start at byte 0.
814
+ if want < size and lines:
815
+ lines = lines[1:]
816
+ return lines[-n:]
817
+ except Exception:
818
+ return []
819
+
820
+
821
+ def _maybe_leak_alert(output: Path, current: dict[str, Any]) -> None:
822
+ """Fire a Sentry event when a monitored process group climbs monotonically for
823
+ N consecutive snapshots — the leak SHAPE that took down Karol's box (claude
824
+ workers + remote-macos-use MCP servers ratcheting up unbounded). This catches a
825
+ leak while it is GROWING, hours before the box freezes, instead of us finding out
826
+ from a support ticket. Best-effort + rate-limited by a cooldown file so a genuine
827
+ ongoing leak pages once per window, not every minute.
828
+
829
+ Runs on the JSONL path (every ~minute), reading its own recent history from the
830
+ file just written, so it needs no extra state beyond a small cooldown marker."""
831
+ groups_to_watch = ("claude_cli", "remote_macos_mcp_servers")
832
+ samples = _env_int("SAPS_LEAK_ALERT_SAMPLES", 5) # consecutive climbs required
833
+ floor = _env_int("SAPS_LEAK_ALERT_FLOOR", 20) # ignore below this count
834
+ climb_min = _env_int("SAPS_LEAK_ALERT_CLIMB_MIN", 12) # min first->last growth
835
+ cooldown_s = _env_int("SAPS_LEAK_ALERT_COOLDOWN", 1800)
836
+ if samples < 3:
837
+ samples = 3
838
+
839
+ tail = _tail_lines(output, samples)
840
+ series: list[dict[str, Any]] = []
841
+ for line in tail:
842
+ try:
843
+ series.append(json.loads(line))
844
+ except Exception:
845
+ continue
846
+ if len(series) < samples:
847
+ return
848
+
849
+ def counts(name: str) -> list[int]:
850
+ vals = []
851
+ for snap in series[-samples:]:
852
+ g = (snap.get("groups") or {}).get(name) or {}
853
+ c = g.get("count")
854
+ vals.append(int(c) if isinstance(c, (int, float)) else 0)
855
+ return vals
856
+
857
+ leaking: list[str] = []
858
+ for name in groups_to_watch:
859
+ vals = counts(name)
860
+ if len(vals) < samples:
861
+ continue
862
+ monotonic = all(vals[i] <= vals[i + 1] for i in range(len(vals) - 1))
863
+ grew = (vals[-1] - vals[0]) >= climb_min
864
+ if monotonic and grew and vals[-1] >= floor:
865
+ leaking.append(f"{name} {vals[0]}->{vals[-1]} over {samples} samples")
866
+
867
+ if not leaking:
868
+ return
869
+
870
+ # Cooldown: one page per window even if the leak persists for hours.
871
+ state = Path(os.environ.get("SAPS_STATE_DIR", str(Path.home() / ".social-autoposter-mcp"))) / "claude-queue"
872
+ cooldown = state / "leak-alert.cooldown"
873
+ now = time.time()
874
+ try:
875
+ if cooldown.exists() and (now - cooldown.stat().st_mtime) < cooldown_s:
876
+ return
877
+ except OSError:
878
+ pass
879
+
880
+ reason = "; ".join(leaking)
881
+ # Always emit the stderr marker (parsed into the dashboard even without Sentry).
882
+ print(f"LEAK_ALERT {reason}", file=sys.stderr)
883
+ try:
884
+ import sentry_init
885
+
886
+ sentry_init.init()
887
+ sentry_init.capture_message(
888
+ f"process-group leak climbing: {reason}",
889
+ level="warning",
890
+ tags={
891
+ "component": "leak_detector",
892
+ "hostname": socket.gethostname(),
893
+ "claude_desktop_version": claude_desktop_version() or "unknown",
894
+ "app_version": _app_version() or "unknown",
895
+ },
896
+ )
897
+ sentry_init.flush(3.0)
898
+ except Exception:
899
+ pass
900
+ try:
901
+ state.mkdir(parents=True, exist_ok=True)
902
+ cooldown.write_text(str(now))
903
+ except Exception:
904
+ pass
905
+
906
+
907
+ def _env_int(name: str, default: int) -> int:
908
+ try:
909
+ return int(os.environ.get(name, default))
910
+ except (TypeError, ValueError):
911
+ return default
912
+
913
+
727
914
  def main() -> int:
728
915
  parser = argparse.ArgumentParser(description=__doc__)
729
916
  parser.add_argument("--output", default=os.environ.get("SAPS_MEMORY_SNAPSHOT_LOG", str(DEFAULT_OUTPUT)))
@@ -748,6 +935,10 @@ def main() -> int:
748
935
  with output.open("a", encoding="utf-8") as fh:
749
936
  fh.write(json.dumps(snapshot, sort_keys=True, separators=(",", ":")) + "\n")
750
937
 
938
+ # Proactive leak page: reads the tail of the JSONL we just appended to, so no
939
+ # extra state. Best-effort; never blocks the snapshot write.
940
+ _maybe_leak_alert(output, snapshot)
941
+
751
942
  groups = snapshot.get("groups", {})
752
943
  queues = snapshot.get("queues", {})
753
944
  claude_queue = queues.get("claude_queue", {}) if isinstance(queues, dict) else {}
@@ -40,6 +40,7 @@ even before the owned runtime is provisioned.
40
40
 
41
41
  from __future__ import annotations
42
42
 
43
+ import datetime as dt
43
44
  import json
44
45
  import os
45
46
  import re
@@ -102,6 +103,16 @@ SIG_REQUIRED = (
102
103
  DISCLAIMER_HINT = "Helpers/disclaimer"
103
104
  SIG_EXCLUDED = (DISCLAIMER_HINT,)
104
105
 
106
+ # A LOOSER probe used purely for telemetry (never for killing): any process that
107
+ # looks like a bundled claude-code agent-mode worker, even if it does NOT satisfy
108
+ # the full SIG_REQUIRED tuple or its session path fails UUID_RE. This is the exact
109
+ # blind spot that let Karol's box leak undetected: a newer Claude Code changed the
110
+ # session-path shape so UUID_RE stopped matching, the worker fell out of `procs`,
111
+ # and the reaper saw "nothing to do" while ~289 workers piled up. We count these
112
+ # separately (`unparsed_worker_procs`) so a future regression is VISIBLE centrally
113
+ # instead of silent.
114
+ WORKER_PROBE = ("claude-code/", "--input-format stream-json")
115
+
105
116
  UUID_RE = re.compile(r"local-agent-mode-sessions/([0-9a-fA-F-]{36})")
106
117
 
107
118
  # The paired leak: every leaked `claude` worker spawns a `mcp-server-macos-use`
@@ -157,15 +168,39 @@ def parse_etime(etime: str) -> int:
157
168
  def snapshot():
158
169
  """Snapshot the process table once.
159
170
 
160
- Returns (procs, by_pid, macos_mcp, meta):
171
+ Returns (procs, by_pid, macos_mcp, meta, stats):
161
172
  * procs — claude agent-mode worker-signature processes (the primary target).
162
173
  * by_pid — {pid: cmd} for every process (used to pair the disclaimer stub).
163
174
  * macos_mcp — {pid, ppid, age, cmd} for every `mcp-server-macos-use` node server
164
175
  (the paired leak, reaped in main()).
165
176
  * meta — {pid: {ppid, age}} for every process, so main() can tell whether an
166
177
  MCP server's parent is still alive (orphan detection).
178
+ * stats — {ps_timed_out, snapshot_empty, worker_probe_seen, reapable_workers,
179
+ unparsed_worker_procs, macos_mcp_seen, total_procs}. Pure telemetry
180
+ so a future regression (e.g. UUID_RE stops matching a newer Claude
181
+ Code, the exact blind spot on Karol's box) is VISIBLE centrally
182
+ instead of silently piling up.
167
183
  """
168
- out = _run_ps()
184
+ stats = {
185
+ "ps_timed_out": False,
186
+ "snapshot_empty": False,
187
+ "worker_probe_seen": 0, # procs that look like a claude-code agent worker
188
+ "reapable_workers": 0, # of those, ones that satisfy SIG + UUID (=len(procs))
189
+ "unparsed_worker_procs": 0, # probe-positive but NOT reapable (regex/sig miss)
190
+ "macos_mcp_seen": 0,
191
+ "total_procs": 0,
192
+ }
193
+ try:
194
+ out = _run_ps()
195
+ except subprocess.TimeoutExpired:
196
+ # ps timed out even after the retry (box is at load 75 / 90% sys under a
197
+ # runaway leak). Surface it: a blind reaper cycle is a first-class datapoint,
198
+ # not a swallowed exception.
199
+ stats["ps_timed_out"] = True
200
+ stats["snapshot_empty"] = True
201
+ return [], {}, [], {}, stats
202
+ if not out.strip():
203
+ stats["snapshot_empty"] = True
169
204
  me = os.getpid()
170
205
  procs = []
171
206
  macos_mcp = []
@@ -177,6 +212,7 @@ def snapshot():
177
212
  continue
178
213
  pid, ppid, etime, cmd = int(m.group(1)), int(m.group(2)), m.group(3), m.group(4)
179
214
  by_pid[pid] = cmd
215
+ stats["total_procs"] += 1
180
216
  try:
181
217
  age = parse_etime(etime)
182
218
  except Exception:
@@ -188,17 +224,34 @@ def snapshot():
188
224
  # claude worker signature; these are separate node procs the workers spawn.
189
225
  if MACOS_MCP_RE.search(cmd) and not _SSH_RE.match(cmd):
190
226
  macos_mcp.append({"pid": pid, "ppid": ppid, "age": age, "cmd": cmd})
227
+ stats["macos_mcp_seen"] += 1
191
228
  continue
192
- # (b) claude agent-mode worker sessions.
229
+ # Telemetry probe: does this look like a claude-code agent worker at all?
230
+ # Deliberately looser than SIG_REQUIRED, and it EXCLUDES the disclaimer stub
231
+ # so we don't double-count the launcher parent.
232
+ is_probe = (
233
+ all(tok in cmd for tok in WORKER_PROBE)
234
+ and not any(tok in cmd for tok in SIG_EXCLUDED)
235
+ )
236
+ if is_probe:
237
+ stats["worker_probe_seen"] += 1
238
+ # (b) claude agent-mode worker sessions — the REAPABLE set.
193
239
  if not all(tok in cmd for tok in SIG_REQUIRED):
240
+ if is_probe:
241
+ stats["unparsed_worker_procs"] += 1 # looks like a worker, sig miss
194
242
  continue
195
243
  if any(tok in cmd for tok in SIG_EXCLUDED):
196
244
  continue
197
245
  u = UUID_RE.search(cmd)
198
246
  if not u:
247
+ # Full signature but the session path shape defeated UUID_RE — THE Karol
248
+ # blind spot. Count it so the leak is never invisible again.
249
+ if is_probe:
250
+ stats["unparsed_worker_procs"] += 1
199
251
  continue
200
252
  procs.append({"pid": pid, "ppid": ppid, "age": age, "uuid": u.group(1), "cmd": cmd})
201
- return procs, by_pid, macos_mcp, meta
253
+ stats["reapable_workers"] = len(procs)
254
+ return procs, by_pid, macos_mcp, meta, stats
202
255
 
203
256
 
204
257
  def kill(pid: int) -> bool:
@@ -231,6 +284,24 @@ def _state_dir() -> str:
231
284
  )
232
285
 
233
286
 
287
+ def write_status(status: dict) -> None:
288
+ """Persist the last reaper cycle to <state_dir>/claude-queue/reaper-status.json
289
+ (atomic write). memory_snapshot.py reads this file and carries it on the heartbeat,
290
+ so the reaper — a SEPARATE launchd job whose stderr only lands in a local file — is
291
+ finally observable centrally. Mirrors the drain_status.json pattern. Best-effort:
292
+ the reaper's real work must never fail because telemetry could not be written."""
293
+ try:
294
+ d = os.path.join(_state_dir(), "claude-queue")
295
+ os.makedirs(d, exist_ok=True)
296
+ path = os.path.join(d, "reaper-status.json")
297
+ tmp = path + ".tmp"
298
+ with open(tmp, "w") as f:
299
+ json.dump(status, f)
300
+ os.replace(tmp, path)
301
+ except Exception:
302
+ pass
303
+
304
+
234
305
  def count_running_jobs():
235
306
  """Number of IN-FLIGHT claimed jobs, or None if the queue dir is unreadable.
236
307
 
@@ -320,7 +391,7 @@ def main() -> int:
320
391
  inflight = count_running_jobs() # None => queue unreadable => age-gate fallback
321
392
  claim_pids = running_claim_pids() # agent-session pids actively holding a claim
322
393
 
323
- procs, by_pid, macos_mcp, meta = snapshot()
394
+ procs, by_pid, macos_mcp, meta, stats = snapshot()
324
395
 
325
396
  # Group by session uuid.
326
397
  groups: dict[str, list[dict]] = {}
@@ -416,18 +487,47 @@ def main() -> int:
416
487
  if dry or kill(mp["pid"]):
417
488
  macos_killed += 1
418
489
 
419
- if not killed and not disclaimers and not macos_killed:
420
- # Nothing reapable this run (common no-leak path).
421
- return 0
422
-
423
490
  mode = "queue" if inflight is not None else "age-fallback"
491
+ leaked_groups = sum(1 for g in groups.values() if len(g) > 1)
492
+
493
+ # Always persist the cycle outcome + always emit ONE structured marker, even on
494
+ # the common no-leak path. Two reasons this replaced the old silent early-return:
495
+ # * The reaper is a separate launchd job; without a per-cycle heartbeat there is
496
+ # no way to tell "reaper ran and found nothing" from "reaper is dead/stuck".
497
+ # * `unparsed_worker_procs > 0` on a quiet cycle is the EARLY WARNING that the
498
+ # worker signature has drifted (Karol's blind spot) — it must be visible even
499
+ # when we killed nothing, precisely because we killed nothing.
500
+ status = {
501
+ "ts": dt.datetime.now(dt.timezone.utc).isoformat(),
502
+ "dry_run": bool(dry),
503
+ "mode": mode,
504
+ "inflight": inflight,
505
+ "ceiling_sec": max_age,
506
+ "max_group": max_group,
507
+ "leaked_groups": leaked_groups,
508
+ "claude_killed": killed,
509
+ "disclaimer_killed": disclaimers,
510
+ "macos_mcp_killed": macos_killed,
511
+ "spared_claim_pids": sorted(claim_pids),
512
+ "worker_probe_seen": stats["worker_probe_seen"],
513
+ "reapable_workers": stats["reapable_workers"],
514
+ "unparsed_worker_procs": stats["unparsed_worker_procs"],
515
+ "macos_mcp_seen": stats["macos_mcp_seen"],
516
+ "total_procs": stats["total_procs"],
517
+ "ps_timed_out": stats["ps_timed_out"],
518
+ "snapshot_empty": stats["snapshot_empty"],
519
+ }
520
+ write_status(status)
521
+
424
522
  prefix = "[claude-reaper]" + (" DRY-RUN" if dry else "")
425
523
  print(
426
- f"{prefix} reaped {killed} stale agent-mode claude session(s)"
427
- f" + {disclaimers} disclaimer stub(s) + {macos_killed} remote-macos-mcp server(s)"
428
- f" across {sum(1 for g in groups.values() if len(g) > 1)}"
429
- f" leaked uuid group(s); mode={mode} inflight={inflight} ceiling={max_age}s"
430
- f" spared_claim_pids={sorted(claim_pids)} max_group={max_group}",
524
+ f"{prefix} cycle mode={mode} inflight={inflight} ceiling={max_age}s"
525
+ f" worker_seen={stats['worker_probe_seen']} reapable={stats['reapable_workers']}"
526
+ f" unparsed={stats['unparsed_worker_procs']} leaked_groups={leaked_groups}"
527
+ f" mcp_seen={stats['macos_mcp_seen']} killed={killed}"
528
+ f" disclaimer_killed={disclaimers} mcp_killed={macos_killed}"
529
+ f" ps_timeout={int(stats['ps_timed_out'])} empty={int(stats['snapshot_empty'])}"
530
+ f" max_group={max_group}",
431
531
  file=sys.stderr,
432
532
  )
433
533
  return 0
@@ -40,6 +40,19 @@ WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
40
40
  # false "not scheduled".
41
41
  FIRING_WINDOW = 420
42
42
 
43
+ # Grace for a JUST-scheduled task that hasn't fired yet. When the user runs
44
+ # "Set up draft schedule", create_scheduled_task registers both worker tasks
45
+ # (cron "* * * * *", enabled) but lastRunAt is null until the host fires them the
46
+ # first time — up to a minute or two later, longer if the launchd kicker is still
47
+ # installing. Without this grace, compute() saw newest_epoch=None and returned
48
+ # "missing", so the menu bar kept flashing ⚠ right after the user successfully
49
+ # scheduled. If a freshly-created, enabled task hasn't fired yet but was created
50
+ # within this window, treat it as "ok" (waiting for first fire, not orphaned).
51
+ # 15 min comfortably covers a slow first fire; a genuinely dead schedule still
52
+ # flips to ⚠ once the grace lapses. Orphaned tasks from an old account carry a
53
+ # stale createdAt, so they never fall inside this window.
54
+ CREATED_GRACE = 900
55
+
43
56
  SCHED_REGISTRY_GLOB = os.path.join(
44
57
  os.path.expanduser("~"), "Library", "Application Support", "Claude",
45
58
  "claude-code-sessions", "*", "*", "scheduled-tasks.json",
@@ -58,9 +71,21 @@ def _iso_to_epoch(s):
58
71
  return None
59
72
 
60
73
 
74
+ def _ms_to_epoch(ms):
75
+ """createdAt is epoch MILLISECONDS in the registry; return epoch seconds."""
76
+ try:
77
+ return float(ms) / 1000.0
78
+ except (TypeError, ValueError):
79
+ return None
80
+
81
+
61
82
  def compute(glob_pattern: str = SCHED_REGISTRY_GLOB) -> str:
62
83
  """Return 'ok' | 'disabled' | 'missing' for the live account's draft schedule."""
63
84
  newest_epoch, newest_enabled = None, False
85
+ # Track the freshest just-created, enabled, never-yet-fired task so a schedule
86
+ # the user only moments ago set up doesn't read as "missing" before its first
87
+ # fire lands (see CREATED_GRACE).
88
+ newest_fresh_created = None
64
89
  any_present, any_enabled = False, False
65
90
  for f in glob.glob(glob_pattern):
66
91
  try:
@@ -79,9 +104,19 @@ def compute(glob_pattern: str = SCHED_REGISTRY_GLOB) -> str:
79
104
  e = max([x for x in epochs if x is not None], default=None)
80
105
  if e is not None and (newest_epoch is None or e > newest_epoch):
81
106
  newest_epoch, newest_enabled = e, enabled
107
+ # Freshly scheduled + enabled + not yet fired anywhere in this registry.
108
+ if enabled and e is None:
109
+ created = [_ms_to_epoch(r.get("createdAt")) for r in recs]
110
+ c = min([x for x in created if x is not None], default=None)
111
+ if c is not None and (newest_fresh_created is None or c > newest_fresh_created):
112
+ newest_fresh_created = c
82
113
  # Firing recently => the live account's schedule is active and healthy.
83
114
  if newest_epoch is not None and (time.time() - newest_epoch) <= FIRING_WINDOW:
84
115
  return "ok" if newest_enabled else "disabled"
116
+ # Just scheduled, enabled, waiting for its first fire => "ok" (not orphaned).
117
+ # Suppresses the false ⚠ right after the user sets up the draft schedule.
118
+ if newest_fresh_created is not None and (time.time() - newest_fresh_created) <= CREATED_GRACE:
119
+ return "ok"
85
120
  # Not firing anywhere. Registered-but-disabled => disabled; else missing.
86
121
  if any_present and not any_enabled:
87
122
  return "disabled"
@@ -50,6 +50,12 @@ def _config_path() -> str:
50
50
  # Keep in sync with REQUIRED_FIELDS (mcp/src/setup.ts), QUEUE_WORKERS / UPDATER_LABEL
51
51
  # / AUTOPILOT_STALL_MS (mcp/src/index.ts).
52
52
  REQUIRED_FIELDS = ["name", "website", "description", "icp", "voice", "search_topics"]
53
+ # Keep in sync with PERSONA_REQUIRED_FIELDS (mcp/src/setup.ts). A personal-brand
54
+ # persona has no product website/icp by design; it is "ready" once it has the fields
55
+ # the cycle consumes (name, voice, seedable topics). Without this, a personal-brand-
56
+ # only setup can NEVER report setup_complete (any_ready requires a managed product),
57
+ # leaving the menu bar stuck on "project not set up". (2026-06-30)
58
+ PERSONA_REQUIRED_FIELDS = ["name", "description", "voice", "search_topics"]
53
59
  WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
54
60
  UPDATER_LABEL = "com.m13v.social-autoposter-update"
55
61
  AUTOPILOT_STALL_MS = 180_000
@@ -97,6 +103,35 @@ def _projects():
97
103
  return [_project_status(n, cfg_projects) for n in _managed_projects()]
98
104
 
99
105
 
106
+ def _persona_status():
107
+ """The personal-brand persona (config.json persona:true) as a project-status
108
+ dict, or None when there's no persona. Validated against PERSONA_REQUIRED_FIELDS
109
+ (a persona has no product website/icp). The persona is excluded from the managed
110
+ scope (_managed_projects) by design, but IS what the cycle drafts in
111
+ personal_brand mode, so it must count toward readiness."""
112
+ cfg = _read_json(_config_path()) or {}
113
+ persona = next((p for p in (cfg.get("projects") or []) if p.get("persona")), None)
114
+ if persona is None:
115
+ return None
116
+ missing = []
117
+ for f in PERSONA_REQUIRED_FIELDS:
118
+ v = persona.get(f)
119
+ if v is None:
120
+ missing.append(f)
121
+ elif isinstance(v, str) and not v.strip():
122
+ missing.append(f)
123
+ elif isinstance(v, (list, tuple)) and len(v) == 0:
124
+ missing.append(f)
125
+ elif isinstance(v, dict) and len(v) == 0:
126
+ missing.append(f)
127
+ return {
128
+ "name": persona.get("name") or "PersonalBrand",
129
+ "ready": len(missing) == 0,
130
+ "missing_required": missing,
131
+ "persona": True,
132
+ }
133
+
134
+
100
135
  # ---- runtime / mode / autopilot (all file/launchctl) -----------------------
101
136
  def _runtime_ready() -> bool:
102
137
  rt = _read_json(os.path.join(_state_dir(), "runtime.json")) or {}
@@ -304,7 +339,15 @@ def compute() -> dict:
304
339
  rt_ready = _runtime_ready()
305
340
  x = _x_status()
306
341
  mode = _mode()
342
+ flags = _flags()
307
343
  schedule_state = _schedule_state()
344
+ # Personal-brand-only setups have NO managed product project; the persona IS the
345
+ # draftable "project" for the self-promo lane. Surface it as a project row when
346
+ # that lane is on so projects_ready / setup_complete / project_ready reflect a
347
+ # persona-only setup instead of forever reading "not set up". (2026-06-30)
348
+ persona = _persona_status()
349
+ if persona is not None and flags.get("personal_brand"):
350
+ projects = projects + [persona]
308
351
  any_ready = any(p["ready"] for p in projects)
309
352
  setup_complete = rt_ready and any_ready and bool(x["connected"])
310
353