social-autoposter 1.6.82 → 1.6.84

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 { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from
22
22
  import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, } from "./runtime.js";
23
23
  import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
24
24
  import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
25
- import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
25
+ import { initSentry, sendHeartbeat, captureError, flushSentry, startLogStreaming, flushLogs } from "./telemetry.js";
26
26
  import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import http from "node:http";
@@ -576,10 +576,20 @@ async function postApproved(batchId, plan) {
576
576
  catch {
577
577
  /* keep raw */
578
578
  }
579
- // On a successful run, mark the posted candidates in the ORIGINAL plan so the
580
- // other review surface (chat vs menu-bar pop-ups) skips them cross-surface
581
- // de-dup. Best-effort; the pipeline's own already-posted check is the backstop.
582
- if (res.code === 0) {
579
+ // Real posted count from the pipeline summary NOT the approved count. A run
580
+ // can exit 0 yet post nothing (every reply hit reply_box_not_found, etc.), so
581
+ // trusting approved.length here reported phantom successes ("posted: 1" when 0
582
+ // landed). Fall back to approved.length only when the summary is unparseable
583
+ // AND the process exited clean.
584
+ const summObj = (summary && typeof summary === "object") ? summary : null;
585
+ const realPosted = summObj && typeof summObj.posted === "number"
586
+ ? summObj.posted
587
+ : res.code === 0 && !summObj
588
+ ? approved.length
589
+ : 0;
590
+ // Mark only candidates that ACTUALLY posted (cross-surface de-dup). When the
591
+ // summary is missing but the run exited clean, fall back to marking all.
592
+ if (realPosted > 0 || (res.code === 0 && !summObj)) {
583
593
  for (const c of approved)
584
594
  c.posted = true;
585
595
  try {
@@ -589,8 +599,27 @@ async function postApproved(batchId, plan) {
589
599
  /* best effort */
590
600
  }
591
601
  }
602
+ // Post failures are HANDLED in the pipeline (it returns a count, never throws),
603
+ // so they never reach Sentry on their own. Capture an explicit event whenever
604
+ // the run exited non-zero OR fewer drafts posted than were approved. This is
605
+ // the only telemetry channel that reaches a customer .mcpb install (their cycle
606
+ // log lives on their machine). install_id/hostname are auto-tagged.
607
+ if (res.code !== 0 || realPosted < approved.length) {
608
+ captureError(new Error(`post_drafts: ${realPosted}/${approved.length} posted (exit=${res.code})`), {
609
+ component: "post",
610
+ exit_code: String(res.code),
611
+ attempted: String(approved.length),
612
+ posted: String(realPosted),
613
+ failure_reasons: String(summObj?.failure_reasons || ""),
614
+ skip_reasons: String(summObj?.skip_reasons || ""),
615
+ stderr_tail: res.stderr.split("\n").slice(-5).join(" | ").slice(0, 500),
616
+ });
617
+ void flushSentry(2000);
618
+ }
619
+ void flushLogs();
592
620
  return {
593
621
  attempted: approved.length,
622
+ posted: realPosted,
594
623
  exit_code: res.code,
595
624
  summary,
596
625
  stderr_tail: res.stderr.split("\n").slice(-8).join("\n"),
@@ -1210,11 +1239,20 @@ tool("post_drafts", {
1210
1239
  });
1211
1240
  }
1212
1241
  const result = await postApproved(batch_id, plan);
1242
+ // Report the REAL posted count from the pipeline, not the approved count.
1243
+ // A run can approve N yet land 0 (browser/session failure); reporting
1244
+ // approve.size here told the agent "posted: N" on a total failure.
1245
+ const actuallyPosted = typeof result.posted === "number" ? result.posted : approve.size;
1246
+ if (actuallyPosted < approve.size) {
1247
+ warnings.push(`only ${actuallyPosted}/${approve.size} actually posted (exit=${result.exit_code}); ` +
1248
+ `see result.summary / result.stderr_tail for the reason`);
1249
+ }
1213
1250
  return jsonContent({
1214
1251
  batch_id,
1215
1252
  drafted: total,
1216
- posted: approve.size,
1217
- skipped: total - approve.size,
1253
+ posted: actuallyPosted,
1254
+ approved: approve.size,
1255
+ skipped: total - actuallyPosted,
1218
1256
  edited: editedCount,
1219
1257
  result,
1220
1258
  warnings,
@@ -2346,6 +2384,11 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
2346
2384
  }));
2347
2385
  async function main() {
2348
2386
  initSentry();
2387
+ // Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
2388
+ // backend so we can troubleshoot/rescue any user scenario (silent stalls,
2389
+ // partial onboarding) without asking them to ship a log file. Best-effort;
2390
+ // disabled with SAPS_LOG_STREAM=0.
2391
+ startLogStreaming();
2349
2392
  // A plugin UPDATE refreshes this server (dist/) but not the materialized
2350
2393
  // pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
2351
2394
  // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
@@ -2369,8 +2412,22 @@ async function main() {
2369
2412
  // and cheap when already present, so existing installs pick it up on the next
2370
2413
  // Claude restart without re-provisioning. Best-effort: never blocks boot.
2371
2414
  void ensureMenubar()
2372
- .then((r) => console.error(`[social-autoposter-mcp] menubar: ${r.skipped ? "skip" : r.ok ? "ok" : "fail"} (${r.detail})`))
2373
- .catch((e) => console.error("[social-autoposter-mcp] menubar ensure failed:", e?.message || e));
2415
+ .then((r) => {
2416
+ console.error(`[social-autoposter-mcp] menubar: ${r.skipped ? "skip" : r.ok ? "ok" : "fail"} (${r.detail})`);
2417
+ // A non-skipped failure here is the boot-time "menu bar didn't come up"
2418
+ // path (e.g. uv missing, rumps reinstall failed on an existing install).
2419
+ // Report it; a skip (non-macOS / runtime not ready) is expected, not an error.
2420
+ if (!r.ok && !r.skipped) {
2421
+ captureError(new Error(`menubar ensure failed: ${r.detail}`), {
2422
+ component: "menubar",
2423
+ phase: "ensure",
2424
+ });
2425
+ }
2426
+ })
2427
+ .catch((e) => {
2428
+ console.error("[social-autoposter-mcp] menubar ensure failed:", e?.message || e);
2429
+ captureError(e, { component: "menubar", phase: "ensure" });
2430
+ });
2374
2431
  // Phone home so this .mcpb install is visible in the install-lane digest
2375
2432
  // (parity with the npx launchd heartbeat). Once on startup, then every 15m
2376
2433
  // while the desktop app keeps the server alive. unref() so it never holds the
@@ -2382,6 +2439,7 @@ async function main() {
2382
2439
  main().catch(async (err) => {
2383
2440
  console.error("[social-autoposter-mcp] fatal:", err);
2384
2441
  captureError(err, { component: "main" });
2442
+ await flushLogs();
2385
2443
  await flushSentry();
2386
2444
  process.exit(1);
2387
2445
  });
package/mcp/dist/repo.js CHANGED
@@ -30,6 +30,27 @@ export function repoDir() {
30
30
  // "No drafts in batch ...". Default to /tmp to match the script; allow an explicit
31
31
  // override for non-standard installs.
32
32
  export const TMP_DIR = process.env.SAPS_TMP_DIR || "/tmp";
33
+ let lineSink = null;
34
+ export function setLineSink(fn) {
35
+ lineSink = fn;
36
+ }
37
+ // Derive a short, stable context label for a spawned command so log lines can
38
+ // be grouped by which script produced them (e.g. "scripts/seed_search_topics.py",
39
+ // "skill/run-twitter-cycle.sh"). Best-effort; never throws.
40
+ function deriveContext(cmd, args) {
41
+ try {
42
+ const base = cmd.split("/").pop() || cmd;
43
+ if (/python/i.test(base) || base === "bash" || base === "sh" || base === "node") {
44
+ const first = args.find((a) => !a.startsWith("-"));
45
+ if (first)
46
+ return first;
47
+ }
48
+ return [base, args[0] ?? ""].join(" ").trim();
49
+ }
50
+ catch {
51
+ return cmd;
52
+ }
53
+ }
33
54
  // Spawn a process inside the repo, inheriting the repo env (API base + keys
34
55
  // come from the install's environment / .env loaded by the scripts themselves).
35
56
  //
@@ -73,6 +94,29 @@ export function run(cmd, args, opts = {}) {
73
94
  }
74
95
  return buf;
75
96
  };
97
+ // Parallel whole-line splitter that tees to the telemetry sink (if any),
98
+ // kept separate from the onLine pump so neither path can affect the other.
99
+ const logCtx = opts.logContext || deriveContext(cmd, args);
100
+ let sinkOutBuf = "";
101
+ let sinkErrBuf = "";
102
+ const sinkPump = (chunk, which, buf) => {
103
+ const sink = lineSink;
104
+ if (!sink)
105
+ return buf;
106
+ buf += chunk;
107
+ let nl;
108
+ while ((nl = buf.indexOf("\n")) !== -1) {
109
+ const line = buf.slice(0, nl);
110
+ buf = buf.slice(nl + 1);
111
+ try {
112
+ sink(line, which, logCtx);
113
+ }
114
+ catch {
115
+ /* the telemetry sink must never break the wrapped command */
116
+ }
117
+ }
118
+ return buf;
119
+ };
76
120
  let timer;
77
121
  if (opts.timeoutMs) {
78
122
  timer = setTimeout(() => {
@@ -83,11 +127,13 @@ export function run(cmd, args, opts = {}) {
83
127
  const s = d.toString();
84
128
  stdout += s;
85
129
  outBuf = pump(s, "stdout", outBuf);
130
+ sinkOutBuf = sinkPump(s, "stdout", sinkOutBuf);
86
131
  });
87
132
  child.stderr.on("data", (d) => {
88
133
  const s = d.toString();
89
134
  stderr += s;
90
135
  errBuf = pump(s, "stderr", errBuf);
136
+ sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
91
137
  });
92
138
  child.on("close", (code) => {
93
139
  if (timer)
@@ -109,6 +155,23 @@ export function run(cmd, args, opts = {}) {
109
155
  /* ignore */
110
156
  }
111
157
  }
158
+ const sink = lineSink;
159
+ if (sink) {
160
+ if (sinkOutBuf)
161
+ try {
162
+ sink(sinkOutBuf, "stdout", logCtx);
163
+ }
164
+ catch {
165
+ /* ignore */
166
+ }
167
+ if (sinkErrBuf)
168
+ try {
169
+ sink(sinkErrBuf, "stderr", logCtx);
170
+ }
171
+ catch {
172
+ /* ignore */
173
+ }
174
+ }
112
175
  resolve({ code: code ?? -1, stdout, stderr });
113
176
  });
114
177
  child.on("error", (err) => {
@@ -22,6 +22,7 @@ import fs from "node:fs";
22
22
  import os from "node:os";
23
23
  import path from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
+ import { captureError } from "./telemetry.js";
25
26
  // Pin the standalone CPython series the venv is built from. Bump deliberately.
26
27
  const PYTHON_VERSION = "3.12";
27
28
  // The CDP scan engine the twitter cycle shells out to (~/.local/bin/browser-harness).
@@ -378,6 +379,15 @@ async function provision(progress) {
378
379
  progress.ok = false;
379
380
  progress.error = msg;
380
381
  writeProgress(progress);
382
+ // Every fatal install-step failure (repo unpack, uv, python, venv, deps,
383
+ // chromium, harness, chrome) was previously only written to the local
384
+ // install-progress.json, invisible to us. Report it so a failed runtime
385
+ // install becomes a real Sentry event, tagged with the step that failed.
386
+ const failedStep = progress.steps.find((s) => s.status === "running");
387
+ captureError(new Error(msg), {
388
+ component: "install",
389
+ ...(failedStep ? { step: failedStep.id } : {}),
390
+ });
381
391
  return progress;
382
392
  };
383
393
  fs.mkdirSync(RUNTIME_DIR, { recursive: true });
@@ -499,6 +509,19 @@ async function provision(progress) {
499
509
  if (r.code !== 0) {
500
510
  return fail(`playwright install chromium failed (exit ${r.code}). ${r.out.slice(-400)}`);
501
511
  }
512
+ // Smoke-test the EXACT gate the pipeline's post path runs at use time
513
+ // (twitter_post_plan.py preflight): the owned interpreter must import
514
+ // playwright. The reply step is the only Playwright importer, so a deps
515
+ // sync that left it unimportable was invisible until the first real post
516
+ // died with no_reply_json in production (Karol, 2026-06-22). Fail the
517
+ // install LOUDLY here instead.
518
+ const smoke = await sh(VENV_PYTHON, ["-c", "import playwright"], {
519
+ timeoutMs: 60000,
520
+ });
521
+ if (smoke.code !== 0) {
522
+ return fail(`runtime smoke test failed: ${VENV_PYTHON} cannot import playwright ` +
523
+ `(exit ${smoke.code}). ${smoke.out.slice(-400)}`);
524
+ }
502
525
  }
503
526
  setStep("chromium", "done");
504
527
  // --- Step 6: browser-harness CLI -----------------------------------------
@@ -607,6 +630,15 @@ async function provision(progress) {
607
630
  if (process.platform === "darwin") {
608
631
  const mb = await installMenubar(uv, uvEnv, VENV_PYTHON);
609
632
  setStep("menubar", mb.ok ? "done" : "error", mb.detail);
633
+ // Non-fatal step, so the only prior signal of a menu bar install failure was
634
+ // a local install-progress.json entry (invisible to us). Report it so "menu
635
+ // bar didn't start" becomes a real Sentry event with the failing detail.
636
+ if (!mb.ok) {
637
+ captureError(new Error(`menubar install failed: ${mb.detail}`), {
638
+ component: "menubar",
639
+ phase: "install",
640
+ });
641
+ }
610
642
  }
611
643
  else {
612
644
  setStep("menubar", "done", "skipped (macOS only)");
@@ -9,7 +9,7 @@
9
9
  import * as Sentry from "@sentry/node";
10
10
  import path from "node:path";
11
11
  import fs from "node:fs";
12
- import { repoDir, runPython } from "./repo.js";
12
+ import { repoDir, runPython, setLineSink } from "./repo.js";
13
13
  import { VERSION } from "./version.js";
14
14
  // Sentry DSN is a client-side identifier (safe to embed, same posture as Fazm's
15
15
  // hardcoded Swift DSN). Overridable via env for dev. Empty -> Sentry disabled.
@@ -102,3 +102,129 @@ export async function sendHeartbeat(reason) {
102
102
  console.error("[social-autoposter-mcp] heartbeat failed:", err?.message || err);
103
103
  }
104
104
  }
105
+ // ---- Raw subprocess log streaming ------------------------------------------
106
+ // Tees the verbatim stdout/stderr of every pipeline subprocess (via the
107
+ // repo.ts run() boundary) to the s4l backend, so we can troubleshoot and
108
+ // rescue any user scenario without asking them to ship a log file. Lines are
109
+ // buffered in memory and flushed in small batches under the same X-Installation
110
+ // identity the heartbeat uses. Best-effort: this NEVER throws into the server,
111
+ // never blocks the child's I/O, and drops on overflow rather than growing
112
+ // unbounded. Disable with SAPS_LOG_STREAM=0.
113
+ const LOG_STREAM_ENABLED = process.env.SAPS_LOG_STREAM !== "0";
114
+ const LOG_MAX_LINE_LEN = 8192; // mirror the backend cap
115
+ const LOG_MAX_BUFFER = 1000; // drop oldest beyond this (overflow protection)
116
+ const LOG_FLUSH_BATCH = 100; // flush eagerly once we have this many lines
117
+ const LOG_MAX_PER_POST = 200; // backend accepts 1-200 per request
118
+ const LOG_FLUSH_MS = 3000; // otherwise flush on this cadence
119
+ const logBuffer = [];
120
+ let logDropped = 0; // count of lines dropped on overflow (surfaced periodically)
121
+ let logFlushing = false;
122
+ let logTimer;
123
+ let cachedInstallHeader = null;
124
+ let logStreamingStarted = false;
125
+ async function installHeader() {
126
+ if (cachedInstallHeader)
127
+ return cachedInstallHeader;
128
+ try {
129
+ const idScript = path.join(repoDir(), "scripts", "identity.py");
130
+ if (!fs.existsSync(idScript))
131
+ return null;
132
+ const res = await runPython("scripts/identity.py", ["header"], { timeoutMs: 10_000 });
133
+ const header = (res.stdout || "").trim();
134
+ if (res.code === 0 && header) {
135
+ cachedInstallHeader = header;
136
+ return header;
137
+ }
138
+ }
139
+ catch {
140
+ /* best-effort */
141
+ }
142
+ return null;
143
+ }
144
+ // Buffer one raw line. Called from the repo.ts line sink, so it must be cheap
145
+ // and total non-throwing.
146
+ export function logLine(stream, line, context) {
147
+ if (!LOG_STREAM_ENABLED)
148
+ return;
149
+ try {
150
+ logBuffer.push({
151
+ ts: new Date().toISOString(),
152
+ stream,
153
+ line: line.length > LOG_MAX_LINE_LEN ? line.slice(0, LOG_MAX_LINE_LEN) : line,
154
+ context: context || "",
155
+ });
156
+ if (logBuffer.length > LOG_MAX_BUFFER) {
157
+ // Drop oldest to bound memory; the newest lines are the most useful.
158
+ logDropped += logBuffer.length - LOG_MAX_BUFFER;
159
+ logBuffer.splice(0, logBuffer.length - LOG_MAX_BUFFER);
160
+ }
161
+ if (logBuffer.length >= LOG_FLUSH_BATCH)
162
+ void flushLogs();
163
+ }
164
+ catch {
165
+ /* never throw into the run() boundary */
166
+ }
167
+ }
168
+ export async function flushLogs() {
169
+ if (!LOG_STREAM_ENABLED)
170
+ return;
171
+ if (logFlushing || logBuffer.length === 0)
172
+ return;
173
+ logFlushing = true;
174
+ try {
175
+ const header = await installHeader();
176
+ if (!header)
177
+ return; // runtime not unpacked yet; keep buffering
178
+ const base = (process.env.AUTOPOSTER_API_BASE || "https://s4l.ai").replace(/\/+$/, "");
179
+ // Drain in <=200-line POSTs until the buffer empties (or a POST fails).
180
+ while (logBuffer.length > 0) {
181
+ const batch = logBuffer.splice(0, LOG_MAX_PER_POST);
182
+ const lines = batch.map((b) => ({
183
+ ts: b.ts,
184
+ stream: b.stream,
185
+ line: b.line,
186
+ context: b.context || undefined,
187
+ }));
188
+ try {
189
+ const resp = await fetch(`${base}/api/v1/installations/logs`, {
190
+ method: "POST",
191
+ headers: { "X-Installation": header, "content-type": "application/json" },
192
+ body: JSON.stringify({ lines }),
193
+ signal: AbortSignal.timeout(15_000),
194
+ });
195
+ if (!resp.ok) {
196
+ // Drop this batch (don't re-buffer): a persistent 4xx/5xx would grow
197
+ // the buffer unbounded. The raw stream is best-effort.
198
+ console.error(`[social-autoposter-mcp] log flush http ${resp.status}`);
199
+ break;
200
+ }
201
+ }
202
+ catch (err) {
203
+ // Network blip: drop this batch, stop draining, try again next tick.
204
+ console.error("[social-autoposter-mcp] log flush failed:", err?.message || err);
205
+ break;
206
+ }
207
+ }
208
+ if (logDropped > 0) {
209
+ console.error(`[social-autoposter-mcp] log stream dropped ${logDropped} line(s) on overflow`);
210
+ logDropped = 0;
211
+ }
212
+ }
213
+ finally {
214
+ logFlushing = false;
215
+ }
216
+ }
217
+ // Register the repo.ts line sink and start the periodic flush. Idempotent.
218
+ export function startLogStreaming() {
219
+ if (!LOG_STREAM_ENABLED || logStreamingStarted)
220
+ return;
221
+ logStreamingStarted = true;
222
+ try {
223
+ setLineSink((line, stream, context) => logLine(stream, line, context));
224
+ logTimer = setInterval(() => void flushLogs(), LOG_FLUSH_MS);
225
+ logTimer.unref();
226
+ }
227
+ catch (err) {
228
+ console.error("[social-autoposter-mcp] log streaming start failed:", err?.message || err);
229
+ }
230
+ }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.82",
3
- "installedAt": "2026-06-22T21:04:08.350Z"
2
+ "version": "1.6.84",
3
+ "installedAt": "2026-06-23T02:53:37.786Z"
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.82",
5
+ "version": "1.6.84",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the S4L pipeline.",
7
7
  "long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Click **Configure** and set every tool permission to **Always Allow**.\n2. Copy this prompt: **Set me up on S4L end to end**.\n3. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
8
8
  "author": {
@@ -24,9 +24,58 @@ import tempfile
24
24
  import threading
25
25
  import time
26
26
 
27
- import rumps
28
-
29
- import s4l_state as st
27
+ # --- Sentry bootstrap --------------------------------------------------------
28
+ # The menu bar runs as a standalone KeepAlive LaunchAgent off the owned venv,
29
+ # a separate process from the MCP server, so it was a Sentry blind spot: a crash
30
+ # (most often rumps missing/broken in the venv -> "menu bar didn't start") only
31
+ # ever landed in the local menubar.err.log. Wire it in BEFORE importing rumps so
32
+ # even an import-time failure of the menu bar's heaviest dependency is reported.
33
+ # sentry_init lives in the pipeline's scripts/ dir (SAPS_REPO_DIR is exported by
34
+ # the launchd plist) and sentry-sdk is in the owned venv (requirements.txt). All
35
+ # best-effort: a missing repo path or SDK degrades to a silent no-op.
36
+ _sentry = None
37
+ try:
38
+ _repo = os.environ.get("SAPS_REPO_DIR")
39
+ if _repo:
40
+ _scripts = os.path.join(_repo, "scripts")
41
+ if _scripts not in sys.path:
42
+ sys.path.insert(0, _scripts)
43
+ import sentry_init as _sentry # noqa: E402
44
+
45
+ _sentry.init()
46
+ except Exception:
47
+ _sentry = None
48
+
49
+
50
+ def _capture(err, **tags):
51
+ """Report a handled menu-bar error to Sentry (component=menubar) without ever
52
+ raising into the caller. No-op if the Sentry bootstrap above failed."""
53
+ try:
54
+ if _sentry is not None:
55
+ tags.setdefault("component", "menubar")
56
+ _sentry.capture_exception(err, tags=tags)
57
+ except Exception:
58
+ pass
59
+
60
+
61
+ def _flush():
62
+ try:
63
+ if _sentry is not None:
64
+ _sentry.flush()
65
+ except Exception:
66
+ pass
67
+
68
+
69
+ try:
70
+ import rumps # noqa: E402
71
+ except Exception as _import_err:
72
+ # rumps missing/broken in the owned venv is THE "menu bar didn't start" case.
73
+ # Report it explicitly, flush, then re-raise so launchd records the crash too.
74
+ _capture(_import_err, phase="import_rumps")
75
+ _flush()
76
+ raise
77
+
78
+ import s4l_state as st # noqa: E402
30
79
 
31
80
  CLAUDE_APP = "Claude"
32
81
  POLL_SECONDS = 5
@@ -432,6 +481,7 @@ class S4LMenuBar(rumps.App):
432
481
  self._review_active = False
433
482
  sys.stderr.write(f"[s4l-menubar] review cards failed: {e}\n")
434
483
  sys.stderr.flush()
484
+ _capture(e, phase="review_cards")
435
485
 
436
486
  def _on_review_done(self, batch, decisions):
437
487
  # Runs on the main thread (from the card controller). Translate decisions
@@ -598,4 +648,11 @@ class S4LMenuBar(rumps.App):
598
648
 
599
649
 
600
650
  if __name__ == "__main__":
601
- S4LMenuBar().run()
651
+ try:
652
+ S4LMenuBar().run()
653
+ except Exception as _run_err:
654
+ # The run loop dying is the other "menu bar didn't start / vanished" case.
655
+ # Report + flush before the KeepAlive relaunch so it isn't lost on teardown.
656
+ _capture(_run_err, phase="run")
657
+ _flush()
658
+ raise
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.82",
3
+ "version": "1.6.84",
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.82",
3
+ "version": "1.6.84",
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"