social-autoposter 1.6.91 → 1.6.92
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 +219 -47
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +22 -9
- package/mcp/menubar/s4l_state.py +5 -2
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/active_users.py +190 -0
package/mcp/dist/index.js
CHANGED
|
@@ -504,9 +504,13 @@ function renderDraftsTable(plan) {
|
|
|
504
504
|
const candidates = plan.candidates || [];
|
|
505
505
|
return candidates
|
|
506
506
|
// Number by FULL-array index (matches post_drafts + the menu bar), then drop
|
|
507
|
-
// already-
|
|
507
|
+
// already-finished entries so the cards only show what's still pending.
|
|
508
508
|
.map((c, i) => ({ c, n: i + 1 }))
|
|
509
|
-
.filter((e) => e.c.posted !== true)
|
|
509
|
+
.filter((e) => e.c.posted !== true && e.c.terminal !== true)
|
|
510
|
+
// The queue is append-only; newest drafts have the highest stable index.
|
|
511
|
+
// Show those first so review starts with likely-live tweets instead of stale
|
|
512
|
+
// low-number drafts that have been sitting around for hours.
|
|
513
|
+
.sort((a, b) => b.n - a.n)
|
|
510
514
|
.map(({ c, n }) => {
|
|
511
515
|
const author = c.thread_author ? `@${c.thread_author}` : "(unknown thread)";
|
|
512
516
|
const style = c.engagement_style ?? "?";
|
|
@@ -532,6 +536,52 @@ function renderDraftsTable(plan) {
|
|
|
532
536
|
})
|
|
533
537
|
.join("\n\n");
|
|
534
538
|
}
|
|
539
|
+
function parsePostCandidateResults(stdout) {
|
|
540
|
+
const byId = new Map();
|
|
541
|
+
const upsert = (candidateId, outcome, reason, ourUrl) => {
|
|
542
|
+
const prev = byId.get(candidateId);
|
|
543
|
+
// A landed post wins over any earlier noisy line for the same candidate.
|
|
544
|
+
if (prev?.outcome === "posted" && outcome !== "posted")
|
|
545
|
+
return;
|
|
546
|
+
byId.set(candidateId, {
|
|
547
|
+
candidate_id: candidateId,
|
|
548
|
+
outcome,
|
|
549
|
+
...(reason ? { reason } : {}),
|
|
550
|
+
...(ourUrl ? { our_url: ourUrl } : {}),
|
|
551
|
+
});
|
|
552
|
+
};
|
|
553
|
+
for (const line of stdout.split("\n")) {
|
|
554
|
+
let m = /\[post\] candidate (\d+) posted as (\S+) \(post_id=/.exec(line);
|
|
555
|
+
if (m) {
|
|
556
|
+
upsert(m[1], "posted", undefined, m[2]);
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
m = /\[post\] candidate (\d+): pre-post dedup hit\b/.exec(line);
|
|
560
|
+
if (m) {
|
|
561
|
+
upsert(m[1], "skipped", "duplicate_thread_pre_post");
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
m = /\[post\] candidate (\d+) reply failed: ([A-Za-z0-9_:-]+)/.exec(line);
|
|
565
|
+
if (m) {
|
|
566
|
+
upsert(m[1], "skipped", m[2]);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
m = /\[post\] candidate (\d+) reply succeeded but reply_url invalid:/.exec(line);
|
|
570
|
+
if (m) {
|
|
571
|
+
upsert(m[1], "skipped", "no_reply_url_captured");
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
m = /\[post\] candidate (\d+): empty reply_text; skipping/.exec(line);
|
|
575
|
+
if (m) {
|
|
576
|
+
upsert(m[1], "skipped", "empty_reply_text");
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
m = /\[post\] candidate (\d+) crashed:/.exec(line);
|
|
580
|
+
if (m)
|
|
581
|
+
upsert(m[1], "failed", "exception");
|
|
582
|
+
}
|
|
583
|
+
return [...byId.values()];
|
|
584
|
+
}
|
|
535
585
|
// Resolve the configured posting handle the SAME way account_resolver.py does:
|
|
536
586
|
// AUTOPOSTER_TWITTER_HANDLE env first, then config.json accounts.twitter.handle.
|
|
537
587
|
// Returns the bare handle (no @) or null. The post preflight uses it so a missing
|
|
@@ -642,9 +692,13 @@ async function postApproved(batchId, plan) {
|
|
|
642
692
|
});
|
|
643
693
|
}
|
|
644
694
|
finally {
|
|
645
|
-
//
|
|
695
|
+
// Don't free the lock immediately — hold it through a grace window so the
|
|
696
|
+
// NEXT approved card (a separate post_drafts call) reuses the same continuous
|
|
697
|
+
// hold, and a parked scan can't slip into the gap between cards. The timer
|
|
698
|
+
// releases it once posting truly stops (mirrors the plist holding the lock
|
|
699
|
+
// through the whole posting phase, then releasing at the end).
|
|
646
700
|
if (heldShellLock)
|
|
647
|
-
|
|
701
|
+
scheduleShellLockRelease();
|
|
648
702
|
}
|
|
649
703
|
})();
|
|
650
704
|
// Persist the poster's own stdout/stderr to a dated log. Without this the post
|
|
@@ -680,11 +734,57 @@ async function postApproved(batchId, plan) {
|
|
|
680
734
|
: res.code === 0 && !summObj
|
|
681
735
|
? approved.length
|
|
682
736
|
: 0;
|
|
683
|
-
// Mark
|
|
684
|
-
//
|
|
685
|
-
|
|
737
|
+
// Mark candidates according to the poster's per-candidate outcome. This keeps
|
|
738
|
+
// the review queue honest: posted drafts disappear as posted, terminal skips
|
|
739
|
+
// (dedup, deleted tweet, no captured URL) disappear without being counted as
|
|
740
|
+
// posted, and multi-approval batches no longer smear one posted count across
|
|
741
|
+
// every approved draft.
|
|
742
|
+
const resultRowsFromSummary = Array.isArray(summObj?.candidate_results)
|
|
743
|
+
? summObj?.candidate_results
|
|
744
|
+
: [];
|
|
745
|
+
const resultRows = resultRowsFromSummary.length
|
|
746
|
+
? resultRowsFromSummary
|
|
747
|
+
.map((r) => ({
|
|
748
|
+
candidate_id: String(r.candidate_id ?? ""),
|
|
749
|
+
outcome: String(r.outcome || ""),
|
|
750
|
+
reason: typeof r.reason === "string" ? r.reason : undefined,
|
|
751
|
+
our_url: typeof r.our_url === "string" ? r.our_url : undefined,
|
|
752
|
+
}))
|
|
753
|
+
.filter((r) => r.candidate_id && ["posted", "skipped", "failed"].includes(r.outcome))
|
|
754
|
+
: parsePostCandidateResults(res.stdout);
|
|
755
|
+
const approvedById = new Map();
|
|
756
|
+
approved.forEach((c) => {
|
|
757
|
+
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
758
|
+
approvedById.set(String(c.candidate_id), c);
|
|
759
|
+
});
|
|
760
|
+
let touchedPlan = false;
|
|
761
|
+
if (resultRows.length) {
|
|
762
|
+
resultRows.forEach((r, idx) => {
|
|
763
|
+
const c = approvedById.get(r.candidate_id) || approved[idx];
|
|
764
|
+
if (!c)
|
|
765
|
+
return;
|
|
766
|
+
if (r.outcome === "posted") {
|
|
767
|
+
c.posted = true;
|
|
768
|
+
c.terminal = false;
|
|
769
|
+
if (r.our_url)
|
|
770
|
+
c.our_url = r.our_url;
|
|
771
|
+
touchedPlan = true;
|
|
772
|
+
}
|
|
773
|
+
else if (r.outcome === "skipped" || r.outcome === "failed") {
|
|
774
|
+
c.terminal = true;
|
|
775
|
+
c.terminal_reason = r.reason || r.outcome;
|
|
776
|
+
touchedPlan = true;
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
else if (realPosted > 0 || (res.code === 0 && !summObj)) {
|
|
781
|
+
// Legacy fallback for older poster output without parseable per-candidate
|
|
782
|
+
// lines. Mark only when we have no finer-grained signal.
|
|
686
783
|
for (const c of approved)
|
|
687
784
|
c.posted = true;
|
|
785
|
+
touchedPlan = true;
|
|
786
|
+
}
|
|
787
|
+
if (touchedPlan) {
|
|
688
788
|
try {
|
|
689
789
|
writePlan(batchId, plan);
|
|
690
790
|
}
|
|
@@ -1984,6 +2084,11 @@ async function runScanCandidates(project, onProgress) {
|
|
|
1984
2084
|
// long-poll doesn't leave it on a generic "working" (or flicker to idle between
|
|
1985
2085
|
// re-calls). Cleared by the handler when the scan resolves.
|
|
1986
2086
|
writeActivity("scanning", project ? `scanning X for ${project}` : "scanning X");
|
|
2087
|
+
// Single-flight: kill any pre-existing run-twitter-cycle.sh (zombies that
|
|
2088
|
+
// survived a prior preempt, or stale waiters parked behind a post) before
|
|
2089
|
+
// launching, so only ONE scan ever exists. The plist gets this from
|
|
2090
|
+
// run-twitter-cycle-singleton.sh; the MCP's direct launch must enforce it here.
|
|
2091
|
+
sigkillAllScans();
|
|
1987
2092
|
const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
|
|
1988
2093
|
env,
|
|
1989
2094
|
timeoutMs: 900_000,
|
|
@@ -2126,68 +2231,140 @@ function rmShellLockDir() {
|
|
|
2126
2231
|
}
|
|
2127
2232
|
}
|
|
2128
2233
|
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2129
|
-
//
|
|
2130
|
-
//
|
|
2234
|
+
// SIGKILL a scan's WHOLE process tree (the bash + its browser-harness/tee
|
|
2235
|
+
// children). run-twitter-cycle.sh traps SIGTERM/INT/HUP (skill/lock.sh installs
|
|
2236
|
+
// `trap _sa_release_locks ... TERM`), so a SIGTERM runs the cleanup handler and
|
|
2237
|
+
// the script KEEPS GOING — the scan never dies, still drives Chrome, and the next
|
|
2238
|
+
// autopilot tick stacks another on top (the zombie pileup that stale-reclaimed the
|
|
2239
|
+
// lock mid-post). SIGKILL can't be trapped. Kill children first so the harness CDP
|
|
2240
|
+
// driver lets go of Chrome immediately.
|
|
2241
|
+
function sigkillScanTree(pid) {
|
|
2242
|
+
try {
|
|
2243
|
+
const out = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf-8", timeout: 4000 });
|
|
2244
|
+
for (const cstr of out.split(/\s+/)) {
|
|
2245
|
+
const c = parseInt(cstr, 10);
|
|
2246
|
+
if (Number.isFinite(c) && c > 0) {
|
|
2247
|
+
try {
|
|
2248
|
+
process.kill(c, "SIGKILL");
|
|
2249
|
+
}
|
|
2250
|
+
catch {
|
|
2251
|
+
/* gone */
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
catch {
|
|
2257
|
+
/* no children / pgrep unavailable */
|
|
2258
|
+
}
|
|
2259
|
+
try {
|
|
2260
|
+
process.kill(pid, "SIGKILL");
|
|
2261
|
+
}
|
|
2262
|
+
catch {
|
|
2263
|
+
/* gone */
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
// Single-flight: SIGKILL every run-twitter-cycle.sh on the box before launching a
|
|
2267
|
+
// fresh scan, so a zombie that survived a prior SIGTERM (or a stale waiter parked
|
|
2268
|
+
// behind a post) can never accumulate. Mirrors the plist's run-twitter-cycle-
|
|
2269
|
+
// singleton.sh "one cycle at a time" guarantee, which the MCP's direct launch
|
|
2270
|
+
// bypassed. Best-effort; never throws.
|
|
2271
|
+
function sigkillAllScans() {
|
|
2272
|
+
try {
|
|
2273
|
+
const out = execFileSync("pgrep", ["-f", "skill/run-twitter-cycle.sh"], {
|
|
2274
|
+
encoding: "utf-8",
|
|
2275
|
+
timeout: 4000,
|
|
2276
|
+
});
|
|
2277
|
+
for (const pstr of out.split(/\s+/)) {
|
|
2278
|
+
const p = parseInt(pstr, 10);
|
|
2279
|
+
if (Number.isFinite(p) && p > 0)
|
|
2280
|
+
sigkillScanTree(p);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
catch {
|
|
2284
|
+
/* none running */
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
// ---- Lock grace-hold: hold the /tmp lock CONTINUOUSLY across per-card posts ----
|
|
2288
|
+
// The plist pipeline acquires the browser lock ONCE and holds it through the whole
|
|
2289
|
+
// posting phase. The MCP posts per approved card (separate post_drafts calls), and
|
|
2290
|
+
// the old code acquired+released the lock PER CARD — leaving a release window
|
|
2291
|
+
// BETWEEN every card that a parked scan stale-reclaimed (the hijack). Instead we
|
|
2292
|
+
// keep the lock and only release it after SHELL_LOCK_GRACE_MS of no posting, so the
|
|
2293
|
+
// hold EXPANDS as more cards get approved and there is never a gap between cards.
|
|
2294
|
+
const SHELL_LOCK_GRACE_MS = Number(process.env.SAPS_POST_LOCK_GRACE_MS) || 60_000;
|
|
2295
|
+
let shellLockReleaseTimer = null;
|
|
2296
|
+
function cancelScheduledShellLockRelease() {
|
|
2297
|
+
if (shellLockReleaseTimer) {
|
|
2298
|
+
clearTimeout(shellLockReleaseTimer);
|
|
2299
|
+
shellLockReleaseTimer = null;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
function scheduleShellLockRelease() {
|
|
2303
|
+
cancelScheduledShellLockRelease();
|
|
2304
|
+
shellLockReleaseTimer = setTimeout(() => {
|
|
2305
|
+
shellLockReleaseTimer = null;
|
|
2306
|
+
releaseShellBrowserLock();
|
|
2307
|
+
}, SHELL_LOCK_GRACE_MS);
|
|
2308
|
+
}
|
|
2309
|
+
// SIGKILL a live scan holding the shell browser lock so the post takes the browser
|
|
2310
|
+
// at once. Best-effort; only ever targets a run-twitter-cycle.sh.
|
|
2131
2311
|
function preemptScanHoldingBrowser() {
|
|
2132
2312
|
try {
|
|
2133
2313
|
const pid = shellLockHolderPid();
|
|
2134
2314
|
if (pid && pidAlive(pid) && pidIsScan(pid)) {
|
|
2135
|
-
console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid})`);
|
|
2136
|
-
|
|
2137
|
-
process.kill(pid, "SIGTERM");
|
|
2138
|
-
}
|
|
2139
|
-
catch {
|
|
2140
|
-
/* already gone */
|
|
2141
|
-
}
|
|
2315
|
+
console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid}) — SIGKILL tree`);
|
|
2316
|
+
sigkillScanTree(pid);
|
|
2142
2317
|
}
|
|
2143
2318
|
}
|
|
2144
2319
|
catch {
|
|
2145
2320
|
/* best effort */
|
|
2146
2321
|
}
|
|
2147
2322
|
}
|
|
2148
|
-
// Take the shell browser lock for the batch. Preempts a scan holder
|
|
2149
|
-
// steals from a live non-scan holder (a peer poster) — there
|
|
2150
|
-
// and posting proceeds unguarded (no worse than before).
|
|
2323
|
+
// Take (or extend) the shell browser lock for the batch. Preempts a scan holder
|
|
2324
|
+
// with SIGKILL; never steals from a live non-scan holder (a peer poster) — there
|
|
2325
|
+
// it returns false and posting proceeds unguarded (no worse than before).
|
|
2151
2326
|
async function acquireShellBrowserLock() {
|
|
2327
|
+
// A new post cancels any pending grace-release and EXTENDS the existing hold.
|
|
2328
|
+
cancelScheduledShellLockRelease();
|
|
2329
|
+
// Already ours? Refresh the pid + expiry and keep holding — this is the "expand
|
|
2330
|
+
// the lock as more cards get approved" path: consecutive per-card posts reuse
|
|
2331
|
+
// ONE continuous hold instead of churning the lock, which is what left a window
|
|
2332
|
+
// a parked scan stale-reclaimed between cards.
|
|
2333
|
+
if (shellLockHolderPid() === process.pid) {
|
|
2334
|
+
try {
|
|
2335
|
+
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
|
|
2336
|
+
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
|
|
2337
|
+
}
|
|
2338
|
+
catch {
|
|
2339
|
+
/* best effort */
|
|
2340
|
+
}
|
|
2341
|
+
return true;
|
|
2342
|
+
}
|
|
2152
2343
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
2153
2344
|
try {
|
|
2154
2345
|
fs.mkdirSync(TW_BROWSER_LOCK_DIR); // atomic mutex — only one winner
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
catch {
|
|
2159
|
-
/* best effort */
|
|
2160
|
-
}
|
|
2161
|
-
try {
|
|
2162
|
-
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
|
|
2163
|
-
}
|
|
2164
|
-
catch {
|
|
2165
|
-
/* best effort */
|
|
2166
|
-
}
|
|
2346
|
+
// Write the pid IMMEDIATELY (sync) so the dir is never observably pid-less.
|
|
2347
|
+
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
|
|
2348
|
+
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
|
|
2167
2349
|
console.error(`[post] holding twitter-browser shell lock pid=${process.pid} — scans queue behind the post`);
|
|
2168
2350
|
return true;
|
|
2169
2351
|
}
|
|
2170
2352
|
catch {
|
|
2171
|
-
// Dir exists. Reclaim if the holder is dead; preempt if it's a scan;
|
|
2353
|
+
// Dir exists. Reclaim if the holder is dead; SIGKILL-preempt if it's a scan;
|
|
2172
2354
|
// otherwise (a live peer poster) leave it and post unguarded.
|
|
2173
2355
|
const pid = shellLockHolderPid();
|
|
2174
2356
|
if (!pid || !pidAlive(pid)) {
|
|
2175
2357
|
rmShellLockDir();
|
|
2176
2358
|
}
|
|
2177
2359
|
else if (pidIsScan(pid)) {
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
}
|
|
2181
|
-
catch {
|
|
2182
|
-
/* already gone */
|
|
2183
|
-
}
|
|
2184
|
-
await sleepMs(400);
|
|
2360
|
+
sigkillScanTree(pid); // SIGKILL — scans trap SIGTERM and survive it
|
|
2361
|
+
await sleepMs(300);
|
|
2185
2362
|
rmShellLockDir();
|
|
2186
2363
|
}
|
|
2187
2364
|
else {
|
|
2188
2365
|
return false; // a real peer holds it — don't steal; proceed
|
|
2189
2366
|
}
|
|
2190
|
-
await sleepMs(
|
|
2367
|
+
await sleepMs(200);
|
|
2191
2368
|
}
|
|
2192
2369
|
}
|
|
2193
2370
|
return false;
|
|
@@ -2213,13 +2390,8 @@ function releaseShellBrowserLock() {
|
|
|
2213
2390
|
function preemptScanForPost() {
|
|
2214
2391
|
try {
|
|
2215
2392
|
if (scanChild && scanChild.pid && scanChild.exitCode === null) {
|
|
2216
|
-
console.error(`[post] preempting in-flight plugin scan (pid ${scanChild.pid}) so the approved post takes the browser`);
|
|
2217
|
-
|
|
2218
|
-
scanChild.kill("SIGTERM");
|
|
2219
|
-
}
|
|
2220
|
-
catch {
|
|
2221
|
-
/* already gone */
|
|
2222
|
-
}
|
|
2393
|
+
console.error(`[post] preempting in-flight plugin scan (pid ${scanChild.pid}) so the approved post takes the browser — SIGKILL tree`);
|
|
2394
|
+
sigkillScanTree(scanChild.pid); // SIGKILL — scan bash traps SIGTERM
|
|
2223
2395
|
}
|
|
2224
2396
|
}
|
|
2225
2397
|
catch {
|
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.92",
|
|
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": {
|
|
@@ -376,13 +376,17 @@ class S4LMenuBar(rumps.App):
|
|
|
376
376
|
act = st.read_activity()
|
|
377
377
|
label = act.get("label") if act else None
|
|
378
378
|
if label:
|
|
379
|
+
# The update arrow must stay visible even while a tool runs, so the
|
|
380
|
+
# "update available" signal is never masked by activity. _tick skips the
|
|
381
|
+
# title repaint while the spinner owns it, so the arrow is injected here.
|
|
382
|
+
head = "S4L ⬆" if self._update_available else "S4L"
|
|
379
383
|
# A "✓" label (e.g. "posted 3/10 ✓") is a momentary confirmation, not
|
|
380
384
|
# ongoing work — show it without the spinner glyph so it reads as done.
|
|
381
385
|
if "✓" in label:
|
|
382
|
-
self.title = f"
|
|
386
|
+
self.title = f"{head} {label}"
|
|
383
387
|
else:
|
|
384
388
|
self._spin_i = (self._spin_i + 1) % len(SPINNER)
|
|
385
|
-
self.title = f"
|
|
389
|
+
self.title = f"{head} {label} {SPINNER[self._spin_i]}"
|
|
386
390
|
return
|
|
387
391
|
try:
|
|
388
392
|
if self._spinner is not None:
|
|
@@ -395,10 +399,12 @@ class S4LMenuBar(rumps.App):
|
|
|
395
399
|
|
|
396
400
|
# ---- tick: read state, set title, (re)build menu ----------------------
|
|
397
401
|
def _tick(self, _):
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
-
|
|
401
|
-
|
|
402
|
+
# The activity spinner owns the TITLE while a tool runs (we don't fight it at
|
|
403
|
+
# 0.12s), but the menu + update indicator must still refresh mid-run —
|
|
404
|
+
# otherwise the "Please update now" item never appears on a box that's always
|
|
405
|
+
# busy (continuous autopilot). So we no longer bail out wholesale when busy;
|
|
406
|
+
# we only skip the title repaint and the review pop-up.
|
|
407
|
+
busy = self._spinner is not None
|
|
402
408
|
snap = st.snapshot()
|
|
403
409
|
ob = snap.get("onboarding") or st.read_onboarding()
|
|
404
410
|
runtime_ready = bool(snap.get("runtime_ready"))
|
|
@@ -414,7 +420,9 @@ class S4LMenuBar(rumps.App):
|
|
|
414
420
|
blocker = (ob or {}).get("current_blocker")
|
|
415
421
|
blocker_code = (blocker or {}).get("code")
|
|
416
422
|
|
|
417
|
-
|
|
423
|
+
# Spinner owns the title while busy; _spin already keeps the ⬆ visible there.
|
|
424
|
+
if not busy:
|
|
425
|
+
self._render_title(setup_complete, ob, blocker)
|
|
418
426
|
|
|
419
427
|
# Blocker notification only on transition into a new blocker.
|
|
420
428
|
if blocker and blocker_code != self._last_blocker_code:
|
|
@@ -431,6 +439,8 @@ class S4LMenuBar(rumps.App):
|
|
|
431
439
|
if ob
|
|
432
440
|
else 0
|
|
433
441
|
)
|
|
442
|
+
# _update_available / _latest_version are in the signature so a freshly
|
|
443
|
+
# detected update rebuilds the menu (adding "Please update now") even mid-run.
|
|
434
444
|
sig = (
|
|
435
445
|
runtime_ready,
|
|
436
446
|
setup_complete,
|
|
@@ -439,6 +449,8 @@ class S4LMenuBar(rumps.App):
|
|
|
439
449
|
bool(snap.get("autopilot_on")),
|
|
440
450
|
snap.get("version"),
|
|
441
451
|
snap.get("update_available"),
|
|
452
|
+
self._update_available,
|
|
453
|
+
self._latest_version,
|
|
442
454
|
snap.get("x_handle"),
|
|
443
455
|
snap.get("projects_ready"),
|
|
444
456
|
snap.get("projects_total"),
|
|
@@ -448,8 +460,9 @@ class S4LMenuBar(rumps.App):
|
|
|
448
460
|
self._build_menu(runtime_ready, setup_complete, ob, blocker, snap)
|
|
449
461
|
|
|
450
462
|
# Draft-review pop-ups: if a draft cycle left a review request, present the
|
|
451
|
-
# cards.
|
|
452
|
-
|
|
463
|
+
# cards. Don't start a review mid-run (the spinner means a tool is active).
|
|
464
|
+
if not busy:
|
|
465
|
+
self._maybe_start_review()
|
|
453
466
|
|
|
454
467
|
# ---- draft review pop-ups ---------------------------------------------
|
|
455
468
|
def _maybe_start_review(self):
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -320,10 +320,10 @@ def read_plan(plan_path):
|
|
|
320
320
|
|
|
321
321
|
|
|
322
322
|
def review_drafts(plan):
|
|
323
|
-
"""Flatten a plan into the card model: only candidates
|
|
323
|
+
"""Flatten a plan into the card model: only unfinished candidates."""
|
|
324
324
|
out = []
|
|
325
325
|
for i, c in enumerate(((plan or {}).get("candidates") or [])):
|
|
326
|
-
if c.get("posted") is True:
|
|
326
|
+
if c.get("posted") is True or c.get("terminal") is True:
|
|
327
327
|
continue
|
|
328
328
|
out.append(
|
|
329
329
|
{
|
|
@@ -334,6 +334,9 @@ def review_drafts(plan):
|
|
|
334
334
|
"link_url": c.get("link_url"),
|
|
335
335
|
}
|
|
336
336
|
)
|
|
337
|
+
# The review queue is append-only, so the highest stable index is newest and
|
|
338
|
+
# most likely to still be live on X.
|
|
339
|
+
out.sort(key=lambda d: d["n"], reverse=True)
|
|
337
340
|
return out
|
|
338
341
|
|
|
339
342
|
|
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.92",
|
|
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,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Who is actually using social-autoposter right now.
|
|
3
|
+
|
|
4
|
+
Reads the `installations` heartbeat table (the only live per-install signal) and
|
|
5
|
+
answers "how many real, external people are active" without the inflation that a
|
|
6
|
+
raw install count carries:
|
|
7
|
+
|
|
8
|
+
* install_id is per identity.json, NOT per machine. A reinstall / reset / each
|
|
9
|
+
ephemeral mk0r E2B sandbox mints a fresh id. So we dedupe by `hardware_uuid`
|
|
10
|
+
(the stable per-machine key) and report MACHINES, not install rows.
|
|
11
|
+
* Our own infra (i@m13v.com operator Mac, the agent@mk0r.com / e2b.local VM
|
|
12
|
+
fleet) is filtered out by default so the roster is real customers. Pass
|
|
13
|
+
--all to include it.
|
|
14
|
+
* Cross-references the `posts` table so you can see the alive-but-not-posting
|
|
15
|
+
gap (the blind spot the Cloud Logging stream exists to explain).
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 scripts/active_users.py # external machines, last 7d
|
|
19
|
+
python3 scripts/active_users.py --days 30 # different window
|
|
20
|
+
python3 scripts/active_users.py --all # include our own infra
|
|
21
|
+
python3 scripts/active_users.py --json # machine-readable
|
|
22
|
+
|
|
23
|
+
Operator-local only: uses the direct-Postgres lane via scripts/db.py (absent in
|
|
24
|
+
the shipped npm package), reading DATABASE_URL from ~/social-autoposter/.env.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from urllib.parse import unquote
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
from db import load_env, get_conn # noqa: E402
|
|
35
|
+
|
|
36
|
+
# Our own installs, hidden by default so the roster is real external users.
|
|
37
|
+
INTERNAL_EMAILS = {"i@m13v.com", "agent@mk0r.com", "matt@mediar.ai"}
|
|
38
|
+
INTERNAL_HOSTNAME_SUBSTR = ("e2b.local", "71522") # mk0r E2B sandboxes; MacStadium QA box
|
|
39
|
+
# MacStadium remote QA box (hostname "71522", no git_email). It actively runs the
|
|
40
|
+
# pipeline and posts, so without this it masquerades as our only posting customer.
|
|
41
|
+
INTERNAL_HARDWARE_UUIDS = {"07CB793D-6E32-5EF8-82E2-7CDEABD47FBC"}
|
|
42
|
+
|
|
43
|
+
# Connected X handle resolves only from posts.our_account; drop scaffolding values.
|
|
44
|
+
PLACEHOLDER_HANDLES = {"your-twitter-handle", "your_handle", "your-handle", "none", "null", ""}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_handles(raw):
|
|
48
|
+
out = []
|
|
49
|
+
for h in (raw or "").split(","):
|
|
50
|
+
h = h.strip().lstrip("@")
|
|
51
|
+
if h and h.lower() not in PLACEHOLDER_HANDLES and h not in out:
|
|
52
|
+
out.append(h)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_internal(emails, hostnames, hardware_uuids):
|
|
57
|
+
if any((e or "").lower() in INTERNAL_EMAILS for e in emails):
|
|
58
|
+
return True
|
|
59
|
+
if any(sub in (h or "") for h in hostnames for sub in INTERNAL_HOSTNAME_SUBSTR):
|
|
60
|
+
return True
|
|
61
|
+
if any((u or "") in INTERNAL_HARDWARE_UUIDS for u in hardware_uuids):
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fetch(days):
|
|
67
|
+
# One row per MACHINE (hardware_uuid; fall back to a per-install key when the
|
|
68
|
+
# client never reported a hardware_uuid so those installs aren't all merged).
|
|
69
|
+
# `days` is an argparse int (injection-safe), inlined because the wrapper's
|
|
70
|
+
# SQL translation mangles %s placeholders.
|
|
71
|
+
days = int(days)
|
|
72
|
+
q = f"""
|
|
73
|
+
WITH win AS (
|
|
74
|
+
SELECT *,
|
|
75
|
+
COALESCE(NULLIF(git_email, ''), NULLIF(hardware_uuid, ''),
|
|
76
|
+
'anon:' || install_id::text) AS entity_key
|
|
77
|
+
FROM installations
|
|
78
|
+
WHERE last_seen_at > now() - interval '{days} days'
|
|
79
|
+
),
|
|
80
|
+
posted AS (
|
|
81
|
+
SELECT install_id, count(*) AS n
|
|
82
|
+
FROM posts
|
|
83
|
+
WHERE posted_at > now() - interval '{days} days' AND install_id IS NOT NULL
|
|
84
|
+
GROUP BY install_id
|
|
85
|
+
),
|
|
86
|
+
handles AS (
|
|
87
|
+
-- The connected X handle is NOT in the heartbeat; it only reaches the
|
|
88
|
+
-- central DB via posts.our_account, so it exists ONLY for installs that
|
|
89
|
+
-- ever posted (all-time, not windowed: a handle is identity, not activity).
|
|
90
|
+
SELECT install_id, string_agg(DISTINCT our_account, ',') AS hs
|
|
91
|
+
FROM posts
|
|
92
|
+
WHERE our_account IS NOT NULL AND length(trim(our_account)) > 0
|
|
93
|
+
GROUP BY install_id
|
|
94
|
+
)
|
|
95
|
+
SELECT
|
|
96
|
+
w.entity_key,
|
|
97
|
+
count(DISTINCT w.install_id) AS installs,
|
|
98
|
+
count(DISTINCT w.hardware_uuid) AS machines,
|
|
99
|
+
array_remove(array_agg(DISTINCT w.hardware_uuid), NULL) AS hardware_uuids,
|
|
100
|
+
array_remove(array_agg(DISTINCT NULLIF(w.git_email, '')), NULL) AS emails,
|
|
101
|
+
array_remove(array_agg(DISTINCT w.hostname), NULL) AS hostnames,
|
|
102
|
+
string_agg(DISTINCT h.hs, ',') AS handles_raw,
|
|
103
|
+
max(w.os_version) AS os,
|
|
104
|
+
array_remove(array_agg(DISTINCT
|
|
105
|
+
w.last_country || '/' || COALESCE(w.last_city, '-')), NULL) AS locations,
|
|
106
|
+
max(w.last_seen_at) AS last_seen,
|
|
107
|
+
COALESCE(sum(p.n), 0) AS posts
|
|
108
|
+
FROM win w
|
|
109
|
+
LEFT JOIN posted p ON p.install_id = w.install_id
|
|
110
|
+
LEFT JOIN handles h ON h.install_id = w.install_id
|
|
111
|
+
GROUP BY w.entity_key
|
|
112
|
+
ORDER BY last_seen DESC;
|
|
113
|
+
"""
|
|
114
|
+
conn = get_conn()
|
|
115
|
+
try:
|
|
116
|
+
cur = conn.execute(q)
|
|
117
|
+
cols = [c.name for c in cur.description]
|
|
118
|
+
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
|
119
|
+
finally:
|
|
120
|
+
conn.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def person(row):
|
|
124
|
+
if row["emails"]:
|
|
125
|
+
return row["emails"][0]
|
|
126
|
+
if row["hostnames"]:
|
|
127
|
+
return row["hostnames"][0]
|
|
128
|
+
return row["entity_key"][:12]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def loc(row):
|
|
132
|
+
return ", ".join(unquote(x) for x in (row["locations"] or [])) or "?"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main():
|
|
136
|
+
ap = argparse.ArgumentParser(
|
|
137
|
+
description="Active social-autoposter users, deduped per person (email, else machine).")
|
|
138
|
+
ap.add_argument("--days", type=int, default=7, help="lookback window in days (default 7)")
|
|
139
|
+
ap.add_argument("--all", action="store_true", help="include our own infra (i@m13v / mk0r)")
|
|
140
|
+
ap.add_argument("--json", action="store_true", help="emit JSON")
|
|
141
|
+
args = ap.parse_args()
|
|
142
|
+
|
|
143
|
+
load_env()
|
|
144
|
+
rows = fetch(args.days)
|
|
145
|
+
for r in rows:
|
|
146
|
+
r["internal"] = is_internal(r["emails"], r["hostnames"], r["hardware_uuids"])
|
|
147
|
+
r["handles"] = parse_handles(r.get("handles_raw"))
|
|
148
|
+
|
|
149
|
+
external = [r for r in rows if not r["internal"]]
|
|
150
|
+
internal = [r for r in rows if r["internal"]]
|
|
151
|
+
shown = rows if args.all else external
|
|
152
|
+
|
|
153
|
+
if args.json:
|
|
154
|
+
out = [{
|
|
155
|
+
"person": person(r), "x_handles": r["handles"], "machines": r["machines"],
|
|
156
|
+
"installs": r["installs"], "hostnames": r["hostnames"], "emails": r["emails"],
|
|
157
|
+
"os": r["os"], "location": loc(r), "posts": int(r["posts"]),
|
|
158
|
+
"last_seen": r["last_seen"].isoformat() if r["last_seen"] else None,
|
|
159
|
+
"internal": r["internal"],
|
|
160
|
+
} for r in shown]
|
|
161
|
+
print(json.dumps({
|
|
162
|
+
"window_days": args.days,
|
|
163
|
+
"external_machines": len(external),
|
|
164
|
+
"external_people": len({e for r in external for e in r["emails"]}),
|
|
165
|
+
"internal_machines_hidden": 0 if args.all else len(internal),
|
|
166
|
+
"rows": out,
|
|
167
|
+
}, indent=2))
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
people = len({e for r in external for e in r["emails"]})
|
|
171
|
+
print(f"\nActive in last {args.days}d: {len(external)} external machines "
|
|
172
|
+
f"(~{people} identified people){'' if args.all else f', {len(internal)} internal hidden'}\n")
|
|
173
|
+
hdr = (f"{'PERSON':<30} {'X HANDLE':<16} {'HOST':<22} {'OS':<7} {'LOC':<16} "
|
|
174
|
+
f"{'INST':>4} {'POSTS':>6} LAST SEEN")
|
|
175
|
+
print(hdr)
|
|
176
|
+
print("-" * len(hdr))
|
|
177
|
+
for r in shown:
|
|
178
|
+
tag = " [internal]" if r["internal"] else ""
|
|
179
|
+
host = (r["hostnames"][0] if r["hostnames"] else "?")[:22]
|
|
180
|
+
handle = (", ".join(r["handles"]) or "-")[:16]
|
|
181
|
+
print(f"{person(r)[:30]:<30} {handle:<16} {host:<22} {(r['os'] or '?'):<7} "
|
|
182
|
+
f"{loc(r)[:16]:<16} {r['installs']:>4} {int(r['posts']):>6} "
|
|
183
|
+
f"{r['last_seen']:%Y-%m-%d %H:%M}{tag}")
|
|
184
|
+
posting = sum(1 for r in external if r["posts"] > 0)
|
|
185
|
+
print(f"\n of {len(external)} external machines, {posting} posted in the window, "
|
|
186
|
+
f"{len(external) - posting} are alive-but-not-posting.\n")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|