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 +11 -0
- package/mcp/dist/setup.js +19 -3
- package/mcp/dist/telemetry.js +11 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +27 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/identity.py +30 -0
- package/scripts/memory_snapshot.py +191 -0
- package/scripts/reap_stale_claude_sessions.py +114 -14
- package/scripts/schedule_state.py +35 -0
- package/scripts/snapshot.py +43 -0
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
|
|
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
|
-
|
|
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 " +
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -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;
|
package/mcp/dist/version.json
CHANGED
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.
|
|
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.
|
|
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
package/scripts/identity.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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}
|
|
427
|
-
f"
|
|
428
|
-
f"
|
|
429
|
-
f"
|
|
430
|
-
f"
|
|
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"
|
package/scripts/snapshot.py
CHANGED
|
@@ -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
|
|