social-autoposter 1.6.129 → 1.6.131
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 +5 -1
- package/mcp/dist/repo.js +2 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +256 -43
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/autopilot_stall_watch.py +31 -0
- package/scripts/memory_snapshot.py +27 -0
package/mcp/dist/index.js
CHANGED
|
@@ -2045,7 +2045,11 @@ async function autopilotLoaded() {
|
|
|
2045
2045
|
}
|
|
2046
2046
|
let auto_update_on = false;
|
|
2047
2047
|
try {
|
|
2048
|
-
|
|
2048
|
+
// noTee: this status probe dumps the entire launchd job table (hundreds of
|
|
2049
|
+
// lines) and fires on every dashboard/status poll — teeing it flooded Cloud
|
|
2050
|
+
// Logging (~98% of an install's log volume). We only need the substring
|
|
2051
|
+
// check, so keep the output in-memory and out of the relay. (2026-06-28)
|
|
2052
|
+
const res = await run("launchctl", ["list"], { timeoutMs: 10_000, noTee: true });
|
|
2049
2053
|
auto_update_on = res.stdout.split("\n").some((l) => l.includes(UPDATER_LABEL));
|
|
2050
2054
|
}
|
|
2051
2055
|
catch {
|
package/mcp/dist/repo.js
CHANGED
|
@@ -100,7 +100,7 @@ export function run(cmd, args, opts = {}) {
|
|
|
100
100
|
let sinkOutBuf = "";
|
|
101
101
|
let sinkErrBuf = "";
|
|
102
102
|
const sinkPump = (chunk, which, buf) => {
|
|
103
|
-
const sink = lineSink;
|
|
103
|
+
const sink = opts.noTee ? null : lineSink;
|
|
104
104
|
if (!sink)
|
|
105
105
|
return buf;
|
|
106
106
|
buf += chunk;
|
|
@@ -155,7 +155,7 @@ export function run(cmd, args, opts = {}) {
|
|
|
155
155
|
/* ignore */
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
-
const sink = lineSink;
|
|
158
|
+
const sink = opts.noTee ? null : lineSink;
|
|
159
159
|
if (sink) {
|
|
160
160
|
if (sinkOutBuf)
|
|
161
161
|
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.6.
|
|
5
|
+
"version": "1.6.131",
|
|
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": {
|
|
@@ -112,21 +112,12 @@ SETUP_PROMPT = (
|
|
|
112
112
|
)
|
|
113
113
|
DRAFT_PROMPT = "Run a social-autoposter draft cycle and show me the drafts to review."
|
|
114
114
|
UPDATE_PROMPT = "Update social-autoposter to the latest version."
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
# like Setup/Draft do. Targeted: it does NOT redo X/login/project setup.
|
|
122
|
-
REARM_PROMPT = (
|
|
123
|
-
"The social-autoposter draft autopilot stopped running (its scheduled-task "
|
|
124
|
-
"routines are missing under this Claude account). Re-arm it now: call "
|
|
125
|
-
"queue_setup, then for EACH returned task call the host tool "
|
|
126
|
-
"create_scheduled_task with its taskId, cronExpression, and prompt verbatim, "
|
|
127
|
-
"then call run_draft_cycle once to verify a draft card appears. Do not redo my "
|
|
128
|
-
"X connection or project setup."
|
|
129
|
-
)
|
|
115
|
+
# NOTE: re-arm does NOT drive the Claude UI. Pasting a prompt to make the agent
|
|
116
|
+
# call create_scheduled_task proved unreliable (keystroke automation into Electron
|
|
117
|
+
# races focus/timing even with Accessibility granted). Instead, re-arm writes the
|
|
118
|
+
# two worker routines straight into the host's scheduled-tasks.json registry during
|
|
119
|
+
# a Claude restart (the same down-window mechanism the cwd-rewrite already uses) —
|
|
120
|
+
# deterministic, no Accessibility, works headless. See _rearm_work().
|
|
130
121
|
|
|
131
122
|
# A pending draft job older than this (seconds) with nothing claiming it means no
|
|
132
123
|
# routine is draining the queue — the worker would claim within a minute if it
|
|
@@ -255,6 +246,10 @@ class S4LMenuBar(rumps.App):
|
|
|
255
246
|
# Cached stall flag (set each _tick) so the 1s activity poll can suppress a
|
|
256
247
|
# stale "drafting" spinner that would otherwise mask the ⚠ in the title.
|
|
257
248
|
self._stalled = False
|
|
249
|
+
# Cached (kind, detail) explaining a stall so the menu offers the right
|
|
250
|
+
# action: 'orphaned' -> Re-arm, 'rate_limited' -> wait/switch (no Re-arm),
|
|
251
|
+
# 'failing' -> generic. Recomputed each tick while stalled.
|
|
252
|
+
self._stall_reason_info = ("", "")
|
|
258
253
|
self._reloc_timer = rumps.Timer(self._maybe_relocate_tasks, 90)
|
|
259
254
|
self._reloc_timer.start()
|
|
260
255
|
self._tick(None)
|
|
@@ -339,21 +334,161 @@ class S4LMenuBar(rumps.App):
|
|
|
339
334
|
self._send_to_claude(DRAFT_PROMPT)
|
|
340
335
|
|
|
341
336
|
def _rearm(self, _=None):
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
Claude
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
337
|
+
"""One-click recovery: re-create the draft autopilot's scheduled-task
|
|
338
|
+
routines under the CURRENT Claude account, deterministically — no GUI
|
|
339
|
+
keystrokes, no Accessibility. The host caches its registry in memory and
|
|
340
|
+
clobbers live edits, so we must edit while Claude is DOWN; this therefore
|
|
341
|
+
restarts Claude. Heavy work + the restart run off the main thread."""
|
|
342
|
+
self._notify("S4L", "Re-arming autopilot… Claude will restart briefly.")
|
|
343
|
+
threading.Thread(target=self._rearm_work, daemon=True).start()
|
|
344
|
+
|
|
345
|
+
def _rearm_work(self):
|
|
346
|
+
try:
|
|
347
|
+
# Bring Claude down so registry edits stick (it reloads the file fresh
|
|
348
|
+
# at launch; a live edit is clobbered on the next scheduler fire).
|
|
349
|
+
subprocess.run(["osascript", "-e", 'tell application "Claude" to quit'],
|
|
350
|
+
capture_output=True, timeout=20)
|
|
351
|
+
time.sleep(4)
|
|
352
|
+
subprocess.run(["killall", "Claude"], capture_output=True)
|
|
353
|
+
time.sleep(2)
|
|
354
|
+
subprocess.run(["killall", "-9", "Claude"], capture_output=True)
|
|
355
|
+
time.sleep(1)
|
|
356
|
+
n = self._ensure_routines_registered()
|
|
357
|
+
# Keep cwd correct + drop the deprecated task while we're down.
|
|
358
|
+
self._rewrite_scheduled_task_cwd()
|
|
359
|
+
subprocess.run(["open", "-a", CLAUDE_APP], capture_output=True, timeout=20)
|
|
360
|
+
self._sig = None
|
|
361
|
+
if n:
|
|
362
|
+
self._notify(
|
|
363
|
+
"S4L re-armed",
|
|
364
|
+
"Draft autopilot routines restored. Drafts resume within a few minutes.",
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
self._notify(
|
|
368
|
+
"S4L",
|
|
369
|
+
"Couldn't find where to register routines. Try again, or sign back "
|
|
370
|
+
"into the original Claude account.",
|
|
371
|
+
)
|
|
372
|
+
except Exception as e:
|
|
373
|
+
self._notify("S4L re-arm failed", str(e)[:140])
|
|
374
|
+
_capture(e, phase="rearm")
|
|
375
|
+
|
|
376
|
+
# ---- re-arm: write routines straight into the host registry -----------
|
|
377
|
+
def _worker_record_templates(self):
|
|
378
|
+
"""Clone existing host-authored worker records (so the shape is EXACTLY what
|
|
379
|
+
the scheduler accepts). Returns {taskId: record_dict}. Empty if none found
|
|
380
|
+
(e.g. a box that never had them)."""
|
|
381
|
+
out = {}
|
|
382
|
+
try:
|
|
383
|
+
for f in glob.glob(SCHED_REGISTRY_GLOB):
|
|
384
|
+
try:
|
|
385
|
+
with open(f) as fh:
|
|
386
|
+
d = json.load(fh)
|
|
387
|
+
except Exception:
|
|
388
|
+
continue
|
|
389
|
+
for t in d.get("scheduledTasks", []) or []:
|
|
390
|
+
tid = t.get("id")
|
|
391
|
+
if tid in WORKER_TASK_IDS and tid not in out:
|
|
392
|
+
out[tid] = dict(t)
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|
|
395
|
+
return out
|
|
396
|
+
|
|
397
|
+
def _make_worker_record(self, tid, template):
|
|
398
|
+
"""Build a routine record. Prefer cloning a host-authored template (exact
|
|
399
|
+
accepted shape); override the fields that must be right for this account."""
|
|
400
|
+
rec = dict(template) if template else {}
|
|
401
|
+
rec["id"] = tid
|
|
402
|
+
rec["cronExpression"] = "* * * * *"
|
|
403
|
+
rec["enabled"] = True
|
|
404
|
+
rec["filePath"] = os.path.join(
|
|
405
|
+
os.path.expanduser("~"), ".claude", "scheduled-tasks", tid, "SKILL.md"
|
|
356
406
|
)
|
|
407
|
+
rec["cwd"] = WORKER_CWD
|
|
408
|
+
rec.setdefault("createdAt", int(time.time() * 1000))
|
|
409
|
+
# Let the host recompute run-history for this account.
|
|
410
|
+
for k in ("lastRunAt", "lastScheduledFor", "fireAt"):
|
|
411
|
+
rec.pop(k, None)
|
|
412
|
+
return rec
|
|
413
|
+
|
|
414
|
+
def _session_registry_targets(self):
|
|
415
|
+
"""Where to write routines: every existing scheduled-tasks.json (fix in
|
|
416
|
+
place) PLUS the most-recently-active session dir (the live account, which
|
|
417
|
+
may have no registry yet). Covers whichever account is logged in without
|
|
418
|
+
having to know which it is."""
|
|
419
|
+
targets = set(glob.glob(SCHED_REGISTRY_GLOB))
|
|
420
|
+
base = os.path.join(
|
|
421
|
+
os.path.expanduser("~"), "Library", "Application Support", "Claude",
|
|
422
|
+
"claude-code-sessions",
|
|
423
|
+
)
|
|
424
|
+
best, best_m = None, -1.0
|
|
425
|
+
try:
|
|
426
|
+
for ws in os.listdir(base):
|
|
427
|
+
wsp = os.path.join(base, ws)
|
|
428
|
+
if not os.path.isdir(wsp):
|
|
429
|
+
continue
|
|
430
|
+
for inner in os.listdir(wsp):
|
|
431
|
+
ip = os.path.join(wsp, inner)
|
|
432
|
+
if not os.path.isdir(ip):
|
|
433
|
+
continue
|
|
434
|
+
try:
|
|
435
|
+
m = os.path.getmtime(ip)
|
|
436
|
+
except OSError:
|
|
437
|
+
continue
|
|
438
|
+
if m > best_m:
|
|
439
|
+
best_m, best = m, ip
|
|
440
|
+
except Exception:
|
|
441
|
+
pass
|
|
442
|
+
if best:
|
|
443
|
+
targets.add(os.path.join(best, "scheduled-tasks.json"))
|
|
444
|
+
return sorted(targets)
|
|
445
|
+
|
|
446
|
+
def _ensure_routines_registered(self):
|
|
447
|
+
"""Create the two queue-worker routines (cwd=~/.s4l-worker, enabled) in the
|
|
448
|
+
live account's registry — recovering an account-switch orphan WITHOUT the
|
|
449
|
+
Claude UI. Caller MUST run this while Claude is DOWN. Best-effort; never
|
|
450
|
+
raises. Returns the number of registry files written."""
|
|
451
|
+
templates = self._worker_record_templates()
|
|
452
|
+
try:
|
|
453
|
+
os.makedirs(WORKER_CWD, exist_ok=True)
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
written = 0
|
|
457
|
+
for f in self._session_registry_targets():
|
|
458
|
+
try:
|
|
459
|
+
if os.path.exists(f):
|
|
460
|
+
with open(f) as fh:
|
|
461
|
+
d = json.load(fh)
|
|
462
|
+
else:
|
|
463
|
+
d = {"scheduledTasks": [], "recordedSkips": {}}
|
|
464
|
+
except Exception:
|
|
465
|
+
d = {"scheduledTasks": [], "recordedSkips": {}}
|
|
466
|
+
tasks = d.get("scheduledTasks") or []
|
|
467
|
+
by_id = {t.get("id"): t for t in tasks}
|
|
468
|
+
dirty = False
|
|
469
|
+
for tid in WORKER_TASK_IDS:
|
|
470
|
+
if tid in by_id:
|
|
471
|
+
t = by_id[tid]
|
|
472
|
+
if not t.get("enabled") or t.get("cwd") != WORKER_CWD:
|
|
473
|
+
t["enabled"] = True
|
|
474
|
+
t["cwd"] = WORKER_CWD
|
|
475
|
+
dirty = True
|
|
476
|
+
else:
|
|
477
|
+
tasks.append(self._make_worker_record(tid, templates.get(tid)))
|
|
478
|
+
dirty = True
|
|
479
|
+
if not dirty:
|
|
480
|
+
continue
|
|
481
|
+
d["scheduledTasks"] = tasks
|
|
482
|
+
try:
|
|
483
|
+
os.makedirs(os.path.dirname(f), exist_ok=True)
|
|
484
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(f))
|
|
485
|
+
with os.fdopen(fd, "w") as fh:
|
|
486
|
+
json.dump(d, fh, indent=2)
|
|
487
|
+
os.replace(tmp, f)
|
|
488
|
+
written += 1
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
return written
|
|
357
492
|
|
|
358
493
|
# ---- autopilot liveness (the false-green fix) -------------------------
|
|
359
494
|
def _autopilot_stalled(self):
|
|
@@ -398,6 +533,57 @@ class S4LMenuBar(rumps.App):
|
|
|
398
533
|
pass
|
|
399
534
|
return False
|
|
400
535
|
|
|
536
|
+
def _recent_worker_outcome(self, window=600):
|
|
537
|
+
"""Inspect worker transcripts written in the last `window` seconds (the
|
|
538
|
+
~/.s4l-worker bucket). Returns (ran, rate_limit_msg):
|
|
539
|
+
ran — a routine actually EXECUTED recently (a worker that runs
|
|
540
|
+
leaves a transcript; an orphaned/not-firing account leaves
|
|
541
|
+
none). This is what tells "routines fire but fail" apart
|
|
542
|
+
from "routines gone".
|
|
543
|
+
rate_limit_msg— set when a recent run hit the Claude weekly/usage limit
|
|
544
|
+
(re-arm cannot fix that); carries a short 'resets …' string.
|
|
545
|
+
Account-agnostic on purpose: it keys off actual execution, not a per-account
|
|
546
|
+
lastRunAt that freezes (and lies) after an account switch."""
|
|
547
|
+
ran = False
|
|
548
|
+
limit_msg = None
|
|
549
|
+
try:
|
|
550
|
+
now = time.time()
|
|
551
|
+
files = glob.glob(
|
|
552
|
+
os.path.expanduser("~/.claude/projects/*s4l-worker*/*.jsonl")
|
|
553
|
+
)
|
|
554
|
+
recent = [f for f in files if (now - os.path.getmtime(f)) <= window]
|
|
555
|
+
recent.sort(key=os.path.getmtime, reverse=True)
|
|
556
|
+
if recent:
|
|
557
|
+
ran = True
|
|
558
|
+
for f in recent[:5]:
|
|
559
|
+
try:
|
|
560
|
+
txt = open(f).read()
|
|
561
|
+
except Exception:
|
|
562
|
+
continue
|
|
563
|
+
low = txt.lower()
|
|
564
|
+
if "weekly limit" in low or "usage limit" in low or "hit your limit" in low:
|
|
565
|
+
import re
|
|
566
|
+
m = re.search(r"resets [^\"\\]{0,40}", txt)
|
|
567
|
+
limit_msg = m.group(0).strip().rstrip(".") if m else "Claude usage limit reached"
|
|
568
|
+
break
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
return ran, limit_msg
|
|
572
|
+
|
|
573
|
+
def _stall_reason(self):
|
|
574
|
+
"""Why drafts aren't draining, so the menu offers the RIGHT action:
|
|
575
|
+
('orphaned', '') routines aren't firing -> Re-arm fixes it.
|
|
576
|
+
('rate_limited', msg) routines fire but the account hit its Claude
|
|
577
|
+
limit -> Re-arm is useless; wait/switch account.
|
|
578
|
+
('failing', '') routines fire but drafts fail for another reason.
|
|
579
|
+
Only meaningful when _autopilot_stalled() is True."""
|
|
580
|
+
ran, limit_msg = self._recent_worker_outcome()
|
|
581
|
+
if limit_msg:
|
|
582
|
+
return ("rate_limited", limit_msg)
|
|
583
|
+
if not ran:
|
|
584
|
+
return ("orphaned", "")
|
|
585
|
+
return ("failing", "")
|
|
586
|
+
|
|
401
587
|
def _toggle_mode(self, _=None):
|
|
402
588
|
"""Flip personal-brand <-> promotion. Pure local state write (no model,
|
|
403
589
|
no network): the cycle reads mode.json on its next run. Rebuild the menu
|
|
@@ -887,6 +1073,11 @@ class S4LMenuBar(rumps.App):
|
|
|
887
1073
|
# which would otherwise own the title and hide the ⚠. _poll_activity reads
|
|
888
1074
|
# this to drop that stale spinner.
|
|
889
1075
|
self._stalled = stalled
|
|
1076
|
+
# When stalled, work out WHY so the menu (and this notification) offer the
|
|
1077
|
+
# right action — re-arm only helps when routines aren't firing, not when
|
|
1078
|
+
# they fire but hit the Claude usage limit. Only computed while stalled
|
|
1079
|
+
# (it reads worker transcripts), and cached for _build_menu.
|
|
1080
|
+
self._stall_reason_info = self._stall_reason() if stalled else ("", "")
|
|
890
1081
|
|
|
891
1082
|
# Spinner owns the title while busy; _spin already keeps the ⬆ visible there.
|
|
892
1083
|
if not busy:
|
|
@@ -899,14 +1090,27 @@ class S4LMenuBar(rumps.App):
|
|
|
899
1090
|
blocker.get("message", "Setup is blocked"),
|
|
900
1091
|
)
|
|
901
1092
|
self._last_blocker_code = blocker_code
|
|
902
|
-
# Notify once on the transition into a stall, with the
|
|
903
|
-
# it. Re-arms are user-driven (composer), so we don't spam every tick.
|
|
1093
|
+
# Notify once on the transition into a stall, with the cause-specific action.
|
|
904
1094
|
if stalled and not self._stall_notified:
|
|
905
|
-
self.
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1095
|
+
kind, detail = self._stall_reason_info
|
|
1096
|
+
if kind == "rate_limited":
|
|
1097
|
+
self._notify(
|
|
1098
|
+
"S4L autopilot paused",
|
|
1099
|
+
"Claude usage limit reached" + (f" ({detail})" if detail else "")
|
|
1100
|
+
+ ". Drafting resumes when the limit resets, or sign into a "
|
|
1101
|
+
"Claude account with quota.",
|
|
1102
|
+
)
|
|
1103
|
+
elif kind == "orphaned":
|
|
1104
|
+
self._notify(
|
|
1105
|
+
"S4L autopilot not running",
|
|
1106
|
+
"The draft routines aren't running (did Claude's account "
|
|
1107
|
+
"change?). Open the S4L menu and click “Re-arm autopilot”.",
|
|
1108
|
+
)
|
|
1109
|
+
else:
|
|
1110
|
+
self._notify(
|
|
1111
|
+
"S4L autopilot stalled",
|
|
1112
|
+
"Drafts aren't being produced. Open the S4L menu for options.",
|
|
1113
|
+
)
|
|
910
1114
|
self._stall_notified = True
|
|
911
1115
|
elif not stalled:
|
|
912
1116
|
self._stall_notified = False
|
|
@@ -935,6 +1139,7 @@ class S4LMenuBar(rumps.App):
|
|
|
935
1139
|
snap.get("projects_total"),
|
|
936
1140
|
st.read_mode(),
|
|
937
1141
|
stalled,
|
|
1142
|
+
self._stall_reason_info,
|
|
938
1143
|
)
|
|
939
1144
|
if sig != self._sig:
|
|
940
1145
|
self._sig = sig
|
|
@@ -1194,13 +1399,21 @@ class S4LMenuBar(rumps.App):
|
|
|
1194
1399
|
# account switch): surface the one-click recovery FIRST, above the normal
|
|
1195
1400
|
# state items, so it's the obvious next action.
|
|
1196
1401
|
if stalled:
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1402
|
+
kind, detail = self._stall_reason_info
|
|
1403
|
+
if kind == "rate_limited":
|
|
1404
|
+
# Routines ARE firing; the account hit its Claude limit. Re-arm
|
|
1405
|
+
# cannot fix that, so DON'T offer it — show the cause instead.
|
|
1406
|
+
items.append(self._label("⚠ Autopilot paused — Claude usage limit"))
|
|
1407
|
+
if detail:
|
|
1408
|
+
items.append(self._label(f" {detail}"))
|
|
1409
|
+
items.append(self._label(" Resumes at reset, or switch Claude account"))
|
|
1410
|
+
elif kind == "orphaned":
|
|
1411
|
+
# Routines aren't firing -> re-arm re-creates them.
|
|
1412
|
+
items.append(self._label("⚠ Autopilot not running — no drafts"))
|
|
1413
|
+
items.append(rumps.MenuItem("Re-arm autopilot", callback=self._rearm))
|
|
1414
|
+
else: # 'failing' — routines fire but drafts fail for another reason
|
|
1415
|
+
items.append(self._label("⚠ Autopilot stalled — drafts failing"))
|
|
1416
|
+
items.append(rumps.MenuItem("Re-arm autopilot", callback=self._rearm))
|
|
1204
1417
|
items.append(rumps.separator)
|
|
1205
1418
|
|
|
1206
1419
|
if not runtime_ready:
|
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.131",
|
|
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
|
@@ -89,6 +89,32 @@ def _consecutive_timeouts() -> int:
|
|
|
89
89
|
return 0
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def _recent_rate_limit(window: int = 1200) -> bool:
|
|
93
|
+
"""True if a worker run in the last `window` seconds hit the Claude weekly/usage
|
|
94
|
+
limit. That stall is EXPECTED and auto-resets, so it must NOT page Sentry —
|
|
95
|
+
paging would be pure noise. Reads the ~/.s4l-worker transcript bucket."""
|
|
96
|
+
try:
|
|
97
|
+
now = time.time()
|
|
98
|
+
files = glob.glob(
|
|
99
|
+
os.path.expanduser("~/.claude/projects/*s4l-worker*/*.jsonl")
|
|
100
|
+
)
|
|
101
|
+
recent = sorted(
|
|
102
|
+
(f for f in files if (now - os.path.getmtime(f)) <= window),
|
|
103
|
+
key=os.path.getmtime,
|
|
104
|
+
reverse=True,
|
|
105
|
+
)[:5]
|
|
106
|
+
for f in recent:
|
|
107
|
+
try:
|
|
108
|
+
low = open(f).read().lower()
|
|
109
|
+
except Exception:
|
|
110
|
+
continue
|
|
111
|
+
if "weekly limit" in low or "usage limit" in low or "hit your limit" in low:
|
|
112
|
+
return True
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
92
118
|
def _oldest_pending_age() -> float | None:
|
|
93
119
|
"""Seconds since the oldest unclaimed pending draft job was written, or None
|
|
94
120
|
if nothing is pending (idle queue). The FAST signal: catches a fresh stall
|
|
@@ -149,6 +175,11 @@ def main() -> int:
|
|
|
149
175
|
stalled = _autopilot_configured() and (
|
|
150
176
|
timeouts >= 1 or (age is not None and age > STALL_SECONDS)
|
|
151
177
|
)
|
|
178
|
+
# A rate-limit stall is expected and self-heals at the quota reset — never page
|
|
179
|
+
# for it (and re-arm can't fix it). Treat it as "not an actionable stall" so the
|
|
180
|
+
# episode resets and a LATER real stall (orphaned routines) still alerts.
|
|
181
|
+
if stalled and _recent_rate_limit():
|
|
182
|
+
stalled = False
|
|
152
183
|
|
|
153
184
|
st = _read_state()
|
|
154
185
|
consecutive = int(st.get("consecutive", 0))
|
|
@@ -424,6 +424,21 @@ def claude_queue_summary() -> dict[str, Any]:
|
|
|
424
424
|
}
|
|
425
425
|
except OSError:
|
|
426
426
|
pass
|
|
427
|
+
# The producer's drain latch: consecutive_timeouts>=1 means the scheduled-task
|
|
428
|
+
# worker stopped draining (the definitive phase2b-stall signal). Surfacing it
|
|
429
|
+
# here lets the heartbeat carry it server-side, so a stall is visible centrally
|
|
430
|
+
# without SSHing the box. See claude_job.py _bump_drain_timeout / _clear_drain.
|
|
431
|
+
drain_path = root / "drain-status.json"
|
|
432
|
+
if drain_path.exists():
|
|
433
|
+
try:
|
|
434
|
+
ds = json.loads(drain_path.read_text())
|
|
435
|
+
summary["drain_status"] = {
|
|
436
|
+
"consecutive_timeouts": int(ds.get("consecutive_timeouts", 0) or 0),
|
|
437
|
+
"last_success_at": ds.get("last_success_at"),
|
|
438
|
+
"last_timeout_at": ds.get("last_timeout_at"),
|
|
439
|
+
}
|
|
440
|
+
except (OSError, ValueError, TypeError):
|
|
441
|
+
pass
|
|
427
442
|
return summary
|
|
428
443
|
|
|
429
444
|
|
|
@@ -603,6 +618,14 @@ def build_summary() -> dict[str, Any]:
|
|
|
603
618
|
else None
|
|
604
619
|
)
|
|
605
620
|
cq = claude_queue_summary()
|
|
621
|
+
ds = cq.get("drain_status") or {}
|
|
622
|
+
oldest = cq.get("oldest_age_sec")
|
|
623
|
+
consec = int(ds.get("consecutive_timeouts", 0) or 0)
|
|
624
|
+
# Mirror the MCP's autopilotStalled(): a latched producer timeout, OR a draft
|
|
625
|
+
# job that has sat unclaimed past 180s, means no scheduled-task worker is
|
|
626
|
+
# draining the queue. Carrying this on the heartbeat makes a phase2b stall
|
|
627
|
+
# visible in installation_resource_samples without SSHing the box.
|
|
628
|
+
stalled = bool(consec >= 1 or (isinstance(oldest, (int, float)) and oldest > 180))
|
|
606
629
|
return {
|
|
607
630
|
"ts": dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="seconds"),
|
|
608
631
|
"hostname": socket.gethostname(),
|
|
@@ -621,6 +644,10 @@ def build_summary() -> dict[str, Any]:
|
|
|
621
644
|
"claude_queue": {
|
|
622
645
|
"pending": cq.get("pending_total", 0),
|
|
623
646
|
"running": cq.get("running_total", 0),
|
|
647
|
+
"oldest_age_sec": oldest,
|
|
648
|
+
"stalled": stalled,
|
|
649
|
+
"consecutive_timeouts": consec,
|
|
650
|
+
"last_success_at": ds.get("last_success_at"),
|
|
624
651
|
},
|
|
625
652
|
}
|
|
626
653
|
|