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 +67 -9
- package/mcp/dist/repo.js +63 -0
- package/mcp/dist/runtime.js +32 -0
- package/mcp/dist/telemetry.js +127 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +61 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/engage_github.py +24 -14
- package/scripts/engage_reddit.py +48 -20
- package/scripts/post_github.py +17 -7
- package/scripts/post_reddit.py +38 -11
- package/scripts/seed_search_queries.py +18 -1
- package/scripts/sentry_init.py +61 -0
- package/scripts/twitter_post_plan.py +86 -5
- package/skill/run-twitter-cycle.sh +1 -1
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
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
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:
|
|
1217
|
-
|
|
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) =>
|
|
2373
|
-
|
|
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) => {
|
package/mcp/dist/runtime.js
CHANGED
|
@@ -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)");
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -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
|
+
}
|
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.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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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.
|
|
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