social-autoposter 1.6.182 → 1.6.185
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/dist/index.js +11 -2
- package/mcp/dist/runtime.js +42 -4
- package/mcp/dist/telemetry.js +18 -5
- package/mcp/dist/twitterAuth.js +18 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +111 -13
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/heartbeat.sh +12 -1
- package/scripts/reap_stale_claude_sessions.py +11 -0
- package/scripts/release-mcpb.sh +60 -22
- package/scripts/scheduled_tasks_snapshot.py +120 -0
package/mcp/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import fs from "node:fs";
|
|
|
22
22
|
import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
|
|
23
23
|
import { applySetup, resolveProject, personaReady, listManagedProjectStatus, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, normalizeStringList, } from "./setup.js";
|
|
24
24
|
import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
|
|
25
|
-
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, ensurePipelineCurrent, ensureRuntimeProvisioned, } from "./runtime.js";
|
|
25
|
+
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, } from "./runtime.js";
|
|
26
26
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
27
27
|
import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
|
|
28
28
|
import { initSentry, sendHeartbeat, captureError, flushSentry, startLogStreaming, flushLogs } from "./telemetry.js";
|
|
@@ -2101,10 +2101,14 @@ tool("restart_menubar", {
|
|
|
2101
2101
|
title: "Restart the S4L menu bar app",
|
|
2102
2102
|
description: "Relaunch the always-on S4L menu bar (tray) app after it was quit. Re-loads its " +
|
|
2103
2103
|
"LaunchAgent (installing the menu bar first if needed). Use when the dashboard reports the menu " +
|
|
2104
|
-
"bar is not running, or the user asks to
|
|
2104
|
+
"bar is not running, or the user asks to start S4L, restart S4L, or bring the S4L tray icon " +
|
|
2105
|
+
"back. Does NOT touch the draft " +
|
|
2105
2106
|
"schedule, X connection, or any posting — it only restarts the tray UI.",
|
|
2106
2107
|
inputSchema: {},
|
|
2107
2108
|
}, async () => {
|
|
2109
|
+
// Explicit user intent to start: lift the stop sentinel a tray Quit wrote,
|
|
2110
|
+
// otherwise ensureMenubar() would no-op forever.
|
|
2111
|
+
clearMenubarStop();
|
|
2108
2112
|
const res = await ensureMenubar();
|
|
2109
2113
|
const running = await menubarRunning();
|
|
2110
2114
|
return jsonContent({
|
|
@@ -2145,6 +2149,11 @@ tool("queue_setup", {
|
|
|
2145
2149
|
inputSchema: {},
|
|
2146
2150
|
}, async () => {
|
|
2147
2151
|
ensureQueueWorkerToolsAllowed();
|
|
2152
|
+
// Re-arming the autopilot is an explicit "start S4L" action: lift a prior
|
|
2153
|
+
// tray Quit so the review cards have a surface again. Best-effort and
|
|
2154
|
+
// async — task specs must return regardless of tray state.
|
|
2155
|
+
clearMenubarStop();
|
|
2156
|
+
void ensureMenubar();
|
|
2148
2157
|
// Write each worker's canonical SKILL.md to disk NOW, before the agent calls
|
|
2149
2158
|
// create_scheduled_task. The host's create_scheduled_task can report a task
|
|
2150
2159
|
// "already exists" (e.g. a stale Routines registration left after a reset) and
|
package/mcp/dist/runtime.js
CHANGED
|
@@ -84,6 +84,29 @@ const MENUBAR_DIR = path.join(STATE_DIR, "menubar");
|
|
|
84
84
|
const MENUBAR_ENTRY = path.join(MENUBAR_DIR, "s4l_menubar.py");
|
|
85
85
|
const MENUBAR_OUT_LOG = path.join(MENUBAR_DIR, "menubar.out.log");
|
|
86
86
|
const MENUBAR_ERR_LOG = path.join(MENUBAR_DIR, "menubar.err.log");
|
|
87
|
+
// Stop sentinel: the menu bar's Quit flow writes this file (and boots itself
|
|
88
|
+
// out) to record that the USER explicitly stopped S4L. Every auto-start path
|
|
89
|
+
// (boot-time ensureMenubar, runtime provision) must respect it, otherwise the
|
|
90
|
+
// tray is guaranteed back on the next Claude restart — the exact bug users hit
|
|
91
|
+
// after Quit. Only an explicit start action (restart_menubar tool, queue_setup
|
|
92
|
+
// re-arm) clears it. KEEP the filename in sync with menubar/s4l_menubar.py.
|
|
93
|
+
const MENUBAR_STOP_FLAG = path.join(STATE_DIR, "stopped.flag");
|
|
94
|
+
export function menubarStopped() {
|
|
95
|
+
try {
|
|
96
|
+
return fs.existsSync(MENUBAR_STOP_FLAG);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function clearMenubarStop() {
|
|
103
|
+
try {
|
|
104
|
+
fs.rmSync(MENUBAR_STOP_FLAG, { force: true });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* best-effort */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
87
110
|
// A directory is a usable pipeline clone only if it carries requirements.txt
|
|
88
111
|
// (the deps manifest) AND scripts/ (the pipeline). Guards against pointing at an
|
|
89
112
|
// empty extension dir or a half-deleted state dir.
|
|
@@ -656,7 +679,15 @@ async function provision(progress) {
|
|
|
656
679
|
// Non-fatal: a menu bar failure must never block a usable runtime, so on any
|
|
657
680
|
// problem we mark the step errored and still persist runtime.json below.
|
|
658
681
|
setStep("menubar", "running");
|
|
659
|
-
if (process.platform
|
|
682
|
+
if (process.platform !== "darwin") {
|
|
683
|
+
setStep("menubar", "done", "skipped (macOS only)");
|
|
684
|
+
}
|
|
685
|
+
else if (menubarStopped()) {
|
|
686
|
+
// A runtime repair/re-provision must not resurrect a tray the user
|
|
687
|
+
// explicitly quit; an explicit start clears the flag first.
|
|
688
|
+
setStep("menubar", "done", "skipped (user stopped the menu bar)");
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
660
691
|
const mb = await installMenubar(uv, uvEnv, VENV_PYTHON);
|
|
661
692
|
setStep("menubar", mb.ok ? "done" : "error", mb.detail);
|
|
662
693
|
// Non-fatal step, so the only prior signal of a menu bar install failure was
|
|
@@ -669,9 +700,6 @@ async function provision(progress) {
|
|
|
669
700
|
});
|
|
670
701
|
}
|
|
671
702
|
}
|
|
672
|
-
else {
|
|
673
|
-
setStep("menubar", "done", "skipped (macOS only)");
|
|
674
|
-
}
|
|
675
703
|
// --- Persist the result ---------------------------------------------------
|
|
676
704
|
const info = {
|
|
677
705
|
python: VENV_PYTHON,
|
|
@@ -790,6 +818,10 @@ export async function menubarRunning() {
|
|
|
790
818
|
return true;
|
|
791
819
|
if (!runtimeReady())
|
|
792
820
|
return true;
|
|
821
|
+
// User explicitly quit the tray: it is down ON PURPOSE, so report "fine" —
|
|
822
|
+
// the dashboard banner must not nag about a state the user chose.
|
|
823
|
+
if (menubarStopped())
|
|
824
|
+
return true;
|
|
793
825
|
try {
|
|
794
826
|
return await menubarLoaded();
|
|
795
827
|
}
|
|
@@ -846,6 +878,12 @@ export async function installMenubar(uv, uvEnv, python) {
|
|
|
846
878
|
export async function ensureMenubar() {
|
|
847
879
|
if (process.platform !== "darwin")
|
|
848
880
|
return { ok: true, skipped: true, detail: "non-macOS" };
|
|
881
|
+
// The user clicked Quit in the tray: stay stopped across Claude restarts,
|
|
882
|
+
// regardless of runtime state. Explicit start paths (restart_menubar tool,
|
|
883
|
+
// queue_setup) clear the flag before calling this.
|
|
884
|
+
if (menubarStopped()) {
|
|
885
|
+
return { ok: true, skipped: true, detail: "user stopped the menu bar (stopped.flag)" };
|
|
886
|
+
}
|
|
849
887
|
if (!runtimeReady())
|
|
850
888
|
return { ok: false, skipped: true, detail: "runtime not ready" };
|
|
851
889
|
if (fs.existsSync(MENUBAR_ENTRY) &&
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -92,17 +92,30 @@ export async function sendHeartbeat(reason) {
|
|
|
92
92
|
// session pile-up that can balloon RAM to tens of GB) is visible centrally
|
|
93
93
|
// without us SSHing in. Best-effort: any failure falls back to "{}" so the
|
|
94
94
|
// heartbeat itself never depends on the sampler succeeding.
|
|
95
|
-
|
|
95
|
+
const bodyObj = {};
|
|
96
96
|
try {
|
|
97
97
|
const mem = await runPython("scripts/memory_snapshot.py", ["--summary"], { timeoutMs: 12_000 });
|
|
98
98
|
const out = (mem.stdout || "").trim();
|
|
99
|
-
if (mem.code === 0 && out)
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
if (mem.code === 0 && out)
|
|
100
|
+
bodyObj.resource = JSON.parse(out);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* omit resource */
|
|
104
|
+
}
|
|
105
|
+
// Also attach the S4L autopilot scheduled-task folder state so the server can
|
|
106
|
+
// tell, per install, whether the queue-worker tasks relocated to ~/.s4l-worker
|
|
107
|
+
// or are still mislocated (the menubar cwd-rewrite self-heal used to fire
|
|
108
|
+
// silently — no fleet-wide signal). Best-effort; independent of resource.
|
|
109
|
+
try {
|
|
110
|
+
const st = await runPython("scripts/scheduled_tasks_snapshot.py", ["--summary"], { timeoutMs: 10_000 });
|
|
111
|
+
const out = (st.stdout || "").trim();
|
|
112
|
+
if (st.code === 0 && out)
|
|
113
|
+
bodyObj.scheduled_tasks = JSON.parse(out);
|
|
102
114
|
}
|
|
103
115
|
catch {
|
|
104
|
-
/*
|
|
116
|
+
/* omit scheduled_tasks */
|
|
105
117
|
}
|
|
118
|
+
const body = Object.keys(bodyObj).length ? JSON.stringify(bodyObj) : "{}";
|
|
106
119
|
const resp = await fetch(`${base}/api/v1/installations/heartbeat`, {
|
|
107
120
|
method: "POST",
|
|
108
121
|
headers: { "X-Installation": header, "content-type": "application/json" },
|
package/mcp/dist/twitterAuth.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// (restore_twitter_session.py for CDP login-check, ai_browser_profile.cookies
|
|
11
11
|
// for Keychain-decrypt + CDP inject). Reusing them keeps this MCP a thin client.
|
|
12
12
|
import { runPython } from "./repo.js";
|
|
13
|
+
import { captureError } from "./telemetry.js";
|
|
13
14
|
function parse(stdout, stderr, code) {
|
|
14
15
|
try {
|
|
15
16
|
return JSON.parse(stdout.trim().split("\n").slice(-50).join("\n"));
|
|
@@ -64,7 +65,15 @@ export async function xScanProfile(opts) {
|
|
|
64
65
|
try {
|
|
65
66
|
return JSON.parse(res.stdout.trim().split("\n").slice(-1).join("\n"));
|
|
66
67
|
}
|
|
67
|
-
catch {
|
|
68
|
+
catch (e) {
|
|
69
|
+
// The X profile scan feeds handle detection + grounding for the draft lane; a
|
|
70
|
+
// silent no-JSON failure here means we scrape the wrong handle (or none) and
|
|
71
|
+
// never know. Surface it so we can see fleet-wide how often the scan breaks.
|
|
72
|
+
captureError(e, {
|
|
73
|
+
component: "twitter_auth",
|
|
74
|
+
phase: "scan_x_profile",
|
|
75
|
+
exit: String(res.code),
|
|
76
|
+
});
|
|
68
77
|
return {
|
|
69
78
|
ok: false,
|
|
70
79
|
state: "error",
|
|
@@ -83,7 +92,14 @@ export async function xDetectSources() {
|
|
|
83
92
|
try {
|
|
84
93
|
return JSON.parse(res.stdout.trim().split("\n").slice(-200).join("\n"));
|
|
85
94
|
}
|
|
86
|
-
catch {
|
|
95
|
+
catch (e) {
|
|
96
|
+
// detect-sources populates the panel's "import from" dropdown; a no-JSON failure
|
|
97
|
+
// leaves the user unable to connect X during setup with no server-side trace.
|
|
98
|
+
captureError(e, {
|
|
99
|
+
component: "twitter_auth",
|
|
100
|
+
phase: "detect_sources",
|
|
101
|
+
exit: String(res.code),
|
|
102
|
+
});
|
|
87
103
|
return {
|
|
88
104
|
ok: false,
|
|
89
105
|
sources: [],
|
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.185",
|
|
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": {
|
|
@@ -60,6 +60,20 @@ def _capture(err, **tags):
|
|
|
60
60
|
pass
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def _capture_msg(message, level="warning", **tags):
|
|
64
|
+
"""Report a handled menu-bar CONDITION (not an exception) to Sentry so we get
|
|
65
|
+
fleet-wide signal on operational states like an orphaned/disabled/rate-limited
|
|
66
|
+
draft schedule. capture_exception only covers thrown errors; this covers the
|
|
67
|
+
"nothing crashed but the autopilot isn't running" case. No-op if the Sentry
|
|
68
|
+
bootstrap failed."""
|
|
69
|
+
try:
|
|
70
|
+
if _sentry is not None:
|
|
71
|
+
tags.setdefault("component", "menubar")
|
|
72
|
+
_sentry.capture_message(message, level=level, tags=tags)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
63
77
|
def _flush():
|
|
64
78
|
try:
|
|
65
79
|
if _sentry is not None:
|
|
@@ -104,6 +118,19 @@ def _activate_front():
|
|
|
104
118
|
CLAUDE_APP = "Claude"
|
|
105
119
|
POLL_SECONDS = 5
|
|
106
120
|
|
|
121
|
+
# Our own LaunchAgent. Quit boots it out and deletes the plist so the tray is
|
|
122
|
+
# genuinely gone (KeepAlive can't respawn it, RunAtLoad can't resurrect it at
|
|
123
|
+
# next login). Keep the label in sync with MENUBAR_LABEL in mcp/src/runtime.ts.
|
|
124
|
+
MENUBAR_LABEL = "com.m13v.social-autoposter.menubar"
|
|
125
|
+
MENUBAR_PLIST = os.path.join(
|
|
126
|
+
os.path.expanduser("~"), "Library", "LaunchAgents", f"{MENUBAR_LABEL}.plist"
|
|
127
|
+
)
|
|
128
|
+
# Stop sentinel read by the MCP server's ensureMenubar()/provision paths: while
|
|
129
|
+
# present, no auto-start path may reinstall the tray. Cleared only by explicit
|
|
130
|
+
# start actions (restart_menubar tool, queue_setup re-arm). Keep the filename in
|
|
131
|
+
# sync with MENUBAR_STOP_FLAG in mcp/src/runtime.ts.
|
|
132
|
+
STOP_FLAG = os.path.join(st.state_dir(), "stopped.flag")
|
|
133
|
+
|
|
107
134
|
# Autopilot scheduled tasks. The two queue workers must RUN in a dedicated folder
|
|
108
135
|
# (~/.s4l-worker) so their once-a-minute sessions don't flood the user's
|
|
109
136
|
# interactive Claude Code history (Claude buckets sessions by cwd). The single
|
|
@@ -690,22 +717,26 @@ class S4LMenuBar(rumps.App):
|
|
|
690
717
|
|
|
691
718
|
def _quit_app(self, _=None):
|
|
692
719
|
"""The single Quit path. Quitting stops the autopilot completely: the
|
|
693
|
-
draft/query scheduled tasks are removed so they no longer fire
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
720
|
+
draft/query scheduled tasks are removed so they no longer fire, AND the
|
|
721
|
+
tray itself goes away for good (stop flag + plist removal + self
|
|
722
|
+
bootout — see _quit_work). Claude Desktop OWNS the live schedule and
|
|
723
|
+
caches the registry in memory, clobbering any live edit on the next
|
|
724
|
+
fire — so the only reliable way to disable them is to quit Claude,
|
|
725
|
+
strip the tasks while it's down, then relaunch. We warn the user with a
|
|
726
|
+
modal FIRST that Claude Desktop will restart, since the app window will
|
|
727
|
+
close and reopen under them."""
|
|
699
728
|
_activate_front()
|
|
700
729
|
choice = rumps.alert(
|
|
701
730
|
title="Quit the S4L autoposter?",
|
|
702
731
|
message=(
|
|
703
732
|
"Quitting stops the autoposter completely: the draft + query "
|
|
704
|
-
"scheduled tasks are removed so nothing fires anymore
|
|
733
|
+
"scheduled tasks are removed so nothing fires anymore, and this "
|
|
734
|
+
"menu bar icon goes away and stays away.\n\n"
|
|
705
735
|
"Claude Desktop will quit and restart to apply this — its window "
|
|
706
736
|
"will close and reopen in a moment. Your X login, browser layer, "
|
|
707
|
-
"and config all stay
|
|
708
|
-
"
|
|
737
|
+
"and config all stay.\n\n"
|
|
738
|
+
"To start S4L again later, open Claude and say \"start S4L\" "
|
|
739
|
+
"(or re-run setup)."
|
|
709
740
|
),
|
|
710
741
|
ok="Quit & restart Claude", cancel="Cancel",
|
|
711
742
|
)
|
|
@@ -754,10 +785,23 @@ class S4LMenuBar(rumps.App):
|
|
|
754
785
|
pass
|
|
755
786
|
|
|
756
787
|
def _quit_work(self):
|
|
757
|
-
"""Quit/kill Claude, strip the scheduled tasks while it's down, relaunch
|
|
758
|
-
|
|
759
|
-
|
|
788
|
+
"""Quit/kill Claude, strip the scheduled tasks while it's down, relaunch
|
|
789
|
+
Claude, then take THIS tray down for good. Mirror of
|
|
790
|
+
_relocate_restart_work's restart block. The menu bar is a separate
|
|
791
|
+
launchd process, so killing Claude does not kill us.
|
|
792
|
+
|
|
793
|
+
The stop flag is written FIRST: the relaunched Claude boots the MCP
|
|
794
|
+
server, whose ensureMenubar() would otherwise reinstall the tray
|
|
795
|
+
unconditionally (the reappearing-icon bug). The plist is deleted so
|
|
796
|
+
RunAtLoad can't resurrect us at next login, and the final bootout
|
|
797
|
+
removes the KeepAlive job — which kills this process, so it must be
|
|
798
|
+
the last thing we do."""
|
|
760
799
|
try:
|
|
800
|
+
try:
|
|
801
|
+
with open(STOP_FLAG, "w") as fh:
|
|
802
|
+
fh.write(f"user quit via menu bar at {time.strftime('%Y-%m-%dT%H:%M:%S%z')}\n")
|
|
803
|
+
except Exception as e:
|
|
804
|
+
_capture(e, action="quit_stop_flag")
|
|
761
805
|
subprocess.run(["osascript", "-e", 'tell application "Claude" to quit'],
|
|
762
806
|
capture_output=True, timeout=20)
|
|
763
807
|
time.sleep(4)
|
|
@@ -766,11 +810,30 @@ class S4LMenuBar(rumps.App):
|
|
|
766
810
|
subprocess.run(["killall", "-9", "Claude"], capture_output=True)
|
|
767
811
|
time.sleep(1)
|
|
768
812
|
self._remove_scheduled_tasks()
|
|
813
|
+
try:
|
|
814
|
+
os.remove(MENUBAR_PLIST)
|
|
815
|
+
except FileNotFoundError:
|
|
816
|
+
pass
|
|
817
|
+
except Exception as e:
|
|
818
|
+
_capture(e, action="quit_remove_plist")
|
|
769
819
|
subprocess.run(["open", "-a", CLAUDE_APP], capture_output=True, timeout=20)
|
|
770
|
-
self._notify("S4L", "Autoposter stopped. Claude
|
|
820
|
+
self._notify("S4L", "Autoposter stopped. Say \"start S4L\" in Claude to bring it back.")
|
|
771
821
|
except Exception as e:
|
|
772
822
|
_capture(e, action="quit_app")
|
|
773
823
|
self._notify("S4L", "Couldn't fully stop the autoposter — see logs.")
|
|
824
|
+
finally:
|
|
825
|
+
# Boot out our own KeepAlive agent. launchd kills this process as
|
|
826
|
+
# part of the bootout, so nothing after this line is guaranteed to
|
|
827
|
+
# run. Runs even if the Claude restart above failed: the user asked
|
|
828
|
+
# for the tray to be gone.
|
|
829
|
+
subprocess.run(
|
|
830
|
+
["launchctl", "bootout", f"gui/{os.getuid()}/{MENUBAR_LABEL}"],
|
|
831
|
+
capture_output=True, timeout=15,
|
|
832
|
+
)
|
|
833
|
+
# Only reached if bootout didn't kill us (e.g. dev run outside
|
|
834
|
+
# launchd). Exit 0: KeepAlive {SuccessfulExit: false} treats a clean
|
|
835
|
+
# exit as final. os._exit because we're on a background thread.
|
|
836
|
+
os._exit(0)
|
|
774
837
|
|
|
775
838
|
def _update(self, _=None):
|
|
776
839
|
self._send_to_claude(UPDATE_PROMPT)
|
|
@@ -1012,11 +1075,30 @@ class S4LMenuBar(rumps.App):
|
|
|
1012
1075
|
if not self._scheduled_task_cwd_needs_fix():
|
|
1013
1076
|
self._cwd_healed = True
|
|
1014
1077
|
self._reloc_needed = False
|
|
1078
|
+
# Push a fresh heartbeat now so the server/dashboard reflects the
|
|
1079
|
+
# corrected scheduled-task folder state within seconds instead of
|
|
1080
|
+
# waiting up to ~15 min for the next MCP heartbeat. Best-effort.
|
|
1081
|
+
self._fire_heartbeat()
|
|
1015
1082
|
except Exception:
|
|
1016
1083
|
pass
|
|
1017
1084
|
finally:
|
|
1018
1085
|
self._relocating = False
|
|
1019
1086
|
|
|
1087
|
+
def _fire_heartbeat(self):
|
|
1088
|
+
"""Best-effort: run the npx-lane heartbeat.sh once so the install's
|
|
1089
|
+
scheduled_tasks sample updates centrally right after a relocation. Never
|
|
1090
|
+
raises; a missing repo/script or network hiccup is silently ignored (the
|
|
1091
|
+
MCP's own ~15-min heartbeat is the durable channel)."""
|
|
1092
|
+
try:
|
|
1093
|
+
repo = os.environ.get("SAPS_REPO_DIR") or ""
|
|
1094
|
+
hb = os.path.join(repo, "scripts", "heartbeat.sh")
|
|
1095
|
+
if not (repo and os.path.exists(hb)):
|
|
1096
|
+
return
|
|
1097
|
+
env = dict(os.environ, REPO_DIR=repo)
|
|
1098
|
+
subprocess.run(["bash", hb], capture_output=True, timeout=30, env=env)
|
|
1099
|
+
except Exception:
|
|
1100
|
+
pass
|
|
1101
|
+
|
|
1020
1102
|
def _open_dashboard(self, _=None):
|
|
1021
1103
|
# Prefer the LIVE MCP loopback panel (full interactivity — its buttons
|
|
1022
1104
|
# reach the MCP tool handlers) when Claude is up. When it's NOT, fall back
|
|
@@ -1311,6 +1393,22 @@ class S4LMenuBar(rumps.App):
|
|
|
1311
1393
|
self._last_blocker_code = blocker_code
|
|
1312
1394
|
# Notify once per episode (the draft schedule isn't running for this account).
|
|
1313
1395
|
if attention and not self._stall_notified:
|
|
1396
|
+
# Fleet-wide telemetry: the draft autopilot needs attention on THIS
|
|
1397
|
+
# install (orphaned by an account switch, disabled, rate-limited, or a
|
|
1398
|
+
# stuck worker). Only channel that surfaces "customer's autopilot silently
|
|
1399
|
+
# stopped drafting" to us; the cycle log lives only on their machine.
|
|
1400
|
+
# Once per episode (gated by _stall_notified), so it never spams.
|
|
1401
|
+
_reason = (
|
|
1402
|
+
self._stall_reason_info[0]
|
|
1403
|
+
or ("disabled" if schedule_state == "disabled" else "missing")
|
|
1404
|
+
)
|
|
1405
|
+
_capture_msg(
|
|
1406
|
+
f"S4L draft autopilot needs attention: {_reason}",
|
|
1407
|
+
level="warning",
|
|
1408
|
+
phase="draft_schedule",
|
|
1409
|
+
reason=_reason,
|
|
1410
|
+
schedule_state=str(schedule_state),
|
|
1411
|
+
)
|
|
1314
1412
|
if self._stall_reason_info[0] == "rate_limited":
|
|
1315
1413
|
self._notify(
|
|
1316
1414
|
"S4L Claude rate-limited",
|
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.185",
|
|
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/heartbeat.sh
CHANGED
|
@@ -28,12 +28,23 @@ HDR=$("$PYTHON_BIN" "$REPO_DIR/scripts/identity.py" header 2>>"$LOG_FILE") || {
|
|
|
28
28
|
exit 1
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
# Attach the S4L autopilot scheduled-task folder state (parity with the .mcpb
|
|
32
|
+
# heartbeat) so the server can tell centrally whether the queue-worker tasks are
|
|
33
|
+
# running from ~/.s4l-worker or are still mislocated. Best-effort: any failure
|
|
34
|
+
# falls back to an empty body so the heartbeat itself never depends on it.
|
|
35
|
+
BODY='{}'
|
|
36
|
+
if ST=$("$PYTHON_BIN" "$REPO_DIR/scripts/scheduled_tasks_snapshot.py" --summary 2>>"$LOG_FILE"); then
|
|
37
|
+
if [ -n "$ST" ]; then
|
|
38
|
+
BODY="{\"scheduled_tasks\":$ST}"
|
|
39
|
+
fi
|
|
40
|
+
fi
|
|
41
|
+
|
|
31
42
|
# POST so the server can refresh the volatile fields (last_ip, last_seen_at).
|
|
32
43
|
RESP=$(curl -fsS -m 20 \
|
|
33
44
|
-X POST \
|
|
34
45
|
-H "X-Installation: $HDR" \
|
|
35
46
|
-H "content-type: application/json" \
|
|
36
|
-
-d
|
|
47
|
+
-d "$BODY" \
|
|
37
48
|
-w "\n__HTTP__%{http_code}__%{time_total}s" \
|
|
38
49
|
"$BASE_URL/api/v1/installations/heartbeat" 2>>"$LOG_FILE") || {
|
|
39
50
|
log "FAIL curl exit=$?"
|
|
@@ -594,4 +594,15 @@ if __name__ == "__main__":
|
|
|
594
594
|
sys.exit(main())
|
|
595
595
|
except Exception as e: # never let the reaper itself crash the launchd job loudly
|
|
596
596
|
print(f"[claude-reaper] error: {e}", file=sys.stderr)
|
|
597
|
+
# If the reaper itself dies, the queue-worker session leak resumes silently
|
|
598
|
+
# and the box climbs back toward OOM with no signal. This is the only channel
|
|
599
|
+
# that surfaces a dead reaper to us. The reaper doesn't import http_api, so
|
|
600
|
+
# Sentry was never init()'d; do it here. Best-effort, never re-raise.
|
|
601
|
+
try:
|
|
602
|
+
import sentry_init
|
|
603
|
+
sentry_init.init()
|
|
604
|
+
sentry_init.capture_exception(e, tags={"component": "claude_reaper"})
|
|
605
|
+
sentry_init.flush(2.0)
|
|
606
|
+
except Exception:
|
|
607
|
+
pass
|
|
597
608
|
sys.exit(0)
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -7,14 +7,19 @@
|
|
|
7
7
|
# 1. Bump the repo-root package.json (the SINGLE source of truth) and lockfile.
|
|
8
8
|
# Default: patch bump. --bump minor|major, or pin with --version / --tag,
|
|
9
9
|
# or --no-bump to re-release the current version as-is.
|
|
10
|
-
# 2.
|
|
11
|
-
# embedded pipeline.tgz
|
|
12
|
-
#
|
|
13
|
-
#
|
|
10
|
+
# 2. Stamp EVERY version satellite from that one source, THEN build, so the
|
|
11
|
+
# embedded pipeline.tgz (an `npm pack` of the repo) captures an all-current
|
|
12
|
+
# mcp/ subtree. Order is load-bearing: satellites (manifest.json,
|
|
13
|
+
# mcp/package.json+lock, dist/version.json) are stamped BEFORE the pack, not
|
|
14
|
+
# after, or the tarball ships a stale mcp/ subtree (the 1.6.181-menu bug).
|
|
15
|
+
# Sub-steps: 2a stamp source satellites -> 2b build panel+server -> 2c stamp
|
|
16
|
+
# dist/version.json -> 2d pack pipeline.tgz.
|
|
17
|
+
# 3. (Regenerate manifest.json `tools` from the server's registrations.)
|
|
14
18
|
# 4. Pack mcp/ into mcp/social-autoposter.mcpb via the mcpb CLI.
|
|
15
19
|
# 5. Verify: size cap, embedded pipeline.tgz present, version.json + manifest +
|
|
16
|
-
# the pipeline.tgz's OWN internal version
|
|
17
|
-
#
|
|
20
|
+
# the pipeline.tgz's OWN internal version AND its mcp/ subtree (dist/version.json,
|
|
21
|
+
# mcp/package.json) all == VERSION (guards the 1.6.84 stale-pipeline and the
|
|
22
|
+
# 1.6.181 stale-menu bugs), install tools present.
|
|
18
23
|
# 6. npm publish social-autoposter@VERSION (idempotent; skipped if already live).
|
|
19
24
|
# 7. Create/update GitHub release vX.Y.Z and upload the .mcpb (--clobber).
|
|
20
25
|
#
|
|
@@ -130,23 +135,25 @@ else
|
|
|
130
135
|
say "Releasing $TAG (repo-root package.json already $VERSION)"
|
|
131
136
|
fi
|
|
132
137
|
|
|
133
|
-
# ---- 2.
|
|
134
|
-
|
|
135
|
-
|
|
138
|
+
# ---- 2. Stamp EVERY version satellite BEFORE packing, then build ------------
|
|
139
|
+
# ONE source of truth (repo-root package.json, bumped in step 1); every other
|
|
140
|
+
# file that carries a version is a SATELLITE stamped from it here. The ordering
|
|
141
|
+
# is load-bearing: the embedded pipeline.tgz is `npm pack` of the repo root
|
|
142
|
+
# (bundle-pipeline.mjs), so it captures mcp/package.json AND mcp/dist/version.json
|
|
143
|
+
# AS THEY ARE ON DISK at pack time. If a satellite is stamped AFTER the pack, the
|
|
144
|
+
# tarball ships a stale mcp/ subtree while its top-level package.json is current.
|
|
145
|
+
# The menu bar resolves its version from mcp/dist/version.json FIRST
|
|
146
|
+
# (scripts/snapshot.py::_resolve_version), so a late stamp shows the OLD version
|
|
147
|
+
# in the menu bar even though the install is current (the 1.6.181-menu-on-a-
|
|
148
|
+
# 1.6.182-box bug, 2026-07-01). So: stamp source-tree satellites (2a) -> build
|
|
149
|
+
# panel + server (2b) -> stamp dist/version.json now that tsc emitted dist/ (2c)
|
|
150
|
+
# -> pack pipeline.tgz, now capturing an all-$VERSION mcp/ subtree (2d).
|
|
136
151
|
|
|
137
|
-
# ----
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
console.log(' '+fs.readFileSync(p,'utf8').trim());
|
|
143
|
-
"
|
|
144
|
-
|
|
145
|
-
# ---- 3b. Stamp manifest.json + mcp/package.json + lockfile version ----------
|
|
146
|
-
# Claude Desktop's extension "Details" panel reads version from manifest.json,
|
|
147
|
-
# so it must track the release version too (not the frozen 0.0.1 placeholder).
|
|
148
|
-
# mcp/package.json and its lockfile are stamped in lockstep so the three stay
|
|
149
|
-
# consistent (npm errors if package.json and package-lock.json disagree).
|
|
152
|
+
# ---- 2a. Stamp manifest.json + mcp/package.json + lockfile (PRE-pack) --------
|
|
153
|
+
# manifest.json feeds Claude Desktop's extension "Details" panel; mcp/package.json
|
|
154
|
+
# + its lockfile are stamped in lockstep (npm errors if they disagree). All three
|
|
155
|
+
# are inside the repo `files` allowlist, so they land in pipeline.tgz — they MUST
|
|
156
|
+
# be current before 2d packs them.
|
|
150
157
|
say "Stamping mcp/manifest.json + mcp/package.json + mcp/package-lock.json -> $VERSION"
|
|
151
158
|
node -e "
|
|
152
159
|
const fs=require('fs');
|
|
@@ -167,6 +174,25 @@ if (fs.existsSync(lp)) {
|
|
|
167
174
|
}
|
|
168
175
|
"
|
|
169
176
|
|
|
177
|
+
# ---- 2b. Build panel + server (emits dist/) ---------------------------------
|
|
178
|
+
# Split out of `build:bundle` so we can stamp dist/version.json (2c) AFTER tsc
|
|
179
|
+
# emits dist/ but BEFORE the pipeline pack (2d). tsc does not touch dist/version.json
|
|
180
|
+
# (it is JSON we author, not a TS output), so a 2c stamp survives to the pack.
|
|
181
|
+
say "Building MCP panel + server"
|
|
182
|
+
( cd "$MCP_DIR" && npm run build:panel && npm run build:server )
|
|
183
|
+
|
|
184
|
+
# ---- 2c. Stamp mcp/dist/version.json (after tsc, before the pipeline pack) ---
|
|
185
|
+
say "Stamping mcp/dist/version.json -> $VERSION"
|
|
186
|
+
node -e "
|
|
187
|
+
const fs=require('fs'),p='$MCP_DIR/dist/version.json';
|
|
188
|
+
fs.writeFileSync(p, JSON.stringify({version:'$VERSION',installedAt:new Date().toISOString()},null,2)+'\n');
|
|
189
|
+
console.log(' '+fs.readFileSync(p,'utf8').trim());
|
|
190
|
+
"
|
|
191
|
+
|
|
192
|
+
# ---- 2d. Pack embedded pipeline.tgz (now captures an all-$VERSION mcp/) ------
|
|
193
|
+
say "Packing embedded pipeline.tgz"
|
|
194
|
+
( cd "$MCP_DIR" && npm run bundle:pipeline )
|
|
195
|
+
|
|
170
196
|
# ---- 3c. Regenerate manifest.json `tools` from the SERVER's registrations ---
|
|
171
197
|
# Claude Desktop exposes a .mcpb extension's tools to agent chats from the
|
|
172
198
|
# manifest's `tools` array. It was hand-written and drifted: it listed 5 old
|
|
@@ -226,6 +252,18 @@ PIPELINE_VER=$(unzip -p "$BUNDLE" dist/pipeline.tgz 2>/dev/null | tar -xzO packa
|
|
|
226
252
|
[[ "$PIPELINE_VER" == "$VERSION" ]] || die "embedded pipeline.tgz version=$PIPELINE_VER != $VERSION (box would materialize a STALE pipeline; bump repo-root package.json BEFORE build)"
|
|
227
253
|
echo " pipeline.tgz internal version: $PIPELINE_VER ok"
|
|
228
254
|
|
|
255
|
+
# The menu bar reads package/mcp/dist/version.json FIRST, then package/mcp/package.json
|
|
256
|
+
# (scripts/snapshot.py::_resolve_version), both from the SAME pipeline.tgz the box
|
|
257
|
+
# extracts into SAPS_REPO_DIR. Assert the whole mcp/ subtree matches so a satellite
|
|
258
|
+
# stamped after the pack can't ship a menu that shows the wrong version on an
|
|
259
|
+
# otherwise-current box (the 1.6.181-menu-on-a-1.6.182-box bug). Empty/missing =
|
|
260
|
+
# fail: _resolve_version would fall through and could read a stale file.
|
|
261
|
+
for sub in mcp/dist/version.json mcp/package.json; do
|
|
262
|
+
SUBV=$(unzip -p "$BUNDLE" dist/pipeline.tgz 2>/dev/null | tar -xzO "package/$sub" 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo "?")
|
|
263
|
+
[[ "$SUBV" == "$VERSION" ]] || die "embedded pipeline.tgz package/$sub=$SUBV != $VERSION (menu bar would show the wrong version; stamp satellites BEFORE the pipeline pack)"
|
|
264
|
+
echo " pipeline.tgz $sub: $SUBV ok"
|
|
265
|
+
done
|
|
266
|
+
|
|
229
267
|
for f in "dist/index.js" "dist/runtime.js" "manifest.json"; do
|
|
230
268
|
# grep -c reads all input (no SIGPIPE); anchor on the time column + 3-space
|
|
231
269
|
# gutter so node_modules/.../dist/index.js does not false-match the top-level.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Slim snapshot of the S4L autopilot scheduled-task registry state.
|
|
3
|
+
|
|
4
|
+
Answers, per install, the question we were previously blind to: "are the
|
|
5
|
+
queue-worker scheduled tasks actually running from the dedicated ~/.s4l-worker
|
|
6
|
+
folder (so their once-a-minute sessions don't flood the user's project history),
|
|
7
|
+
or are they still mislocated, and is the deprecated autopilot task lingering?"
|
|
8
|
+
|
|
9
|
+
The heartbeat (scripts/heartbeat.sh + mcp/src/telemetry.ts) attaches the
|
|
10
|
+
`--summary` output as the top-level `scheduled_tasks` field of the heartbeat
|
|
11
|
+
body, so the state lands on the installations row centrally, keyed by
|
|
12
|
+
install_id, with no SSH needed.
|
|
13
|
+
|
|
14
|
+
Read-only: never edits a registry (that is the menubar's
|
|
15
|
+
`_rewrite_scheduled_task_cwd` job). Stdlib only, /usr/bin/python3 compatible.
|
|
16
|
+
|
|
17
|
+
Kept in sync with mcp/menubar/s4l_menubar.py (WORKER_TASK_IDS,
|
|
18
|
+
DEPRECATED_TASK_IDS, WORKER_CWD, SCHED_REGISTRY_GLOB) and scripts/s4l_box_update.sh.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import glob
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
# --- Kept in sync with mcp/menubar/s4l_menubar.py ---------------------------
|
|
30
|
+
WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
|
|
31
|
+
DEPRECATED_TASK_IDS = ("social-autoposter-autopilot",)
|
|
32
|
+
WORKER_CWD = os.path.join(os.path.expanduser("~"), ".s4l-worker")
|
|
33
|
+
SCHED_REGISTRY_GLOB = os.path.join(
|
|
34
|
+
os.path.expanduser("~"), "Library", "Application Support", "Claude",
|
|
35
|
+
"claude-code-sessions", "*", "*", "scheduled-tasks.json",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cwd_tail(cwd: str) -> str:
|
|
40
|
+
"""Last path component only, so we surface WHERE a mislocated task points
|
|
41
|
+
(e.g. 's4lsetup' vs '.s4l-worker') without shipping the full home path /
|
|
42
|
+
username off-box."""
|
|
43
|
+
if not cwd:
|
|
44
|
+
return ""
|
|
45
|
+
return os.path.basename(os.path.normpath(cwd))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_summary() -> dict:
|
|
49
|
+
"""Scan every scheduled-tasks.json registry and summarize the S4L worker
|
|
50
|
+
tasks' folder state. Never raises; a broken/absent registry yields an empty
|
|
51
|
+
(but well-formed) summary so the heartbeat body is always valid."""
|
|
52
|
+
tasks: list[dict] = []
|
|
53
|
+
registries = 0
|
|
54
|
+
deprecated_present = False
|
|
55
|
+
seen_ids: set[str] = set()
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
files = glob.glob(SCHED_REGISTRY_GLOB)
|
|
59
|
+
except Exception:
|
|
60
|
+
files = []
|
|
61
|
+
|
|
62
|
+
for f in files:
|
|
63
|
+
try:
|
|
64
|
+
with open(f) as fh:
|
|
65
|
+
d = json.load(fh)
|
|
66
|
+
except Exception:
|
|
67
|
+
continue
|
|
68
|
+
registries += 1
|
|
69
|
+
for t in (d.get("scheduledTasks") or []):
|
|
70
|
+
tid = t.get("id")
|
|
71
|
+
if tid in DEPRECATED_TASK_IDS:
|
|
72
|
+
deprecated_present = True
|
|
73
|
+
continue
|
|
74
|
+
if tid not in WORKER_TASK_IDS:
|
|
75
|
+
continue
|
|
76
|
+
cwd = t.get("cwd") or ""
|
|
77
|
+
in_worker = os.path.normpath(cwd) == os.path.normpath(WORKER_CWD) if cwd else False
|
|
78
|
+
seen_ids.add(tid)
|
|
79
|
+
tasks.append({
|
|
80
|
+
"id": tid,
|
|
81
|
+
"enabled": bool(t.get("enabled")),
|
|
82
|
+
"in_worker_dir": in_worker,
|
|
83
|
+
"cwd_tail": _cwd_tail(cwd),
|
|
84
|
+
"last_run_at": t.get("lastRunAt"),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
mislocated = sum(1 for t in tasks if not t["in_worker_dir"])
|
|
88
|
+
return {
|
|
89
|
+
"worker_dir_tail": _cwd_tail(WORKER_CWD),
|
|
90
|
+
"registries": registries,
|
|
91
|
+
"worker_tasks": len(tasks),
|
|
92
|
+
"missing_worker_tasks": sorted(set(WORKER_TASK_IDS) - seen_ids),
|
|
93
|
+
"mislocated": mislocated,
|
|
94
|
+
# all_in_worker_dir is False when there are zero worker tasks too, since
|
|
95
|
+
# "no autopilot registered" is itself a state worth seeing centrally.
|
|
96
|
+
"all_in_worker_dir": bool(tasks) and mislocated == 0,
|
|
97
|
+
"deprecated_present": deprecated_present,
|
|
98
|
+
"tasks": tasks,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main() -> int:
|
|
103
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--summary",
|
|
106
|
+
action="store_true",
|
|
107
|
+
help="Print a slim JSON summary to stdout and exit. Used by the heartbeat.",
|
|
108
|
+
)
|
|
109
|
+
args = parser.parse_args()
|
|
110
|
+
|
|
111
|
+
summary = build_summary()
|
|
112
|
+
if args.summary:
|
|
113
|
+
sys.stdout.write(json.dumps(summary, separators=(",", ":")))
|
|
114
|
+
else:
|
|
115
|
+
sys.stdout.write(json.dumps(summary, indent=2) + "\n")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sys.exit(main())
|