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 +34 -87
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +49 -0
- package/mcp/menubar/s4l_state.py +24 -15
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/claude_job.py +51 -1
- package/scripts/reap_stale_claude_sessions.py +69 -7
- package/scripts/snapshot.py +338 -0
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
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
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
|
-
|
|
2736
|
-
|
|
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
|
-
//
|
|
2739
|
-
//
|
|
2740
|
-
//
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
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
|
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.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))
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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
|
|
199
|
-
|
|
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 —
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
snap
|
|
214
|
-
|
|
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.
|
|
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
package/scripts/claude_job.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
246
|
-
#
|
|
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,
|
|
258
|
-
# can only ever add provably-idle
|
|
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
|
-
#
|
|
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"
|
|
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())
|