social-autoposter 1.6.90 → 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 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-posted entries so the cards only show what's still pending.
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
- // Always free the lock so a queued scan can proceed, even on throw/timeout.
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
- releaseShellBrowserLock();
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 only candidates that ACTUALLY posted (cross-surface de-dup). When the
684
- // summary is missing but the run exited clean, fall back to marking all.
685
- if (realPosted > 0 || (res.code === 0 && !summObj)) {
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
- // SIGTERM a live scan holding the shell browser lock so the post takes the
2130
- // browser at once. Best-effort; only ever targets a run-twitter-cycle.sh.
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
- try {
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; never
2149
- // steals from a live non-scan holder (a peer poster) — there it returns false
2150
- // and posting proceeds unguarded (no worse than before). Best-effort.
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
- try {
2156
- fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
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
- try {
2179
- process.kill(pid, "SIGTERM");
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(250);
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
- try {
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 {
@@ -144,7 +144,12 @@ export function flushOnboardingEvents() {
144
144
  error: "installation identity unavailable",
145
145
  };
146
146
  }
147
- const base = (process.env.AUTOPOSTER_API_BASE || "https://s4l.ai").replace(/\/+$/, "");
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
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.90",
3
- "installedAt": "2026-06-23T19:42:27.028Z"
2
+ "version": "1.6.92",
3
+ "installedAt": "2026-06-23T20:21:02.288Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.6.90",
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"S4L {label}"
386
+ self.title = f"{head} {label}"
383
387
  else:
384
388
  self._spin_i = (self._spin_i + 1) % len(SPINNER)
385
- self.title = f"S4L {label} {SPINNER[self._spin_i]}"
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
- # While a tool is running, the spinner owns the title/menu; don't fight it
399
- # or start a review mid-run.
400
- if self._spinner is not None:
401
- return
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
- self._render_title(setup_complete, ob, blocker)
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. Independent of the menu rebuild above.
452
- self._maybe_start_review()
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):
@@ -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 not already posted."""
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.90",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.90",
3
+ "version": "1.6.92",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -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()