@m13v/s4l 1.7.1-rc.26 → 1.7.1-rc.28
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 +58 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +69 -18
- package/mcp/menubar/s4l_state.py +41 -8
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/schedule_state.py +117 -54
package/mcp/dist/index.js
CHANGED
|
@@ -885,11 +885,24 @@ async function postApproved(batchId, plan) {
|
|
|
885
885
|
{
|
|
886
886
|
const gateDeadline = Date.now() + 8 * 60_000;
|
|
887
887
|
let waited = false;
|
|
888
|
+
let waitStartedAt = 0;
|
|
888
889
|
while ((postingActive || isPeerDrainActive()) && Date.now() < gateDeadline) {
|
|
890
|
+
if (!waited) {
|
|
891
|
+
waitStartedAt = Date.now();
|
|
892
|
+
// This loop was previously silent end-to-end (2026-07-08 forensics: a
|
|
893
|
+
// ~1:45 gap in a user's approval batch had zero trace anywhere). Log
|
|
894
|
+
// once on entry (not per 5s tick, to avoid spamming the file) so a
|
|
895
|
+
// later "why did card N take so long" question has a starting point.
|
|
896
|
+
logPostEvent(`postApproved_wait_start batch=${batchId} blocked_by=${describePostingBlocker()}`);
|
|
897
|
+
}
|
|
889
898
|
waited = true;
|
|
890
899
|
await sleepMs(5000);
|
|
891
900
|
}
|
|
892
|
-
|
|
901
|
+
const timedOut = postingActive || isPeerDrainActive();
|
|
902
|
+
if (waited) {
|
|
903
|
+
logPostEvent(`postApproved_wait_end batch=${batchId} waited_ms=${Date.now() - waitStartedAt} timed_out=${timedOut}`);
|
|
904
|
+
}
|
|
905
|
+
if (timedOut) {
|
|
893
906
|
return {
|
|
894
907
|
attempted: 0,
|
|
895
908
|
exit_code: 0,
|
|
@@ -3858,8 +3871,8 @@ async function ensureUpdaterInstalled() {
|
|
|
3858
3871
|
// 'disabled' — present but a worker task is disabled.
|
|
3859
3872
|
// 'missing' — not firing anywhere (orphaned / not registered for the live
|
|
3860
3873
|
// account) -> dashboard offers "Set up draft schedule".
|
|
3861
|
-
// The algorithm (live-account detection
|
|
3862
|
-
// etc.) lives in ONE place: scripts/schedule_state.py. The Python menu bar imports
|
|
3874
|
+
// The algorithm (live-account detection via config.json's lastKnownAccountUuid,
|
|
3875
|
+
// firing window, etc.) lives in ONE place: scripts/schedule_state.py. The Python menu bar imports
|
|
3863
3876
|
// that module in-process; we shell out to it here. Keeping a single implementation
|
|
3864
3877
|
// is the whole point — the two surfaces can no longer drift. The script is
|
|
3865
3878
|
// stdlib-only and resolvePython() falls back to system python3, so this works even
|
|
@@ -4155,8 +4168,12 @@ function writePanelUrl(url) {
|
|
|
4155
4168
|
// and fully usable via `url` for anything that already has it (e.g. this
|
|
4156
4169
|
// process's own Code side-panel proxy); we just don't publish ourselves
|
|
4157
4170
|
// as THE shared menu-bar target.
|
|
4171
|
+
logPanelEvent(`panel_cede existing_pid=${existing.pid}`);
|
|
4158
4172
|
return;
|
|
4159
4173
|
}
|
|
4174
|
+
logPanelEvent(existing?.pid
|
|
4175
|
+
? `panel_takeover dead_pid=${existing.pid}`
|
|
4176
|
+
: `panel_claim previous=none`);
|
|
4160
4177
|
fs.writeFileSync(panelEndpointPath(), JSON.stringify({ url, pid: process.pid, version: VERSION, started_at: new Date().toISOString() }, null, 2) + "\n", "utf-8");
|
|
4161
4178
|
}
|
|
4162
4179
|
catch (e) {
|
|
@@ -4307,6 +4324,25 @@ function isPeerDrainActive() {
|
|
|
4307
4324
|
return false;
|
|
4308
4325
|
}
|
|
4309
4326
|
}
|
|
4327
|
+
// Human-readable reason for the postApproved wait gate, used only for the
|
|
4328
|
+
// wait_start log line — distinguishes "our own drain hasn't hit its grace
|
|
4329
|
+
// release yet" from "a peer MCP instance (different pid, e.g. a separate
|
|
4330
|
+
// Claude session/queue-worker) is mid-drain," and names the peer pid + how
|
|
4331
|
+
// much longer its flag has left so a log reader doesn't have to guess.
|
|
4332
|
+
function describePostingBlocker() {
|
|
4333
|
+
if (postingActive)
|
|
4334
|
+
return "own_in_process_flag";
|
|
4335
|
+
try {
|
|
4336
|
+
const j = JSON.parse(fs.readFileSync(postingFlagPath(), "utf-8"));
|
|
4337
|
+
if (typeof j?.expires_at === "number" && j.expires_at > Date.now() && typeof j?.pid === "number") {
|
|
4338
|
+
return `peer_pid=${j.pid} expires_in_ms=${j.expires_at - Date.now()}`;
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
catch {
|
|
4342
|
+
/* best effort */
|
|
4343
|
+
}
|
|
4344
|
+
return "unknown";
|
|
4345
|
+
}
|
|
4310
4346
|
// activity.json: a tiny "what's running right now" signal the menu bar reads to
|
|
4311
4347
|
// show a loading spinner + label (scanning / drafting / posting / …). Written by
|
|
4312
4348
|
// long-running tools, cleared when they finish. Best-effort; absence == idle.
|
|
@@ -4491,6 +4527,25 @@ function logPostEvent(msg) {
|
|
|
4491
4527
|
/* best effort */
|
|
4492
4528
|
}
|
|
4493
4529
|
}
|
|
4530
|
+
// Timestamped on-disk trail of panel-endpoint.json ownership handoffs. Mirrors
|
|
4531
|
+
// logPostEvent's rationale (per-process console.error is hard to even locate
|
|
4532
|
+
// across ephemeral, per-host-session MCP instances), for the panel-election
|
|
4533
|
+
// side instead of the posting side. writePanelUrl() runs on every process's
|
|
4534
|
+
// eager startup, so this also captures the mundane "claimed with nothing
|
|
4535
|
+
// registered" and "ceded to an already-alive owner" cases, not just handoffs
|
|
4536
|
+
// — that's what makes it possible to tell "a fresh instance took over from a
|
|
4537
|
+
// dead registrant" apart from "an approve click found nothing registered at
|
|
4538
|
+
// all" after the fact, instead of inferring it from timing alone.
|
|
4539
|
+
function logPanelEvent(msg) {
|
|
4540
|
+
try {
|
|
4541
|
+
const dir = path.join(repoDir(), "skill", "logs");
|
|
4542
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
4543
|
+
fs.appendFileSync(path.join(dir, "panel-events.log"), `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);
|
|
4544
|
+
}
|
|
4545
|
+
catch {
|
|
4546
|
+
/* best effort */
|
|
4547
|
+
}
|
|
4548
|
+
}
|
|
4494
4549
|
function sigkillScanTree(pid) {
|
|
4495
4550
|
logPostEvent(`sigkill_scan_tree target=${pid}`);
|
|
4496
4551
|
try {
|
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.7.1-rc.
|
|
5
|
+
"version": "1.7.1-rc.28",
|
|
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": {
|
|
@@ -28,6 +28,50 @@ import tempfile
|
|
|
28
28
|
import threading
|
|
29
29
|
import time
|
|
30
30
|
|
|
31
|
+
# --- stderr timestamp wrapper -------------------------------------------------
|
|
32
|
+
# menubar.err.log (this process's stderr, redirected by the launchd plist) has
|
|
33
|
+
# never carried timestamps, so correlating a line like "[s4l-card] approved at
|
|
34
|
+
# level 1" with the UTC-stamped logs elsewhere (post-*.log, post-preempt-
|
|
35
|
+
# events.log, panel-events.log) meant guessing from file position alone
|
|
36
|
+
# (2026-07-08 forensics). Wrap sys.stderr once, here, before any other module
|
|
37
|
+
# gets a chance to print, so every line written to stderr for the rest of the
|
|
38
|
+
# process gets a leading UTC timestamp in the same format those other logs
|
|
39
|
+
# use — zero changes needed at any individual print()/write() call site
|
|
40
|
+
# (s4l_card._log, the many sys.stderr.write(...) calls in this file, rumps'/
|
|
41
|
+
# PyObjC's own warnings, …).
|
|
42
|
+
class _TimestampedStderr:
|
|
43
|
+
def __init__(self, stream):
|
|
44
|
+
self._stream = stream
|
|
45
|
+
self._newline_pending = True # True at the start of a fresh line
|
|
46
|
+
|
|
47
|
+
def write(self, data):
|
|
48
|
+
if not data:
|
|
49
|
+
return 0
|
|
50
|
+
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
51
|
+
lines = data.split("\n")
|
|
52
|
+
out = []
|
|
53
|
+
for i, line in enumerate(lines):
|
|
54
|
+
if i > 0:
|
|
55
|
+
out.append("\n")
|
|
56
|
+
starts_new_line = i > 0 or self._newline_pending
|
|
57
|
+
if starts_new_line and line:
|
|
58
|
+
out.append(f"[{ts}] ")
|
|
59
|
+
out.append(line)
|
|
60
|
+
self._newline_pending = data.endswith("\n")
|
|
61
|
+
return self._stream.write("".join(out))
|
|
62
|
+
|
|
63
|
+
def flush(self):
|
|
64
|
+
self._stream.flush()
|
|
65
|
+
|
|
66
|
+
def isatty(self):
|
|
67
|
+
try:
|
|
68
|
+
return self._stream.isatty()
|
|
69
|
+
except Exception:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
sys.stderr = _TimestampedStderr(sys.stderr)
|
|
74
|
+
|
|
31
75
|
# --- Sentry bootstrap --------------------------------------------------------
|
|
32
76
|
# The menu bar runs as a standalone KeepAlive LaunchAgent off the owned venv,
|
|
33
77
|
# a separate process from the MCP server, so it was a Sentry blind spot: a crash
|
|
@@ -2269,10 +2313,14 @@ class S4LMenuBar(rumps.App):
|
|
|
2269
2313
|
# never nag the user mid-onboarding — only the value is now always honest.
|
|
2270
2314
|
schedule_state = self._schedule_state()
|
|
2271
2315
|
self._schedule_state_cache = schedule_state
|
|
2272
|
-
# 'stalled' (task present + enabled
|
|
2273
|
-
# the Desktop warm-session wedge
|
|
2274
|
-
# attention just like missing/disabled
|
|
2275
|
-
#
|
|
2316
|
+
# 'stalled' (task present + enabled FOR THE ACTIVE ACCOUNT, host
|
|
2317
|
+
# scheduler stopped launching it: the Desktop warm-session wedge,
|
|
2318
|
+
# Karol 2026-07-06) needs attention just like missing/disabled.
|
|
2319
|
+
# schedule_state.py is account-scoped (2026-07-08): an account switch
|
|
2320
|
+
# now correctly resolves to 'missing' (the active account genuinely
|
|
2321
|
+
# has no registration), not 'stalled' — a stale OTHER account's
|
|
2322
|
+
# registry is never consulted for the active account's health, so
|
|
2323
|
+
# 'stalled' can no longer be misdiagnosed here.
|
|
2276
2324
|
attention = setup_complete and schedule_state in ("missing", "disabled", "stalled")
|
|
2277
2325
|
# Routines-lane rate limit (429): the draft tasks ARE registered and firing
|
|
2278
2326
|
# for this account, but every run dies on a Claude rate limit, so nothing
|
|
@@ -2304,13 +2352,12 @@ class S4LMenuBar(rumps.App):
|
|
|
2304
2352
|
# draft_stuck shadowed the missing/disabled branch in _build_menu — the user
|
|
2305
2353
|
# saw "worker keeps getting killed" with NO Re-arm button instead of "Draft
|
|
2306
2354
|
# tasks aren't scheduled on this account" + the one-click fix (Karol,
|
|
2307
|
-
# 2026-07-06). "stalled" is deliberately included (2026-07-08): that
|
|
2308
|
-
#
|
|
2309
|
-
# long — claimed-and-hung, OR never claimed at all
|
|
2310
|
-
#
|
|
2311
|
-
#
|
|
2312
|
-
#
|
|
2313
|
-
# which is a more reliable signal than the host's lastRunAt staleness.
|
|
2355
|
+
# 2026-07-06). "stalled" is deliberately included (2026-07-08): that
|
|
2356
|
+
# branch's fix (Set up draft schedule / re-arm) works for it too, but a
|
|
2357
|
+
# job that has sat this long — claimed-and-hung, OR never claimed at all
|
|
2358
|
+
# (see the ⧖ prefix check below) — is more specific, direct evidence
|
|
2359
|
+
# from the queue itself than the host's lastRunAt staleness, so it's
|
|
2360
|
+
# worth surfacing as its own reason even under "stalled".
|
|
2314
2361
|
if (
|
|
2315
2362
|
setup_complete
|
|
2316
2363
|
and schedule_state in ("ok", "stalled")
|
|
@@ -2965,16 +3012,20 @@ class S4LMenuBar(rumps.App):
|
|
|
2965
3012
|
items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
|
|
2966
3013
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
2967
3014
|
elif schedule_state == "stalled":
|
|
2968
|
-
# Task registered + enabled
|
|
2969
|
-
#
|
|
2970
|
-
#
|
|
2971
|
-
#
|
|
3015
|
+
# Task registered + enabled FOR THE ACTIVE ACCOUNT (schedule_state.py
|
|
3016
|
+
# is account-scoped, 2026-07-08 — see its module docstring) but the
|
|
3017
|
+
# host stopped launching it: the Claude Desktop warm-session wedge
|
|
3018
|
+
# (finished worker sessions never exit; the overlap guard skips
|
|
3019
|
+
# every fire). Re-arm goes through the same real create_scheduled_task
|
|
2972
3020
|
# path as onboarding — a verified fix the user has seen work —
|
|
2973
3021
|
# rather than a silent Claude Desktop quit/relaunch with no visible
|
|
2974
3022
|
# feedback, which field evidence (2026-07-08) showed doesn't
|
|
2975
|
-
# reliably clear the underlying stall
|
|
2976
|
-
#
|
|
2977
|
-
#
|
|
3023
|
+
# reliably clear the underlying stall (that field incident turned
|
|
3024
|
+
# out to be a DIFFERENT bug: schedule_state.py was reading a stale,
|
|
3025
|
+
# no-longer-active account's registry — now fixed, so "stalled"
|
|
3026
|
+
# here means what it says: same account, task present, just not
|
|
3027
|
+
# firing). Distinct label from the "aren't scheduled" case below
|
|
3028
|
+
# since the task DOES exist here; same one-click remedy either way.
|
|
2978
3029
|
items.append(self._label("⚠ Drafts stopped — Claude’s scheduler is stuck"))
|
|
2979
3030
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
2980
3031
|
else:
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -1317,8 +1317,18 @@ def review_event_add(event):
|
|
|
1317
1317
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1318
1318
|
with open(p, "a") as f:
|
|
1319
1319
|
f.write(json.dumps(ev) + "\n")
|
|
1320
|
-
except Exception:
|
|
1321
|
-
pass
|
|
1320
|
+
except Exception as err:
|
|
1321
|
+
# Was a silent `pass` — a card's approve/reject decision could vanish
|
|
1322
|
+
# with zero trace anywhere (2026-07-08: two approved-and-posted cards
|
|
1323
|
+
# had no review_events row at all, and this was one of the candidate
|
|
1324
|
+
# causes with nothing to confirm or rule it out). Never never write to
|
|
1325
|
+
# the outbox again for THIS event, so it's worth saying loudly.
|
|
1326
|
+
sys.stderr.write(
|
|
1327
|
+
f"[s4l-state] review_event_add: failed to append to outbox ({type(err).__name__}: {err}); "
|
|
1328
|
+
f"event DROPPED event_uuid={ev.get('event_uuid')} decision={ev.get('decision')} "
|
|
1329
|
+
f"candidate_id={ev.get('candidate_id')}\n"
|
|
1330
|
+
)
|
|
1331
|
+
sys.stderr.flush()
|
|
1322
1332
|
flush_review_events_async()
|
|
1323
1333
|
|
|
1324
1334
|
|
|
@@ -1339,16 +1349,29 @@ def flush_review_events():
|
|
|
1339
1349
|
if not p.exists():
|
|
1340
1350
|
return
|
|
1341
1351
|
lines = [ln for ln in p.read_text().splitlines() if ln.strip()]
|
|
1342
|
-
except Exception:
|
|
1352
|
+
except Exception as err:
|
|
1353
|
+
sys.stderr.write(f"[s4l-state] flush_review_events: failed to read outbox: {type(err).__name__}: {err}\n")
|
|
1354
|
+
sys.stderr.flush()
|
|
1343
1355
|
return
|
|
1344
1356
|
events = []
|
|
1345
1357
|
for ln in lines:
|
|
1346
1358
|
try:
|
|
1347
1359
|
ev = json.loads(ln)
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1360
|
+
except Exception as err:
|
|
1361
|
+
sys.stderr.write(
|
|
1362
|
+
f"[s4l-state] flush_review_events: dropping unparseable outbox line "
|
|
1363
|
+
f"({type(err).__name__}: {err}): {ln[:200]!r}\n"
|
|
1364
|
+
)
|
|
1365
|
+
sys.stderr.flush()
|
|
1351
1366
|
continue # corrupt line: dropped on the next rewrite
|
|
1367
|
+
if isinstance(ev, dict) and ev.get("event_uuid"):
|
|
1368
|
+
events.append(ev)
|
|
1369
|
+
else:
|
|
1370
|
+
sys.stderr.write(
|
|
1371
|
+
f"[s4l-state] flush_review_events: dropping malformed outbox line "
|
|
1372
|
+
f"(not a dict, or missing event_uuid): {ln[:200]!r}\n"
|
|
1373
|
+
)
|
|
1374
|
+
sys.stderr.flush()
|
|
1352
1375
|
if not events:
|
|
1353
1376
|
if lines: # only corrupt lines left — clear the file
|
|
1354
1377
|
_outbox_remove(set())
|
|
@@ -1357,7 +1380,12 @@ def flush_review_events():
|
|
|
1357
1380
|
# import lazily so a missing pipeline repo degrades to buffer-only.
|
|
1358
1381
|
try:
|
|
1359
1382
|
from http_api import api_post
|
|
1360
|
-
except Exception:
|
|
1383
|
+
except Exception as err:
|
|
1384
|
+
sys.stderr.write(
|
|
1385
|
+
f"[s4l-state] flush_review_events: http_api unavailable ({type(err).__name__}: {err}); "
|
|
1386
|
+
f"{len(events)} event(s) staying buffered in the outbox\n"
|
|
1387
|
+
)
|
|
1388
|
+
sys.stderr.flush()
|
|
1361
1389
|
return
|
|
1362
1390
|
shipped = set()
|
|
1363
1391
|
for i in range(0, len(events), 100):
|
|
@@ -1365,7 +1393,12 @@ def flush_review_events():
|
|
|
1365
1393
|
try:
|
|
1366
1394
|
api_post("/api/v1/review-events", {"events": batch})
|
|
1367
1395
|
shipped.update(e["event_uuid"] for e in batch)
|
|
1368
|
-
except Exception:
|
|
1396
|
+
except Exception as err:
|
|
1397
|
+
sys.stderr.write(
|
|
1398
|
+
f"[s4l-state] flush_review_events: POST failed ({type(err).__name__}: {err}); "
|
|
1399
|
+
f"{len(batch)} event(s) in this batch (and any after it) staying buffered for the next kick\n"
|
|
1400
|
+
)
|
|
1401
|
+
sys.stderr.flush()
|
|
1369
1402
|
break # network/API down: keep the rest for the next kick
|
|
1370
1403
|
_outbox_remove(shipped, keep_only_valid=True)
|
|
1371
1404
|
finally:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.1-rc.
|
|
3
|
+
"version": "1.7.1-rc.28",
|
|
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
|
@@ -104,16 +104,66 @@ CREATED_GRACE = 900
|
|
|
104
104
|
# "Claude*" (not "Claude"): the host app can run with a custom --user-data-dir
|
|
105
105
|
# (per-account dirs like "Claude-mediar" on multi-account machines), and the
|
|
106
106
|
# registry lands under THAT dir while plain "Claude/" has no claude-code-sessions
|
|
107
|
-
# at all.
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
107
|
+
# at all. UNSCOPED across every account under each root — deliberately, this is
|
|
108
|
+
# also imported by callers that need to see EVERY account's registries (e.g. the
|
|
109
|
+
# menu bar's relocation/legacy-consolidation sweep, which must find and clean up
|
|
110
|
+
# old accounts' leftover tasks, not just the live one). compute()/_detail() do
|
|
111
|
+
# NOT use this directly by default any more — see _active_registry_glob_patterns.
|
|
111
112
|
SCHED_REGISTRY_GLOB = os.path.join(
|
|
112
113
|
os.path.expanduser("~"), "Library", "Application Support", "Claude*",
|
|
113
114
|
"claude-code-sessions", "*", "*", "scheduled-tasks.json",
|
|
114
115
|
)
|
|
115
116
|
|
|
116
117
|
|
|
118
|
+
def _config_json_paths():
|
|
119
|
+
"""One config.json per Claude* root (multi-account machines can run
|
|
120
|
+
several distinct --user-data-dir roots, e.g. Claude-mediar, each a
|
|
121
|
+
separate host app instance with its own logged-in account)."""
|
|
122
|
+
return glob.glob(os.path.join(
|
|
123
|
+
os.path.expanduser("~"), "Library", "Application Support", "Claude*",
|
|
124
|
+
"config.json",
|
|
125
|
+
))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _active_account_uuid(config_path):
|
|
129
|
+
"""The account currently logged into the Claude* root config_path lives
|
|
130
|
+
under, or None if config.json is missing/unreadable/predates this field."""
|
|
131
|
+
try:
|
|
132
|
+
with open(config_path) as fh:
|
|
133
|
+
d = json.load(fh)
|
|
134
|
+
u = d.get("lastKnownAccountUuid")
|
|
135
|
+
return u if isinstance(u, str) and u else None
|
|
136
|
+
except Exception:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _active_registry_glob_patterns():
|
|
141
|
+
"""One glob pattern per Claude* root, scoped to ONLY the account that root
|
|
142
|
+
is currently logged into (verified 2026-07-08: claude-code-sessions/<uuid>/
|
|
143
|
+
is keyed by account — lastKnownAccountUuid in that root's config.json
|
|
144
|
+
matches the top-level directory local-agent-mode-sessions/<uuid>/ actually
|
|
145
|
+
running under, and a different, no-longer-active account's own <uuid>/
|
|
146
|
+
directory sits untouched alongside it). Falls back to the OLD
|
|
147
|
+
every-account-under-this-root pattern for a root whose config.json doesn't
|
|
148
|
+
resolve an account, so an unusual/older install never regresses to a blind
|
|
149
|
+
'missing' just because this field happens to be absent."""
|
|
150
|
+
patterns = []
|
|
151
|
+
for cfg in _config_json_paths():
|
|
152
|
+
root = os.path.dirname(cfg)
|
|
153
|
+
uuid = _active_account_uuid(cfg)
|
|
154
|
+
if uuid:
|
|
155
|
+
patterns.append(os.path.join(
|
|
156
|
+
root, "claude-code-sessions", uuid, "*", "scheduled-tasks.json"
|
|
157
|
+
))
|
|
158
|
+
else:
|
|
159
|
+
patterns.append(os.path.join(
|
|
160
|
+
root, "claude-code-sessions", "*", "*", "scheduled-tasks.json"
|
|
161
|
+
))
|
|
162
|
+
# No Claude* root found at all (host app never launched here) -> fall back
|
|
163
|
+
# to the unscoped glob so callers still see SOMETHING rather than nothing.
|
|
164
|
+
return patterns or [SCHED_REGISTRY_GLOB]
|
|
165
|
+
|
|
166
|
+
|
|
117
167
|
def _iso_to_epoch(s):
|
|
118
168
|
if not s:
|
|
119
169
|
return None
|
|
@@ -134,37 +184,45 @@ def _ms_to_epoch(ms):
|
|
|
134
184
|
return None
|
|
135
185
|
|
|
136
186
|
|
|
137
|
-
def compute(glob_pattern: str =
|
|
138
|
-
"""Return 'ok' | 'disabled' | 'stalled' | 'missing' for the draft schedule.
|
|
187
|
+
def compute(glob_pattern: str | None = None) -> str:
|
|
188
|
+
"""Return 'ok' | 'disabled' | 'stalled' | 'missing' for the draft schedule.
|
|
189
|
+
|
|
190
|
+
With no argument (every real caller), scans ONLY the active account's
|
|
191
|
+
registries via _active_registry_glob_patterns() — see its docstring and
|
|
192
|
+
the module docstring for why. Pass an explicit glob_pattern to scan a
|
|
193
|
+
literal pattern instead (e.g. a test fixture dir); this bypasses account
|
|
194
|
+
scoping entirely, matching the old unconditional behavior."""
|
|
195
|
+
patterns = [glob_pattern] if glob_pattern is not None else _active_registry_glob_patterns()
|
|
139
196
|
newest_epoch, newest_enabled = None, False
|
|
140
197
|
# Track the freshest just-created, enabled, never-yet-fired task so a schedule
|
|
141
198
|
# the user only moments ago set up doesn't read as "missing" before its first
|
|
142
199
|
# fire lands (see CREATED_GRACE).
|
|
143
200
|
newest_fresh_created = None
|
|
144
201
|
any_present, any_enabled = False, False
|
|
145
|
-
for
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
newest_epoch
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
newest_fresh_created
|
|
202
|
+
for pattern in patterns:
|
|
203
|
+
for f in glob.glob(pattern):
|
|
204
|
+
try:
|
|
205
|
+
with open(f) as fh:
|
|
206
|
+
d = json.load(fh)
|
|
207
|
+
except Exception:
|
|
208
|
+
continue
|
|
209
|
+
by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
|
|
210
|
+
recs = _registry_worker_recs(by_id)
|
|
211
|
+
if recs is None:
|
|
212
|
+
continue
|
|
213
|
+
any_present = True
|
|
214
|
+
enabled = all(r.get("enabled") for r in recs)
|
|
215
|
+
any_enabled = any_enabled or enabled
|
|
216
|
+
epochs = [_iso_to_epoch(r.get("lastRunAt")) for r in recs]
|
|
217
|
+
e = max([x for x in epochs if x is not None], default=None)
|
|
218
|
+
if e is not None and (newest_epoch is None or e > newest_epoch):
|
|
219
|
+
newest_epoch, newest_enabled = e, enabled
|
|
220
|
+
# Freshly scheduled + enabled + not yet fired anywhere in this registry.
|
|
221
|
+
if enabled and e is None:
|
|
222
|
+
created = [_ms_to_epoch(r.get("createdAt")) for r in recs]
|
|
223
|
+
c = min([x for x in created if x is not None], default=None)
|
|
224
|
+
if c is not None and (newest_fresh_created is None or c > newest_fresh_created):
|
|
225
|
+
newest_fresh_created = c
|
|
168
226
|
# Firing recently => the live account's schedule is active and healthy.
|
|
169
227
|
if newest_epoch is not None and (time.time() - newest_epoch) <= FIRING_WINDOW:
|
|
170
228
|
return "ok" if newest_enabled else "disabled"
|
|
@@ -172,9 +230,11 @@ def compute(glob_pattern: str = SCHED_REGISTRY_GLOB) -> str:
|
|
|
172
230
|
# Suppresses the false ⚠ right after the user sets up the draft schedule.
|
|
173
231
|
if newest_fresh_created is not None and (time.time() - newest_fresh_created) <= CREATED_GRACE:
|
|
174
232
|
return "ok"
|
|
175
|
-
# Not firing anywhere. Registered-but-disabled =>
|
|
176
|
-
# enabled but the host stopped launching it =>
|
|
177
|
-
# wedge
|
|
233
|
+
# Not firing anywhere for the active account. Registered-but-disabled =>
|
|
234
|
+
# disabled; registered and enabled but the host stopped launching it =>
|
|
235
|
+
# stalled (Desktop scheduler wedge, SAME account); absent for the active
|
|
236
|
+
# account entirely (never scheduled, or orphaned by an account switch) =>
|
|
237
|
+
# missing.
|
|
178
238
|
if any_present and not any_enabled:
|
|
179
239
|
return "disabled"
|
|
180
240
|
if any_present and any_enabled:
|
|
@@ -182,29 +242,32 @@ def compute(glob_pattern: str = SCHED_REGISTRY_GLOB) -> str:
|
|
|
182
242
|
return "missing"
|
|
183
243
|
|
|
184
244
|
|
|
185
|
-
def _detail(glob_pattern: str =
|
|
186
|
-
"""Cheap diagnostics for the JSON output: which registries the glob saw
|
|
187
|
-
which contain both worker tasks, with each one's freshest lastRunAt
|
|
188
|
-
This is what makes a 'missing' verdict debuggable from a log line
|
|
189
|
-
requiring filesystem forensics (the 2026-07-02 rotated-dir bug
|
|
245
|
+
def _detail(glob_pattern: str | None = None) -> dict:
|
|
246
|
+
"""Cheap diagnostics for the JSON output: which registries the glob(s) saw
|
|
247
|
+
and which contain both worker tasks, with each one's freshest lastRunAt
|
|
248
|
+
age. This is what makes a 'missing' verdict debuggable from a log line
|
|
249
|
+
instead of requiring filesystem forensics (the 2026-07-02 rotated-dir bug,
|
|
250
|
+
and the 2026-07-08 wrong-account bug, both hid here)."""
|
|
251
|
+
patterns = [glob_pattern] if glob_pattern is not None else _active_registry_glob_patterns()
|
|
190
252
|
regs = []
|
|
191
|
-
for
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
253
|
+
for pattern in patterns:
|
|
254
|
+
for f in glob.glob(pattern):
|
|
255
|
+
entry = {"path": f, "has_workers": False, "last_run_age_s": None}
|
|
256
|
+
try:
|
|
257
|
+
with open(f) as fh:
|
|
258
|
+
d = json.load(fh)
|
|
259
|
+
by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
|
|
260
|
+
recs = _registry_worker_recs(by_id)
|
|
261
|
+
if recs is not None:
|
|
262
|
+
entry["has_workers"] = True
|
|
263
|
+
epochs = [_iso_to_epoch(r.get("lastRunAt")) for r in recs]
|
|
264
|
+
e = max([x for x in epochs if x is not None], default=None)
|
|
265
|
+
if e is not None:
|
|
266
|
+
entry["last_run_age_s"] = int(time.time() - e)
|
|
267
|
+
except Exception as exc:
|
|
268
|
+
entry["error"] = str(exc)
|
|
269
|
+
regs.append(entry)
|
|
270
|
+
return {"glob": patterns, "registries": regs}
|
|
208
271
|
|
|
209
272
|
|
|
210
273
|
def main() -> int:
|