social-autoposter 1.6.89 → 1.6.91
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 +73 -1
- package/mcp/dist/onboarding.js +6 -1
- package/mcp/dist/repo.js +63 -0
- package/mcp/dist/telemetry.js +166 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +35 -11
- package/mcp/menubar/s4l_menubar.py +87 -32
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/s4l_box_update.sh +76 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/setup_twitter_auth.py +37 -0
- package/scripts/test_percard_posting.py +123 -0
package/mcp/dist/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from
|
|
|
23
23
|
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, } from "./runtime.js";
|
|
24
24
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
25
25
|
import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
|
|
26
|
-
import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
|
|
26
|
+
import { initSentry, sendHeartbeat, captureError, flushSentry, startLogStreaming, flushLogs } from "./telemetry.js";
|
|
27
27
|
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
29
|
import http from "node:http";
|
|
@@ -532,10 +532,64 @@ function renderDraftsTable(plan) {
|
|
|
532
532
|
})
|
|
533
533
|
.join("\n\n");
|
|
534
534
|
}
|
|
535
|
+
// Resolve the configured posting handle the SAME way account_resolver.py does:
|
|
536
|
+
// AUTOPOSTER_TWITTER_HANDLE env first, then config.json accounts.twitter.handle.
|
|
537
|
+
// Returns the bare handle (no @) or null. The post preflight uses it so a missing
|
|
538
|
+
// handle fails ONCE, loudly, instead of as N silent per-reply no_account_configured
|
|
539
|
+
// skips (twitter_browser.py refuses to post with no handle — no impersonation).
|
|
540
|
+
function readConfiguredTwitterHandle() {
|
|
541
|
+
const env = (process.env.AUTOPOSTER_TWITTER_HANDLE || "").trim().replace(/^@/, "");
|
|
542
|
+
if (env)
|
|
543
|
+
return env;
|
|
544
|
+
try {
|
|
545
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(repoDir(), "config.json"), "utf-8"));
|
|
546
|
+
const h = cfg?.accounts?.twitter?.handle;
|
|
547
|
+
const s = (typeof h === "string" ? h : "").trim().replace(/^@/, "");
|
|
548
|
+
return s || null;
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// Self-heal a missing handle: read the live logged-in @handle from the managed
|
|
555
|
+
// Chrome and persist it to config.json accounts.twitter.handle. This is ground
|
|
556
|
+
// truth (the poster posts through that exact session), NOT a guess — so it's safe
|
|
557
|
+
// where a hardcoded fallback would not be. Closes the onboarding gap where
|
|
558
|
+
// connect_x's best-effort handle capture silently no-op'd and left posting dead.
|
|
559
|
+
// Best-effort; never throws — the caller re-checks and refuses loudly if still unset.
|
|
560
|
+
async function ensurePostingHandle() {
|
|
561
|
+
try {
|
|
562
|
+
await runPython("scripts/setup_twitter_auth.py", ["resolve-handle"], {
|
|
563
|
+
timeoutMs: 60_000,
|
|
564
|
+
env: { SAPS_REPO_DIR: repoDir(), PATH: pipelinePath() },
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
/* best effort */
|
|
569
|
+
}
|
|
570
|
+
}
|
|
535
571
|
async function postApproved(batchId, plan) {
|
|
536
572
|
const approved = (plan.candidates || []).filter((c) => c.approved === true);
|
|
537
573
|
if (approved.length === 0)
|
|
538
574
|
return { attempted: 0, exit_code: 0, summary: "nothing approved" };
|
|
575
|
+
// PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
|
|
576
|
+
// EVERY reply with no_account_configured and the whole batch skips — invisibly.
|
|
577
|
+
// If onboarding never persisted it, self-heal from the live session; if even that
|
|
578
|
+
// can't determine it, refuse here with a clear reason rather than launching a
|
|
579
|
+
// poster that silently burns the whole batch.
|
|
580
|
+
if (!readConfiguredTwitterHandle())
|
|
581
|
+
await ensurePostingHandle();
|
|
582
|
+
if (!readConfiguredTwitterHandle()) {
|
|
583
|
+
return {
|
|
584
|
+
attempted: 0,
|
|
585
|
+
exit_code: 0,
|
|
586
|
+
posted: 0,
|
|
587
|
+
summary: "no_account_configured",
|
|
588
|
+
error: "X is connected but no posting @handle is configured, so every reply would be refused " +
|
|
589
|
+
"(no_account_configured). Re-run project_config action:'connect_x' to capture the handle, " +
|
|
590
|
+
"or set accounts.twitter.handle in config.json.",
|
|
591
|
+
};
|
|
592
|
+
}
|
|
539
593
|
// Posting is a priority over scanning: abort any in-flight plugin scan so the
|
|
540
594
|
// approved post takes the browser immediately instead of waiting on the lock.
|
|
541
595
|
// Plugin pipeline only — never affects the plist autopilot.
|
|
@@ -574,6 +628,17 @@ async function postApproved(batchId, plan) {
|
|
|
574
628
|
// BYO-Chrome), else default to the local harness on port 9555.
|
|
575
629
|
TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
|
|
576
630
|
},
|
|
631
|
+
// Stream the poster's output live (like scan_candidates does) so HANDLED
|
|
632
|
+
// failures — e.g. every reply refused with no_account_configured, which
|
|
633
|
+
// returns a reason instead of throwing — surface in main.log + telemetry
|
|
634
|
+
// in real time. Without this the poster's stdout was buffered in-process
|
|
635
|
+
// and only flushed to post-*.log at the END, so a 0/N batch was invisible
|
|
636
|
+
// while the menu bar showed "posting N/89" climbing.
|
|
637
|
+
onLine: (line) => {
|
|
638
|
+
const t = line.replace(/\s+$/, "");
|
|
639
|
+
if (t.trim())
|
|
640
|
+
console.error(`[post] ${t}`);
|
|
641
|
+
},
|
|
577
642
|
});
|
|
578
643
|
}
|
|
579
644
|
finally {
|
|
@@ -644,6 +709,7 @@ async function postApproved(batchId, plan) {
|
|
|
644
709
|
});
|
|
645
710
|
void flushSentry(2000);
|
|
646
711
|
}
|
|
712
|
+
void flushLogs();
|
|
647
713
|
return {
|
|
648
714
|
attempted: approved.length,
|
|
649
715
|
posted: realPosted,
|
|
@@ -2630,6 +2696,11 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
|
|
|
2630
2696
|
}));
|
|
2631
2697
|
async function main() {
|
|
2632
2698
|
initSentry();
|
|
2699
|
+
// Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
|
|
2700
|
+
// Cloud Run relay (-> Cloud Logging) so we can troubleshoot/rescue any user
|
|
2701
|
+
// scenario (silent stalls, partial onboarding) without asking them to ship a
|
|
2702
|
+
// log file. Best-effort; disabled with SAPS_LOG_STREAM=0.
|
|
2703
|
+
startLogStreaming();
|
|
2633
2704
|
// A plugin UPDATE refreshes this server (dist/) but not the materialized
|
|
2634
2705
|
// pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
|
|
2635
2706
|
// disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
|
|
@@ -2684,6 +2755,7 @@ async function main() {
|
|
|
2684
2755
|
main().catch(async (err) => {
|
|
2685
2756
|
console.error("[social-autoposter-mcp] fatal:", err);
|
|
2686
2757
|
captureError(err, { component: "main" });
|
|
2758
|
+
await flushLogs();
|
|
2687
2759
|
await flushSentry();
|
|
2688
2760
|
process.exit(1);
|
|
2689
2761
|
});
|
package/mcp/dist/onboarding.js
CHANGED
|
@@ -144,7 +144,12 @@ export function flushOnboardingEvents() {
|
|
|
144
144
|
error: "installation identity unavailable",
|
|
145
145
|
};
|
|
146
146
|
}
|
|
147
|
-
|
|
147
|
+
// Onboarding milestones go to the CLOUD RUN host (AUTOPOSTER_LOG_BASE,
|
|
148
|
+
// default app.s4l.ai), the same GCP-logging lane as the raw log stream: the
|
|
149
|
+
// relay console.log()s each event so Cloud Run's runtime ships it to Cloud
|
|
150
|
+
// Logging. NOT the Vercel host (AUTOPOSTER_API_BASE / s4l.ai) the heartbeat
|
|
151
|
+
// still uses — these events are not a DB row anymore.
|
|
152
|
+
const base = (process.env.AUTOPOSTER_LOG_BASE || "https://app.s4l.ai").replace(/\/+$/, "");
|
|
148
153
|
let sent = 0;
|
|
149
154
|
// Re-read after every batch. This catches milestone events appended while a
|
|
150
155
|
// prior network request was in flight, so the final onboarding event is not
|
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/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,168 @@ 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 Cloud Run relay, which simply
|
|
108
|
+
// console.log()s each line so Cloud Run's runtime ships it to Cloud Logging.
|
|
109
|
+
// No database, no service-account key on the client — the relay is the only
|
|
110
|
+
// thing authenticated to GCP, and it authenticates implicitly via its Cloud
|
|
111
|
+
// Run runtime identity. Lines are buffered in memory and flushed in small
|
|
112
|
+
// batches under the same X-Installation identity the heartbeat uses.
|
|
113
|
+
//
|
|
114
|
+
// Best-effort: NEVER throws into the server, never blocks the child's I/O, and
|
|
115
|
+
// drops on overflow rather than growing unbounded. Disable with
|
|
116
|
+
// SAPS_LOG_STREAM=0.
|
|
117
|
+
//
|
|
118
|
+
// IMPORTANT: logs go to the CLOUD RUN host (AUTOPOSTER_LOG_BASE, default
|
|
119
|
+
// app.s4l.ai), NOT the Vercel host (AUTOPOSTER_API_BASE / s4l.ai) the heartbeat
|
|
120
|
+
// and onboarding-events use. Cloud Run's native stdout -> Cloud Logging path is
|
|
121
|
+
// the whole point of this lane.
|
|
122
|
+
const LOG_STREAM_ENABLED = process.env.SAPS_LOG_STREAM !== "0";
|
|
123
|
+
const LOG_MAX_LINE_LEN = 8192; // mirror the relay cap
|
|
124
|
+
const LOG_MAX_BUFFER = 1000; // drop oldest beyond this (overflow protection)
|
|
125
|
+
const LOG_FLUSH_BATCH = 100; // flush eagerly once we have this many lines
|
|
126
|
+
const LOG_MAX_PER_POST = 200; // relay accepts 1-200 per request
|
|
127
|
+
const LOG_FLUSH_MS = 3000; // otherwise flush on this cadence
|
|
128
|
+
// Drop genuinely useless high-volume lines before they ever buffer, so a chatty
|
|
129
|
+
// run doesn't crowd out the signal (and to keep Cloud Logging volume sane).
|
|
130
|
+
// Empty/whitespace-only lines plus an env-extensible regex of obvious dump
|
|
131
|
+
// signatures. Deliberately conservative: real pipeline output is the value, so
|
|
132
|
+
// we only filter clear noise. Extend via SAPS_LOG_NOISE_RE (a JS regex source).
|
|
133
|
+
let logNoiseRe = null;
|
|
134
|
+
try {
|
|
135
|
+
const extra = (process.env.SAPS_LOG_NOISE_RE || "").trim();
|
|
136
|
+
// Default signatures: a bare `ps`/`launchctl`-style table dump row is rare in
|
|
137
|
+
// pipeline output but floods when an agent shells one in. Keep this tight.
|
|
138
|
+
const sources = [
|
|
139
|
+
extra,
|
|
140
|
+
].filter(Boolean);
|
|
141
|
+
logNoiseRe = sources.length ? new RegExp(sources.join("|")) : null;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
logNoiseRe = null;
|
|
145
|
+
}
|
|
146
|
+
function isNoise(line) {
|
|
147
|
+
if (!line || !line.trim())
|
|
148
|
+
return true; // blank / whitespace-only
|
|
149
|
+
if (logNoiseRe && logNoiseRe.test(line))
|
|
150
|
+
return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const logBuffer = [];
|
|
154
|
+
let logDropped = 0; // count of lines dropped on overflow (surfaced periodically)
|
|
155
|
+
let logFlushing = false;
|
|
156
|
+
let logTimer;
|
|
157
|
+
let cachedInstallHeader = null;
|
|
158
|
+
let logStreamingStarted = false;
|
|
159
|
+
async function installHeader() {
|
|
160
|
+
if (cachedInstallHeader)
|
|
161
|
+
return cachedInstallHeader;
|
|
162
|
+
try {
|
|
163
|
+
const idScript = path.join(repoDir(), "scripts", "identity.py");
|
|
164
|
+
if (!fs.existsSync(idScript))
|
|
165
|
+
return null;
|
|
166
|
+
const res = await runPython("scripts/identity.py", ["header"], { timeoutMs: 10_000 });
|
|
167
|
+
const header = (res.stdout || "").trim();
|
|
168
|
+
if (res.code === 0 && header) {
|
|
169
|
+
cachedInstallHeader = header;
|
|
170
|
+
return header;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* best-effort */
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
// Buffer one raw line. Called from the repo.ts line sink, so it must be cheap
|
|
179
|
+
// and total non-throwing.
|
|
180
|
+
export function logLine(stream, line, context) {
|
|
181
|
+
if (!LOG_STREAM_ENABLED)
|
|
182
|
+
return;
|
|
183
|
+
try {
|
|
184
|
+
if (isNoise(line))
|
|
185
|
+
return;
|
|
186
|
+
logBuffer.push({
|
|
187
|
+
ts: new Date().toISOString(),
|
|
188
|
+
stream,
|
|
189
|
+
line: line.length > LOG_MAX_LINE_LEN ? line.slice(0, LOG_MAX_LINE_LEN) : line,
|
|
190
|
+
context: context || "",
|
|
191
|
+
});
|
|
192
|
+
if (logBuffer.length > LOG_MAX_BUFFER) {
|
|
193
|
+
// Drop oldest to bound memory; the newest lines are the most useful.
|
|
194
|
+
logDropped += logBuffer.length - LOG_MAX_BUFFER;
|
|
195
|
+
logBuffer.splice(0, logBuffer.length - LOG_MAX_BUFFER);
|
|
196
|
+
}
|
|
197
|
+
if (logBuffer.length >= LOG_FLUSH_BATCH)
|
|
198
|
+
void flushLogs();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* never throw into the run() boundary */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export async function flushLogs() {
|
|
205
|
+
if (!LOG_STREAM_ENABLED)
|
|
206
|
+
return;
|
|
207
|
+
if (logFlushing || logBuffer.length === 0)
|
|
208
|
+
return;
|
|
209
|
+
logFlushing = true;
|
|
210
|
+
try {
|
|
211
|
+
const header = await installHeader();
|
|
212
|
+
if (!header)
|
|
213
|
+
return; // runtime not unpacked yet; keep buffering
|
|
214
|
+
// Cloud Run relay host (NOT the Vercel API host). app.s4l.ai serves
|
|
215
|
+
// bin/server.js, whose POST /api/v1/installations/logs console.log()s each
|
|
216
|
+
// line into Cloud Logging.
|
|
217
|
+
const base = (process.env.AUTOPOSTER_LOG_BASE || "https://app.s4l.ai").replace(/\/+$/, "");
|
|
218
|
+
// Drain in <=200-line POSTs until the buffer empties (or a POST fails).
|
|
219
|
+
while (logBuffer.length > 0) {
|
|
220
|
+
const batch = logBuffer.splice(0, LOG_MAX_PER_POST);
|
|
221
|
+
const lines = batch.map((b) => ({
|
|
222
|
+
ts: b.ts,
|
|
223
|
+
stream: b.stream,
|
|
224
|
+
line: b.line,
|
|
225
|
+
context: b.context || undefined,
|
|
226
|
+
}));
|
|
227
|
+
try {
|
|
228
|
+
const resp = await fetch(`${base}/api/v1/installations/logs`, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "X-Installation": header, "content-type": "application/json" },
|
|
231
|
+
body: JSON.stringify({ lines }),
|
|
232
|
+
signal: AbortSignal.timeout(15_000),
|
|
233
|
+
});
|
|
234
|
+
if (!resp.ok) {
|
|
235
|
+
// Drop this batch (don't re-buffer): a persistent 4xx/5xx would grow
|
|
236
|
+
// the buffer unbounded. The raw stream is best-effort.
|
|
237
|
+
console.error(`[social-autoposter-mcp] log flush http ${resp.status}`);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
// Network blip: drop this batch, stop draining, try again next tick.
|
|
243
|
+
console.error("[social-autoposter-mcp] log flush failed:", err?.message || err);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (logDropped > 0) {
|
|
248
|
+
console.error(`[social-autoposter-mcp] log stream dropped ${logDropped} line(s) on overflow`);
|
|
249
|
+
logDropped = 0;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
logFlushing = false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Register the repo.ts line sink and start the periodic flush. Idempotent.
|
|
257
|
+
export function startLogStreaming() {
|
|
258
|
+
if (!LOG_STREAM_ENABLED || logStreamingStarted)
|
|
259
|
+
return;
|
|
260
|
+
logStreamingStarted = true;
|
|
261
|
+
try {
|
|
262
|
+
setLineSink((line, stream, context) => logLine(stream, line, context));
|
|
263
|
+
logTimer = setInterval(() => void flushLogs(), LOG_FLUSH_MS);
|
|
264
|
+
logTimer.unref();
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
console.error("[social-autoposter-mcp] log streaming start failed:", err?.message || err);
|
|
268
|
+
}
|
|
269
|
+
}
|
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.91",
|
|
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": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Corner pop-up review cards for draft approval (AppKit / pyobjc).
|
|
2
2
|
|
|
3
|
-
`present_review(drafts, on_complete)` shows one small floating panel
|
|
4
|
-
the top-right corner: thread context, an EDITABLE reply field, a
|
|
5
|
-
Reject / Approve
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
`present_review(drafts, on_decision, on_complete)` shows one small floating panel
|
|
4
|
+
per draft in the top-right corner: thread context, an EDITABLE reply field, a
|
|
5
|
+
counter, and Reject / Approve. `on_decision` fires the INSTANT each card is
|
|
6
|
+
approved/rejected (so an approved draft can post right away), and `on_complete`
|
|
7
|
+
fires once the last card is decided or the window is closed. The whole AppKit
|
|
8
|
+
surface is isolated behind that one function so the menu bar wiring doesn't
|
|
9
|
+
depend on the windowing details.
|
|
8
10
|
|
|
9
11
|
Decision shape: {"n": int, "approved": bool, "text": str, "edited": bool}
|
|
10
12
|
|
|
@@ -70,11 +72,12 @@ def _label(frame, text, *, size=12, bold=False, muted=False):
|
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
class _ReviewController(NSObject):
|
|
73
|
-
def
|
|
75
|
+
def initWithDrafts_onDecision_onComplete_(self, drafts, on_decision, on_complete):
|
|
74
76
|
self = objc.super(_ReviewController, self).init()
|
|
75
77
|
if self is None:
|
|
76
78
|
return None
|
|
77
79
|
self._drafts = list(drafts)
|
|
80
|
+
self._on_decision = on_decision
|
|
78
81
|
self._on_complete = on_complete
|
|
79
82
|
self._idx = 0
|
|
80
83
|
self._decisions = []
|
|
@@ -218,13 +221,29 @@ class _ReviewController(NSObject):
|
|
|
218
221
|
else:
|
|
219
222
|
self._render()
|
|
220
223
|
|
|
224
|
+
@objc.python_method
|
|
225
|
+
def _fire_decision(self):
|
|
226
|
+
# Fire the per-card callback the instant a decision is made, so an
|
|
227
|
+
# approved draft starts posting immediately instead of waiting for the
|
|
228
|
+
# whole batch to be reviewed. A throwing callback must never break the
|
|
229
|
+
# card flow (or the panel would wedge on the current card).
|
|
230
|
+
cb = self._on_decision
|
|
231
|
+
if cb is None or not self._decisions:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
cb(dict(self._decisions[-1]))
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
221
238
|
# ObjC selectors (trailing underscore -> "approve:" etc.)
|
|
222
239
|
def approve_(self, sender):
|
|
223
240
|
self._record(True)
|
|
241
|
+
self._fire_decision()
|
|
224
242
|
self._advance()
|
|
225
243
|
|
|
226
244
|
def reject_(self, sender):
|
|
227
245
|
self._record(False)
|
|
246
|
+
self._fire_decision()
|
|
228
247
|
self._advance()
|
|
229
248
|
|
|
230
249
|
def windowShouldClose_(self, sender):
|
|
@@ -252,12 +271,17 @@ class _ReviewController(NSObject):
|
|
|
252
271
|
_active = None
|
|
253
272
|
|
|
254
273
|
|
|
255
|
-
def present_review(drafts, on_complete):
|
|
274
|
+
def present_review(drafts, on_decision=None, on_complete=None):
|
|
256
275
|
"""Show the review cards (main thread only). drafts: list of
|
|
257
|
-
{n, thread_author, thread_text, reply_text, link_url}.
|
|
258
|
-
fires
|
|
276
|
+
{n, thread_author, thread_text, reply_text, link_url}.
|
|
277
|
+
on_decision(decision) fires the instant each card is approved/rejected (so an
|
|
278
|
+
approved draft posts right away); on_complete(decisions) fires when the user
|
|
279
|
+
finishes the last card or closes the window. Both run on the main thread."""
|
|
259
280
|
global _active
|
|
260
281
|
if not drafts:
|
|
261
|
-
on_complete
|
|
282
|
+
if on_complete is not None:
|
|
283
|
+
on_complete([])
|
|
262
284
|
return
|
|
263
|
-
_active = _ReviewController.alloc().
|
|
285
|
+
_active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_(
|
|
286
|
+
drafts, on_decision, on_complete
|
|
287
|
+
)
|
|
@@ -18,6 +18,7 @@ rather than rumps.notification (which needs a bundle id).
|
|
|
18
18
|
|
|
19
19
|
import json
|
|
20
20
|
import os
|
|
21
|
+
import queue
|
|
21
22
|
import subprocess
|
|
22
23
|
import sys
|
|
23
24
|
import tempfile
|
|
@@ -152,6 +153,17 @@ class S4LMenuBar(rumps.App):
|
|
|
152
153
|
# later batch for the life of this process (only a restart cleared it),
|
|
153
154
|
# which is exactly the "drafts queued but no cards" bug.
|
|
154
155
|
self._last_review_sig = None
|
|
156
|
+
# Per-card posting. Each approved card posts the INSTANT it's approved,
|
|
157
|
+
# serialized through one persistent worker so two posts never drive the
|
|
158
|
+
# shared harness Chrome at once (the poster lock fails a concurrent peer
|
|
159
|
+
# after 45s rather than queuing it, which would land the 2nd card 0/N).
|
|
160
|
+
# `_review_active` stays true while the panel is open OR posts are still
|
|
161
|
+
# draining, so a half-posted set is never re-presented as fresh cards.
|
|
162
|
+
self._post_q = queue.Queue()
|
|
163
|
+
self._post_worker = None
|
|
164
|
+
self._review_lock = threading.Lock()
|
|
165
|
+
self._panel_open = False
|
|
166
|
+
self._posts_outstanding = 0
|
|
155
167
|
self._spin_i = 0
|
|
156
168
|
self._spinner = None # fast rumps.Timer animating the title while busy
|
|
157
169
|
# Reliable self-check of our own Accessibility (TCC) grant — this is the
|
|
@@ -467,11 +479,14 @@ class S4LMenuBar(rumps.App):
|
|
|
467
479
|
if sig == self._last_review_sig:
|
|
468
480
|
return
|
|
469
481
|
self._review_active = True
|
|
482
|
+
self._panel_open = True
|
|
470
483
|
try:
|
|
471
484
|
import s4l_card
|
|
472
485
|
|
|
473
486
|
s4l_card.present_review(
|
|
474
|
-
drafts,
|
|
487
|
+
drafts,
|
|
488
|
+
on_decision=lambda d: self._on_card_decision(batch, d),
|
|
489
|
+
on_complete=lambda decisions: self._on_review_closed(batch, decisions),
|
|
475
490
|
)
|
|
476
491
|
# Record as shown only AFTER the cards are actually up, so a transient
|
|
477
492
|
# card-UI failure never permanently suppresses this pending set.
|
|
@@ -479,41 +494,80 @@ class S4LMenuBar(rumps.App):
|
|
|
479
494
|
except Exception as e:
|
|
480
495
|
# Card UI unavailable — don't strand the batch; chat review still works.
|
|
481
496
|
self._review_active = False
|
|
497
|
+
self._panel_open = False
|
|
482
498
|
sys.stderr.write(f"[s4l-menubar] review cards failed: {e}\n")
|
|
483
499
|
sys.stderr.flush()
|
|
484
500
|
_capture(e, phase="review_cards")
|
|
485
501
|
|
|
486
|
-
def
|
|
487
|
-
# Runs on the main thread
|
|
488
|
-
#
|
|
489
|
-
#
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
502
|
+
def _on_card_decision(self, batch, decision):
|
|
503
|
+
# Runs on the main thread the INSTANT a card is approved/rejected. An
|
|
504
|
+
# approved card is enqueued for immediate posting; a rejected card does
|
|
505
|
+
# nothing. We never post inline here — posting can take minutes and would
|
|
506
|
+
# freeze the card UI while the user reviews the rest of the stack.
|
|
507
|
+
if not decision.get("approved"):
|
|
508
|
+
return
|
|
509
|
+
with self._review_lock:
|
|
510
|
+
self._posts_outstanding += 1
|
|
511
|
+
self._review_active = True
|
|
512
|
+
self._post_q.put((batch, decision))
|
|
513
|
+
self._ensure_post_worker()
|
|
514
|
+
|
|
515
|
+
def _on_review_closed(self, batch, decisions):
|
|
516
|
+
# Fires when the card sequence ends (last card decided or window closed).
|
|
517
|
+
# The panel is gone, but approved cards may still be draining — keep the
|
|
518
|
+
# review "active" until the queue empties so the not-yet-posted remainder
|
|
519
|
+
# isn't re-presented as a fresh batch.
|
|
520
|
+
with self._review_lock:
|
|
521
|
+
self._panel_open = False
|
|
522
|
+
if self._posts_outstanding <= 0:
|
|
523
|
+
self._review_active = False
|
|
493
524
|
st.clear_review_request()
|
|
494
|
-
if not approved:
|
|
495
|
-
self._review_active = False
|
|
525
|
+
if not any(d.get("approved") for d in decisions):
|
|
496
526
|
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
497
|
-
return
|
|
498
|
-
self._notify("S4L", f"Posting {len(approved)} draft(s)…")
|
|
499
|
-
|
|
500
|
-
# The server's post_drafts writes "posting" activity, so the activity
|
|
501
|
-
# spinner shows automatically while this runs — no local spinner needed.
|
|
502
|
-
def work():
|
|
503
|
-
res = st.post_drafts(batch, post=post_nums, edits=edits)
|
|
504
|
-
if res is None:
|
|
505
|
-
self._notify(
|
|
506
|
-
"S4L", "Couldn't post — open Claude Desktop and try the draft again."
|
|
507
|
-
)
|
|
508
|
-
else:
|
|
509
|
-
posted = res.get("posted") if isinstance(res, dict) else None
|
|
510
|
-
self._notify(
|
|
511
|
-
"S4L",
|
|
512
|
-
f"Posted {posted if posted is not None else len(approved)} draft(s).",
|
|
513
|
-
)
|
|
514
|
-
self._review_active = False
|
|
515
527
|
|
|
516
|
-
|
|
528
|
+
def _ensure_post_worker(self):
|
|
529
|
+
# One persistent daemon worker drains the approved-card queue. It never
|
|
530
|
+
# exits (avoids an enqueue-vs-exit race) — an idle parked thread is cheap.
|
|
531
|
+
if self._post_worker is not None and self._post_worker.is_alive():
|
|
532
|
+
return
|
|
533
|
+
self._post_worker = threading.Thread(target=self._post_worker_loop, daemon=True)
|
|
534
|
+
self._post_worker.start()
|
|
535
|
+
|
|
536
|
+
def _post_worker_loop(self):
|
|
537
|
+
# Serialized poster: one approved card at a time so two posts never drive
|
|
538
|
+
# the shared harness Chrome simultaneously. The server's post_drafts writes
|
|
539
|
+
# "posting" activity, so the menu-bar spinner shows automatically.
|
|
540
|
+
while True:
|
|
541
|
+
batch, decision = self._post_q.get() # blocks until a card is approved
|
|
542
|
+
n = decision.get("n")
|
|
543
|
+
try:
|
|
544
|
+
self._notify("S4L", f"Posting draft {n}…")
|
|
545
|
+
if decision.get("edited"):
|
|
546
|
+
res = st.post_drafts(
|
|
547
|
+
batch, edits=[{"n": n, "text": decision.get("text") or ""}]
|
|
548
|
+
)
|
|
549
|
+
else:
|
|
550
|
+
res = st.post_drafts(batch, post=[n])
|
|
551
|
+
if res is None:
|
|
552
|
+
self._notify(
|
|
553
|
+
"S4L", "Couldn't post — open Claude Desktop and try the draft again."
|
|
554
|
+
)
|
|
555
|
+
else:
|
|
556
|
+
posted = res.get("posted") if isinstance(res, dict) else None
|
|
557
|
+
if posted == 0:
|
|
558
|
+
self._notify("S4L", f"Draft {n} didn't post — see the dashboard for why.")
|
|
559
|
+
else:
|
|
560
|
+
self._notify("S4L", f"Posted draft {n}.")
|
|
561
|
+
except Exception as e:
|
|
562
|
+
sys.stderr.write(f"[s4l-menubar] post draft {n} failed: {e}\n")
|
|
563
|
+
sys.stderr.flush()
|
|
564
|
+
_capture(e, phase="post_card")
|
|
565
|
+
finally:
|
|
566
|
+
with self._review_lock:
|
|
567
|
+
self._posts_outstanding -= 1
|
|
568
|
+
if self._posts_outstanding <= 0 and not self._panel_open:
|
|
569
|
+
self._review_active = False
|
|
570
|
+
self._post_q.task_done()
|
|
517
571
|
|
|
518
572
|
def _render_title(self, setup_complete, ob, blocker):
|
|
519
573
|
if blocker:
|
|
@@ -641,9 +695,10 @@ class S4LMenuBar(rumps.App):
|
|
|
641
695
|
rumps.MenuItem("Run draft cycle in Claude", callback=self._draft)
|
|
642
696
|
)
|
|
643
697
|
# No "Post approved drafts" item: approving a review card already posts
|
|
644
|
-
# directly + programmatically (
|
|
645
|
-
# CDP poster). A menu button
|
|
646
|
-
#
|
|
698
|
+
# that card directly + programmatically (_on_card_decision -> queue ->
|
|
699
|
+
# _post_worker_loop -> st.post_drafts -> the CDP poster). A menu button
|
|
700
|
+
# that types a prompt into the chat to do the same thing was a redundant
|
|
701
|
+
# detour through the model, so it's gone.
|
|
647
702
|
return out
|
|
648
703
|
|
|
649
704
|
|
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.91",
|
|
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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Programmatic equivalent of the menu-bar "Please update now" button
|
|
3
|
+
# (mcp/menubar/s4l_menubar.py::_mcpb_update_work). Pulls the latest .mcpb from
|
|
4
|
+
# GitHub releases, unzips it over the Claude Desktop extension dir in place, and
|
|
5
|
+
# restarts Claude so the new MCP server loads. Designed to be run over SSH on a
|
|
6
|
+
# .mcpb box (e.g. `ssh macstadium 'bash -s' < scripts/s4l_box_update.sh`), where
|
|
7
|
+
# npm/npx is absent so the `runtime action:update` (npx) path is dead.
|
|
8
|
+
#
|
|
9
|
+
# Flags:
|
|
10
|
+
# --check Print installed vs latest and exit (no download, no restart).
|
|
11
|
+
# --no-restart Download + unpack the new .mcpb but do NOT restart Claude.
|
|
12
|
+
# (default) Download + unpack + restart Claude.
|
|
13
|
+
#
|
|
14
|
+
# Exits: 0 ok / already current, 2 download failed, 3 unpack failed, 4 no install.
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
EXT_DIR="$HOME/Library/Application Support/Claude/Claude Extensions/local.mcpb.m13v.social-autoposter"
|
|
18
|
+
MCPB_URL="https://github.com/m13v/social-autoposter/releases/latest/download/social-autoposter.mcpb"
|
|
19
|
+
RELEASE_API="https://api.github.com/repos/m13v/social-autoposter/releases/latest"
|
|
20
|
+
PY="/usr/bin/python3"
|
|
21
|
+
|
|
22
|
+
mode="run"
|
|
23
|
+
case "${1:-}" in
|
|
24
|
+
--check) mode="check" ;;
|
|
25
|
+
--no-restart) mode="no-restart" ;;
|
|
26
|
+
"") mode="run" ;;
|
|
27
|
+
*) echo "unknown flag: $1" >&2; exit 64 ;;
|
|
28
|
+
esac
|
|
29
|
+
|
|
30
|
+
[ -f "$EXT_DIR/manifest.json" ] || { echo "no .mcpb install at $EXT_DIR" >&2; exit 4; }
|
|
31
|
+
|
|
32
|
+
installed="$("$PY" -c "import json,sys;print((json.load(open(sys.argv[1])) or {}).get('version',''))" "$EXT_DIR/manifest.json" 2>/dev/null || true)"
|
|
33
|
+
latest_tag="$(curl -fsSL -m 15 "$RELEASE_API" | "$PY" -c "import sys,json;print((json.load(sys.stdin) or {}).get('tag_name',''))" 2>/dev/null || true)"
|
|
34
|
+
latest="${latest_tag#v}"
|
|
35
|
+
echo "installed=$installed latest=$latest"
|
|
36
|
+
|
|
37
|
+
if [ "$mode" = "check" ]; then
|
|
38
|
+
[ -n "$latest" ] && [ "$installed" != "$latest" ] && echo "update_available=true" || echo "update_available=false"
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
if [ -n "$latest" ] && [ "$installed" = "$latest" ]; then
|
|
43
|
+
echo "already on latest ($installed); re-applying anyway would just restart Claude. skipping."
|
|
44
|
+
# Comment the next line out if you want a forced re-unpack even when current.
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
tmpd="$(mktemp -d -t s4l-update-XXXXXX)"
|
|
49
|
+
trap 'rm -rf "$tmpd"' EXIT
|
|
50
|
+
mcpb="$tmpd/social-autoposter.mcpb"
|
|
51
|
+
|
|
52
|
+
echo "downloading $MCPB_URL ..."
|
|
53
|
+
curl -fLs -m 300 "$MCPB_URL" -o "$mcpb" || { echo "download failed" >&2; exit 2; }
|
|
54
|
+
sz=$(stat -f%z "$mcpb" 2>/dev/null || echo 0)
|
|
55
|
+
[ "$sz" -ge 100000 ] || { echo "download too small ($sz bytes), aborting" >&2; exit 2; }
|
|
56
|
+
|
|
57
|
+
echo "unpacking into extension dir ..."
|
|
58
|
+
unzip -oq "$mcpb" -d "$EXT_DIR" || { echo "unpack failed" >&2; exit 3; }
|
|
59
|
+
new_ver="$("$PY" -c "import json,sys;print((json.load(open(sys.argv[1])) or {}).get('version',''))" "$EXT_DIR/manifest.json" 2>/dev/null || true)"
|
|
60
|
+
echo "unpacked version=$new_ver"
|
|
61
|
+
|
|
62
|
+
if [ "$mode" = "no-restart" ]; then
|
|
63
|
+
echo "done (no restart requested); restart Claude to load v$new_ver."
|
|
64
|
+
exit 0
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Restart Claude. From an SSH session we skip the osascript graceful-quit the
|
|
68
|
+
# menu bar uses (it can trip an Automation TCC prompt for sshd and block
|
|
69
|
+
# unattended); killall sends SIGTERM and needs no automation grant.
|
|
70
|
+
echo "restarting Claude ..."
|
|
71
|
+
killall Claude 2>/dev/null || true
|
|
72
|
+
sleep 4
|
|
73
|
+
killall -9 Claude 2>/dev/null || true
|
|
74
|
+
sleep 1
|
|
75
|
+
open -a Claude 2>/dev/null || true
|
|
76
|
+
echo "done; Claude restarting on v$new_ver."
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# s4l-ctl: programmatic control of the running social-autoposter (S4L) plugin via
|
|
3
|
+
# its loopback tool server, for QA. Runs the same handlers as the in-chat MCP
|
|
4
|
+
# tools (POST /tool/<name>). MUST run ON the box (the loopback is 127.0.0.1-only),
|
|
5
|
+
# so over SSH use: ssh macstadium 'bash -s -- <subcommand> [args]' < scripts/s4l_ctl.sh
|
|
6
|
+
#
|
|
7
|
+
# Subcommands:
|
|
8
|
+
# status Dashboard snapshot (read-only).
|
|
9
|
+
# count Number of pending (unposted) drafts (read-only).
|
|
10
|
+
# drafts List pending drafts with their 1-based numbers (read-only).
|
|
11
|
+
# approve <n> [n...] Post the given card numbers. DESTRUCTIVE — requires --yes.
|
|
12
|
+
# approve-all Post EVERY pending card. DESTRUCTIVE — requires --yes.
|
|
13
|
+
#
|
|
14
|
+
# DESTRUCTIVE note: "approve" really posts replies to live X/Twitter threads. The
|
|
15
|
+
# write subcommands refuse to run unless --yes is present (no interactive prompt,
|
|
16
|
+
# because over piped SSH there is no tty). For host-level plugin UPDATE use the
|
|
17
|
+
# separate scripts/s4l_box_update.sh (different mechanism: works even when the
|
|
18
|
+
# loopback is down, and it restarts Claude).
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
BATCH="review-queue"
|
|
22
|
+
PLAN="/tmp/twitter_cycle_plan_${BATCH}.json"
|
|
23
|
+
EP="$HOME/.social-autoposter-mcp/panel-endpoint.json"
|
|
24
|
+
PY="/usr/bin/python3"
|
|
25
|
+
|
|
26
|
+
YES=0; ARGS=()
|
|
27
|
+
for a in "$@"; do
|
|
28
|
+
case "$a" in --yes|-y) YES=1 ;; *) ARGS+=("$a") ;; esac
|
|
29
|
+
done
|
|
30
|
+
set -- ${ARGS[@]+"${ARGS[@]}"}
|
|
31
|
+
cmd="${1:-}"; [ $# -gt 0 ] && shift || true
|
|
32
|
+
|
|
33
|
+
[ -f "$EP" ] || { echo "no panel-endpoint.json — is Claude Desktop / the MCP running?" >&2; exit 1; }
|
|
34
|
+
URL="$("$PY" -c "import json;print(json.load(open('$EP'))['url'])")"
|
|
35
|
+
curl -s -m 3 "${URL}health" >/dev/null || { echo "loopback unreachable at $URL" >&2; exit 1; }
|
|
36
|
+
|
|
37
|
+
tool() { curl -s -m "${2:-900}" -X POST "${URL}tool/$1" -H 'Content-Type: application/json' -d "${3:-{}}"; }
|
|
38
|
+
|
|
39
|
+
pending_count() {
|
|
40
|
+
[ -f "$PLAN" ] || { echo 0; return; }
|
|
41
|
+
"$PY" -c "import json;d=json.load(open('$PLAN'));print(sum(1 for c in d.get('candidates',[]) if not c.get('posted')))"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
case "$cmd" in
|
|
45
|
+
status)
|
|
46
|
+
tool dashboard 20 ; echo ;;
|
|
47
|
+
count)
|
|
48
|
+
echo "pending=$(pending_count)" ;;
|
|
49
|
+
drafts)
|
|
50
|
+
if [ ! -f "$PLAN" ]; then echo "no review-queue plan on box (0 drafts)"; exit 0; fi
|
|
51
|
+
"$PY" - "$PLAN" <<'PYEOF'
|
|
52
|
+
import json,sys
|
|
53
|
+
d=json.load(open(sys.argv[1]))
|
|
54
|
+
for i,c in enumerate(d.get("candidates",[]),1):
|
|
55
|
+
if c.get("posted"): continue
|
|
56
|
+
txt=(c.get("reply_text") or "").replace("\n"," ")
|
|
57
|
+
print(f"#{i:<4} @{(c.get('thread_author') or '?'):<18} {txt[:90]}")
|
|
58
|
+
PYEOF
|
|
59
|
+
echo "pending=$(pending_count)" ;;
|
|
60
|
+
approve)
|
|
61
|
+
[ $# -ge 1 ] || { echo "usage: approve <n> [n...] --yes" >&2; exit 64; }
|
|
62
|
+
nums="$(printf '%s\n' "$@" | paste -sd, -)"
|
|
63
|
+
if [ "$YES" != "1" ]; then
|
|
64
|
+
echo "REFUSING: 'approve $*' will POST those cards to live X. Re-run with --yes to confirm." >&2; exit 3; fi
|
|
65
|
+
echo "posting cards [$nums] ..."
|
|
66
|
+
tool post_drafts 900 "{\"batch_id\":\"$BATCH\",\"post\":[$nums]}" ; echo ;;
|
|
67
|
+
approve-all)
|
|
68
|
+
n="$(pending_count)"
|
|
69
|
+
if [ "$YES" != "1" ]; then
|
|
70
|
+
echo "REFUSING: approve-all will POST all $n pending cards to live X. Re-run with --yes to confirm." >&2; exit 3; fi
|
|
71
|
+
echo "posting all $n pending cards ..."
|
|
72
|
+
tool post_drafts 1800 "{\"batch_id\":\"$BATCH\",\"post_all\":true}" ; echo ;;
|
|
73
|
+
*)
|
|
74
|
+
echo "usage: s4l_ctl.sh {status|count|drafts|approve <n...>|approve-all} [--yes]" >&2; exit 64 ;;
|
|
75
|
+
esac
|
|
@@ -1209,6 +1209,37 @@ def cmd_connect(args) -> dict:
|
|
|
1209
1209
|
}
|
|
1210
1210
|
|
|
1211
1211
|
|
|
1212
|
+
def cmd_resolve_handle(args) -> dict:
|
|
1213
|
+
"""Read the live logged-in @handle from the managed Chrome and persist it to
|
|
1214
|
+
config.json accounts.twitter.handle.
|
|
1215
|
+
|
|
1216
|
+
The MCP post preflight calls this to self-heal a missing handle — the onboarding
|
|
1217
|
+
gap where connect_x's best-effort live-DOM read silently no-op'd, leaving the
|
|
1218
|
+
install logged in but with accounts:null, so twitter_browser.py refused EVERY
|
|
1219
|
+
reply with no_account_configured. Reading the handle from the SAME session the
|
|
1220
|
+
poster posts through is ground truth, not a guess, so it's safe where a hardcoded
|
|
1221
|
+
fallback would not be. Best-effort: returns state=browser_not_running / no_handle
|
|
1222
|
+
on failure and never raises."""
|
|
1223
|
+
try:
|
|
1224
|
+
ws, send = _attach()
|
|
1225
|
+
except Exception as e:
|
|
1226
|
+
return {"ok": False, "state": "browser_not_running", "error": str(e)}
|
|
1227
|
+
handle = None
|
|
1228
|
+
try:
|
|
1229
|
+
handle = _resolve_live_handle(send)
|
|
1230
|
+
except Exception:
|
|
1231
|
+
handle = None
|
|
1232
|
+
finally:
|
|
1233
|
+
try:
|
|
1234
|
+
ws.close()
|
|
1235
|
+
except Exception:
|
|
1236
|
+
pass
|
|
1237
|
+
if not handle:
|
|
1238
|
+
return {"ok": False, "state": "no_handle"}
|
|
1239
|
+
persisted = _write_handle_to_config(handle)
|
|
1240
|
+
return {"ok": True, "state": "resolved", "handle": handle, "persisted": persisted}
|
|
1241
|
+
|
|
1242
|
+
|
|
1212
1243
|
def main() -> int:
|
|
1213
1244
|
ap = argparse.ArgumentParser(description="Twitter/X session bootstrap for MCP setup.")
|
|
1214
1245
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
@@ -1216,6 +1247,10 @@ def main() -> int:
|
|
|
1216
1247
|
sub.add_parser("detect-sources",
|
|
1217
1248
|
help="List browsers/profiles to import the X session from "
|
|
1218
1249
|
"(JSON, for the panel dropdown). No keychain prompt.")
|
|
1250
|
+
sub.add_parser("resolve-handle",
|
|
1251
|
+
help="Read the live logged-in @handle from the managed Chrome and "
|
|
1252
|
+
"persist it to config.json accounts.twitter.handle. Idempotent "
|
|
1253
|
+
"self-heal for the post preflight; never overwrites a real handle.")
|
|
1219
1254
|
c = sub.add_parser("connect", help="Ensure browser + import/validate the X session.")
|
|
1220
1255
|
c.add_argument("--source", default=None,
|
|
1221
1256
|
help="Browser profile to import from (e.g. chrome:Default, arc:Default), "
|
|
@@ -1244,6 +1279,8 @@ def main() -> int:
|
|
|
1244
1279
|
out = {"ok": False, "state": "error", "error": _WEBSOCKET_IMPORT_ERROR}
|
|
1245
1280
|
elif args.cmd == "status":
|
|
1246
1281
|
out = cmd_status(args)
|
|
1282
|
+
elif args.cmd == "resolve-handle":
|
|
1283
|
+
out = cmd_resolve_handle(args)
|
|
1247
1284
|
else:
|
|
1248
1285
|
out = cmd_connect(args)
|
|
1249
1286
|
print(json.dumps(out, indent=2))
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Headless logic test for per-card serialized posting in the menu bar.
|
|
2
|
+
|
|
3
|
+
Stubs the heavy deps (rumps / sentry_init / s4l_state) so s4l_menubar imports
|
|
4
|
+
without AppKit, then drives the REAL _on_card_decision + _post_worker_loop on an
|
|
5
|
+
instance built via object.__new__ (bypassing rumps.App.__init__). Verifies:
|
|
6
|
+
1. posts run strictly one-at-a-time (no overlap on the shared browser)
|
|
7
|
+
2. order is preserved (FIFO)
|
|
8
|
+
3. plain approvals -> post=[n]; edited approvals -> edits=[{n,text}]
|
|
9
|
+
4. rejected cards never post
|
|
10
|
+
5. _posts_outstanding / _review_active settle to idle once drained
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import types
|
|
18
|
+
|
|
19
|
+
HERE = os.path.join(os.path.dirname(__file__), "..", "mcp", "menubar")
|
|
20
|
+
sys.path.insert(0, os.path.abspath(HERE))
|
|
21
|
+
|
|
22
|
+
# --- stub heavy deps so the import is headless --------------------------------
|
|
23
|
+
rumps = types.ModuleType("rumps")
|
|
24
|
+
class _App:
|
|
25
|
+
def __init__(self, *a, **k):
|
|
26
|
+
pass
|
|
27
|
+
rumps.App = _App
|
|
28
|
+
rumps.Timer = lambda *a, **k: types.SimpleNamespace(start=lambda: None, stop=lambda: None)
|
|
29
|
+
rumps.MenuItem = lambda *a, **k: object()
|
|
30
|
+
rumps.separator = object()
|
|
31
|
+
rumps.notification = lambda *a, **k: None
|
|
32
|
+
sys.modules["rumps"] = rumps
|
|
33
|
+
|
|
34
|
+
sentry_init = types.ModuleType("sentry_init")
|
|
35
|
+
sentry_init.init_sentry = lambda *a, **k: None
|
|
36
|
+
sentry_init.capture = lambda *a, **k: None
|
|
37
|
+
sys.modules["sentry_init"] = sentry_init
|
|
38
|
+
|
|
39
|
+
# Track concurrency + record every post_drafts call.
|
|
40
|
+
overlap_detected = []
|
|
41
|
+
inflight = {"n": 0}
|
|
42
|
+
inflight_lock = threading.Lock()
|
|
43
|
+
calls = []
|
|
44
|
+
|
|
45
|
+
def fake_post_drafts(batch_id, post=None, edits=None, timeout=900):
|
|
46
|
+
with inflight_lock:
|
|
47
|
+
inflight["n"] += 1
|
|
48
|
+
if inflight["n"] > 1:
|
|
49
|
+
overlap_detected.append(True)
|
|
50
|
+
calls.append({"batch": batch_id, "post": post or [], "edits": edits or []})
|
|
51
|
+
time.sleep(0.15) # simulate a slow post so overlaps would be caught
|
|
52
|
+
with inflight_lock:
|
|
53
|
+
inflight["n"] -= 1
|
|
54
|
+
# mimic the real shape: posted count
|
|
55
|
+
n_posted = len(post or []) + len(edits or [])
|
|
56
|
+
return {"posted": n_posted}
|
|
57
|
+
|
|
58
|
+
st = types.ModuleType("s4l_state")
|
|
59
|
+
st.post_drafts = fake_post_drafts
|
|
60
|
+
st.accessibility_trusted = lambda: True
|
|
61
|
+
st.clear_review_request = lambda: None
|
|
62
|
+
sys.modules["s4l_state"] = st
|
|
63
|
+
|
|
64
|
+
import s4l_menubar # noqa: E402
|
|
65
|
+
|
|
66
|
+
# --- build an instance without running rumps.App.__init__ ---------------------
|
|
67
|
+
app = object.__new__(s4l_menubar.S4LMenuBar)
|
|
68
|
+
app._post_q = queue.Queue()
|
|
69
|
+
app._post_worker = None
|
|
70
|
+
app._review_lock = threading.Lock()
|
|
71
|
+
app._panel_open = True
|
|
72
|
+
app._posts_outstanding = 0
|
|
73
|
+
app._review_active = False
|
|
74
|
+
app._notify = lambda title, msg: None # silence Notification Center
|
|
75
|
+
|
|
76
|
+
BATCH = "review-queue"
|
|
77
|
+
|
|
78
|
+
# Approve a quick burst (as if the user clicked Approve on several cards fast),
|
|
79
|
+
# one edited, plus a rejected card that must NOT post.
|
|
80
|
+
decisions = [
|
|
81
|
+
{"n": 1, "approved": True, "text": "reply one", "edited": False},
|
|
82
|
+
{"n": 2, "approved": True, "text": "edited two", "edited": True},
|
|
83
|
+
{"n": 3, "approved": False, "text": "skip", "edited": False},
|
|
84
|
+
{"n": 4, "approved": True, "text": "reply four", "edited": False},
|
|
85
|
+
]
|
|
86
|
+
for d in decisions:
|
|
87
|
+
app._on_card_decision(BATCH, d)
|
|
88
|
+
time.sleep(0.02) # tight succession -> overlap would happen if not serialized
|
|
89
|
+
|
|
90
|
+
# Panel closes while posts may still be draining.
|
|
91
|
+
app._on_review_closed(BATCH, decisions)
|
|
92
|
+
|
|
93
|
+
# Wait for the queue to drain.
|
|
94
|
+
deadline = time.time() + 10
|
|
95
|
+
while time.time() < deadline:
|
|
96
|
+
with app._review_lock:
|
|
97
|
+
if app._posts_outstanding == 0 and app._post_q.empty():
|
|
98
|
+
break
|
|
99
|
+
time.sleep(0.05)
|
|
100
|
+
|
|
101
|
+
# --- assertions ---------------------------------------------------------------
|
|
102
|
+
fail = []
|
|
103
|
+
if overlap_detected:
|
|
104
|
+
fail.append(f"posts overlapped ({len(overlap_detected)} times) — not serialized")
|
|
105
|
+
posted_ns = [(c["post"], c["edits"]) for c in calls]
|
|
106
|
+
expected = [([1], []), ([], [{"n": 2, "text": "edited two"}]), ([4], [])]
|
|
107
|
+
if posted_ns != expected:
|
|
108
|
+
fail.append(f"wrong calls/order: got {posted_ns}\n expected {expected}")
|
|
109
|
+
if any(c["post"] == [3] or any(e.get("n") == 3 for e in c["edits"]) for c in calls):
|
|
110
|
+
fail.append("rejected card #3 was posted")
|
|
111
|
+
with app._review_lock:
|
|
112
|
+
if app._posts_outstanding != 0:
|
|
113
|
+
fail.append(f"_posts_outstanding leaked: {app._posts_outstanding}")
|
|
114
|
+
if app._review_active:
|
|
115
|
+
fail.append("_review_active stuck true after drain + panel closed")
|
|
116
|
+
|
|
117
|
+
if fail:
|
|
118
|
+
print("FAIL:")
|
|
119
|
+
for f in fail:
|
|
120
|
+
print(" -", f)
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
print("PASS: 3 posts, serialized, FIFO order, #3 skipped, flags settled idle.")
|
|
123
|
+
print(" calls:", posted_ns)
|