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 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 bring the S4L tray icon back. Does NOT touch the draft " +
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
@@ -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 === "darwin") {
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) &&
@@ -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
- let body = "{}";
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
- body = JSON.stringify({ resource: JSON.parse(out) });
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
- /* keep body = "{}" */
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" },
@@ -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: [],
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.182",
3
- "installedAt": "2026-07-01T18:31:17.834Z"
2
+ "version": "1.6.185",
3
+ "installedAt": "2026-07-01T21:48:09.463Z"
4
4
  }
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.182",
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. Claude
694
- Desktop OWNS the live schedule and caches the registry in memory,
695
- clobbering any live edit on the next fire — so the only reliable way to
696
- disable them is to quit Claude, strip the tasks while it's down, then
697
- relaunch. We warn the user with a modal FIRST that Claude Desktop will
698
- restart, since the app window will close and reopen under them."""
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.\n\n"
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; you can re-arm the schedule any time from "
708
- "this menu."
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
- Mirror of _relocate_restart_work's restart block. The menu bar is a separate
759
- launchd process, so killing Claude does not kill us."""
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 restarted.")
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.182",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.182",
3
+ "version": "1.6.185",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -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)
@@ -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. Build the MCP bundle: vite (panel) + tsc (server) + bundle-pipeline. The
11
- # embedded pipeline.tgz is `npm pack` of the (now-bumped) repo, so the shell
12
- # and the bundled Python pipeline CANNOT diverge.
13
- # 3. Stamp dist/version.json + manifest.json + mcp/package.json(+lock) to match.
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 all == VERSION (the guard that was
17
- # missing when 1.6.84 shipped a 1.6.83 pipeline), install tools present.
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. Build the bundle ----------------------------------------------------
134
- say "Building MCP bundle (panel + server + embedded pipeline.tgz)"
135
- ( cd "$MCP_DIR" && npm run build:bundle )
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
- # ---- 3. Stamp version.json --------------------------------------------------
138
- say "Stamping mcp/dist/version.json -> $VERSION"
139
- node -e "
140
- const fs=require('fs'),p='$MCP_DIR/dist/version.json';
141
- fs.writeFileSync(p, JSON.stringify({version:'$VERSION',installedAt:new Date().toISOString()},null,2)+'\n');
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())