social-autoposter 1.6.91 → 1.6.93

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
@@ -235,11 +235,20 @@ const TOOL_ACTIVITY = {
235
235
  post_drafts: "posting",
236
236
  get_stats: "loading stats",
237
237
  };
238
+ function toolActivityLabel(name, args) {
239
+ const fallback = TOOL_ACTIVITY[name];
240
+ if (!fallback)
241
+ return null;
242
+ const override = typeof args?.__saps_activity_label === "string"
243
+ ? args.__saps_activity_label.replace(/\s+/g, " ").trim().slice(0, 80)
244
+ : "";
245
+ return override || fallback;
246
+ }
238
247
  function withActivity(name, cb) {
239
- const label = TOOL_ACTIVITY[name];
240
- if (!label)
248
+ if (!TOOL_ACTIVITY[name])
241
249
  return cb;
242
250
  return async (args, extra) => {
251
+ const label = toolActivityLabel(name, args) || TOOL_ACTIVITY[name];
243
252
  writeActivity("working", label);
244
253
  try {
245
254
  return await cb(args, extra);
@@ -504,9 +513,13 @@ function renderDraftsTable(plan) {
504
513
  const candidates = plan.candidates || [];
505
514
  return candidates
506
515
  // 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.
516
+ // already-finished entries so the cards only show what's still pending.
508
517
  .map((c, i) => ({ c, n: i + 1 }))
509
- .filter((e) => e.c.posted !== true)
518
+ .filter((e) => e.c.posted !== true && e.c.terminal !== true)
519
+ // The queue is append-only; newest drafts have the highest stable index.
520
+ // Show those first so review starts with likely-live tweets instead of stale
521
+ // low-number drafts that have been sitting around for hours.
522
+ .sort((a, b) => b.n - a.n)
510
523
  .map(({ c, n }) => {
511
524
  const author = c.thread_author ? `@${c.thread_author}` : "(unknown thread)";
512
525
  const style = c.engagement_style ?? "?";
@@ -532,6 +545,52 @@ function renderDraftsTable(plan) {
532
545
  })
533
546
  .join("\n\n");
534
547
  }
548
+ function parsePostCandidateResults(stdout) {
549
+ const byId = new Map();
550
+ const upsert = (candidateId, outcome, reason, ourUrl) => {
551
+ const prev = byId.get(candidateId);
552
+ // A landed post wins over any earlier noisy line for the same candidate.
553
+ if (prev?.outcome === "posted" && outcome !== "posted")
554
+ return;
555
+ byId.set(candidateId, {
556
+ candidate_id: candidateId,
557
+ outcome,
558
+ ...(reason ? { reason } : {}),
559
+ ...(ourUrl ? { our_url: ourUrl } : {}),
560
+ });
561
+ };
562
+ for (const line of stdout.split("\n")) {
563
+ let m = /\[post\] candidate (\d+) posted as (\S+) \(post_id=/.exec(line);
564
+ if (m) {
565
+ upsert(m[1], "posted", undefined, m[2]);
566
+ continue;
567
+ }
568
+ m = /\[post\] candidate (\d+): pre-post dedup hit\b/.exec(line);
569
+ if (m) {
570
+ upsert(m[1], "skipped", "duplicate_thread_pre_post");
571
+ continue;
572
+ }
573
+ m = /\[post\] candidate (\d+) reply failed: ([A-Za-z0-9_:-]+)/.exec(line);
574
+ if (m) {
575
+ upsert(m[1], "skipped", m[2]);
576
+ continue;
577
+ }
578
+ m = /\[post\] candidate (\d+) reply succeeded but reply_url invalid:/.exec(line);
579
+ if (m) {
580
+ upsert(m[1], "skipped", "no_reply_url_captured");
581
+ continue;
582
+ }
583
+ m = /\[post\] candidate (\d+): empty reply_text; skipping/.exec(line);
584
+ if (m) {
585
+ upsert(m[1], "skipped", "empty_reply_text");
586
+ continue;
587
+ }
588
+ m = /\[post\] candidate (\d+) crashed:/.exec(line);
589
+ if (m)
590
+ upsert(m[1], "failed", "exception");
591
+ }
592
+ return [...byId.values()];
593
+ }
535
594
  // Resolve the configured posting handle the SAME way account_resolver.py does:
536
595
  // AUTOPOSTER_TWITTER_HANDLE env first, then config.json accounts.twitter.handle.
537
596
  // Returns the bare handle (no @) or null. The post preflight uses it so a missing
@@ -590,6 +649,12 @@ async function postApproved(batchId, plan) {
590
649
  "or set accounts.twitter.handle in config.json.",
591
650
  };
592
651
  }
652
+ // Mark posting active so scan_candidates DEFERS launching any scan for the
653
+ // duration of this batch (+ grace). This is the source-level mutual exclusion
654
+ // that actually fixes the hijack: the autopilot never launches a scan to race
655
+ // the post for the browser. Reset is guaranteed by scheduleShellLockRelease()
656
+ // in the finally below, so an early/failed post can't wedge scanning.
657
+ postingActive = true;
593
658
  // Posting is a priority over scanning: abort any in-flight plugin scan so the
594
659
  // approved post takes the browser immediately instead of waiting on the lock.
595
660
  // Plugin pipeline only — never affects the plist autopilot.
@@ -642,9 +707,12 @@ async function postApproved(batchId, plan) {
642
707
  });
643
708
  }
644
709
  finally {
645
- // Always free the lock so a queued scan can proceed, even on throw/timeout.
646
- if (heldShellLock)
647
- releaseShellBrowserLock();
710
+ // Always schedule the grace release (even if the lock acquire failed): the
711
+ // timer both frees the lock AND clears postingActive, so scanning resumes
712
+ // SHELL_LOCK_GRACE_MS after the last card. Holding through the grace lets the
713
+ // NEXT approved card reuse one continuous hold (mirrors the plist holding the
714
+ // lock through the whole posting phase, then releasing at the end).
715
+ scheduleShellLockRelease();
648
716
  }
649
717
  })();
650
718
  // Persist the poster's own stdout/stderr to a dated log. Without this the post
@@ -680,11 +748,57 @@ async function postApproved(batchId, plan) {
680
748
  : res.code === 0 && !summObj
681
749
  ? approved.length
682
750
  : 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)) {
751
+ // Mark candidates according to the poster's per-candidate outcome. This keeps
752
+ // the review queue honest: posted drafts disappear as posted, terminal skips
753
+ // (dedup, deleted tweet, no captured URL) disappear without being counted as
754
+ // posted, and multi-approval batches no longer smear one posted count across
755
+ // every approved draft.
756
+ const resultRowsFromSummary = Array.isArray(summObj?.candidate_results)
757
+ ? summObj?.candidate_results
758
+ : [];
759
+ const resultRows = resultRowsFromSummary.length
760
+ ? resultRowsFromSummary
761
+ .map((r) => ({
762
+ candidate_id: String(r.candidate_id ?? ""),
763
+ outcome: String(r.outcome || ""),
764
+ reason: typeof r.reason === "string" ? r.reason : undefined,
765
+ our_url: typeof r.our_url === "string" ? r.our_url : undefined,
766
+ }))
767
+ .filter((r) => r.candidate_id && ["posted", "skipped", "failed"].includes(r.outcome))
768
+ : parsePostCandidateResults(res.stdout);
769
+ const approvedById = new Map();
770
+ approved.forEach((c) => {
771
+ if (c.candidate_id !== undefined && c.candidate_id !== null)
772
+ approvedById.set(String(c.candidate_id), c);
773
+ });
774
+ let touchedPlan = false;
775
+ if (resultRows.length) {
776
+ resultRows.forEach((r, idx) => {
777
+ const c = approvedById.get(r.candidate_id) || approved[idx];
778
+ if (!c)
779
+ return;
780
+ if (r.outcome === "posted") {
781
+ c.posted = true;
782
+ c.terminal = false;
783
+ if (r.our_url)
784
+ c.our_url = r.our_url;
785
+ touchedPlan = true;
786
+ }
787
+ else if (r.outcome === "skipped" || r.outcome === "failed") {
788
+ c.terminal = true;
789
+ c.terminal_reason = r.reason || r.outcome;
790
+ touchedPlan = true;
791
+ }
792
+ });
793
+ }
794
+ else if (realPosted > 0 || (res.code === 0 && !summObj)) {
795
+ // Legacy fallback for older poster output without parseable per-candidate
796
+ // lines. Mark only when we have no finer-grained signal.
686
797
  for (const c of approved)
687
798
  c.posted = true;
799
+ touchedPlan = true;
800
+ }
801
+ if (touchedPlan) {
688
802
  try {
689
803
  writePlan(batchId, plan);
690
804
  }
@@ -1984,6 +2098,11 @@ async function runScanCandidates(project, onProgress) {
1984
2098
  // long-poll doesn't leave it on a generic "working" (or flicker to idle between
1985
2099
  // re-calls). Cleared by the handler when the scan resolves.
1986
2100
  writeActivity("scanning", project ? `scanning X for ${project}` : "scanning X");
2101
+ // Single-flight: kill any pre-existing run-twitter-cycle.sh (zombies that
2102
+ // survived a prior preempt, or stale waiters parked behind a post) before
2103
+ // launching, so only ONE scan ever exists. The plist gets this from
2104
+ // run-twitter-cycle-singleton.sh; the MCP's direct launch must enforce it here.
2105
+ sigkillAllScans();
1987
2106
  const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
1988
2107
  env,
1989
2108
  timeoutMs: 900_000,
@@ -2126,68 +2245,150 @@ function rmShellLockDir() {
2126
2245
  }
2127
2246
  }
2128
2247
  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.
2248
+ // SIGKILL a scan's WHOLE process tree (the bash + its browser-harness/tee
2249
+ // children). run-twitter-cycle.sh traps SIGTERM/INT/HUP (skill/lock.sh installs
2250
+ // `trap _sa_release_locks ... TERM`), so a SIGTERM runs the cleanup handler and
2251
+ // the script KEEPS GOING — the scan never dies, still drives Chrome, and the next
2252
+ // autopilot tick stacks another on top (the zombie pileup that stale-reclaimed the
2253
+ // lock mid-post). SIGKILL can't be trapped. Kill children first so the harness CDP
2254
+ // driver lets go of Chrome immediately.
2255
+ function sigkillScanTree(pid) {
2256
+ try {
2257
+ const out = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf-8", timeout: 4000 });
2258
+ for (const cstr of out.split(/\s+/)) {
2259
+ const c = parseInt(cstr, 10);
2260
+ if (Number.isFinite(c) && c > 0) {
2261
+ try {
2262
+ process.kill(c, "SIGKILL");
2263
+ }
2264
+ catch {
2265
+ /* gone */
2266
+ }
2267
+ }
2268
+ }
2269
+ }
2270
+ catch {
2271
+ /* no children / pgrep unavailable */
2272
+ }
2273
+ try {
2274
+ process.kill(pid, "SIGKILL");
2275
+ }
2276
+ catch {
2277
+ /* gone */
2278
+ }
2279
+ }
2280
+ // Single-flight: SIGKILL every run-twitter-cycle.sh on the box before launching a
2281
+ // fresh scan, so a zombie that survived a prior SIGTERM (or a stale waiter parked
2282
+ // behind a post) can never accumulate. Mirrors the plist's run-twitter-cycle-
2283
+ // singleton.sh "one cycle at a time" guarantee, which the MCP's direct launch
2284
+ // bypassed. Best-effort; never throws.
2285
+ function sigkillAllScans() {
2286
+ try {
2287
+ const out = execFileSync("pgrep", ["-f", "skill/run-twitter-cycle.sh"], {
2288
+ encoding: "utf-8",
2289
+ timeout: 4000,
2290
+ });
2291
+ for (const pstr of out.split(/\s+/)) {
2292
+ const p = parseInt(pstr, 10);
2293
+ if (Number.isFinite(p) && p > 0)
2294
+ sigkillScanTree(p);
2295
+ }
2296
+ }
2297
+ catch {
2298
+ /* none running */
2299
+ }
2300
+ }
2301
+ // ---- Lock grace-hold: hold the /tmp lock CONTINUOUSLY across per-card posts ----
2302
+ // The plist pipeline acquires the browser lock ONCE and holds it through the whole
2303
+ // posting phase. The MCP posts per approved card (separate post_drafts calls), and
2304
+ // the old code acquired+released the lock PER CARD — leaving a release window
2305
+ // BETWEEN every card that a parked scan stale-reclaimed (the hijack). Instead we
2306
+ // keep the lock and only release it after SHELL_LOCK_GRACE_MS of no posting, so the
2307
+ // hold EXPANDS as more cards get approved and there is never a gap between cards.
2308
+ const SHELL_LOCK_GRACE_MS = Number(process.env.SAPS_POST_LOCK_GRACE_MS) || 60_000;
2309
+ let shellLockReleaseTimer = null;
2310
+ // True from the start of a post batch until SHELL_LOCK_GRACE_MS after the last
2311
+ // card. scan_candidates checks this and DEFERS launching a scan while it's set —
2312
+ // the real fix: posting and scanning are mutually exclusive at the SOURCE (both
2313
+ // are children of THIS one MCP), so we never even launch a scan that would race
2314
+ // the post for the browser lock. Having the post fight scans for the lock (the
2315
+ // prior approach) lost the race because the autopilot relaunches scans faster
2316
+ // than the post can hold the dir. Reset is guaranteed by the grace timer below,
2317
+ // so it can never wedge scanning permanently.
2318
+ let postingActive = false;
2319
+ function cancelScheduledShellLockRelease() {
2320
+ if (shellLockReleaseTimer) {
2321
+ clearTimeout(shellLockReleaseTimer);
2322
+ shellLockReleaseTimer = null;
2323
+ }
2324
+ }
2325
+ function scheduleShellLockRelease() {
2326
+ cancelScheduledShellLockRelease();
2327
+ shellLockReleaseTimer = setTimeout(() => {
2328
+ shellLockReleaseTimer = null;
2329
+ postingActive = false; // posting drained -> the autopilot may scan again
2330
+ releaseShellBrowserLock();
2331
+ }, SHELL_LOCK_GRACE_MS);
2332
+ }
2333
+ // SIGKILL a live scan holding the shell browser lock so the post takes the browser
2334
+ // at once. Best-effort; only ever targets a run-twitter-cycle.sh.
2131
2335
  function preemptScanHoldingBrowser() {
2132
2336
  try {
2133
2337
  const pid = shellLockHolderPid();
2134
2338
  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
- }
2339
+ console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid}) — SIGKILL tree`);
2340
+ sigkillScanTree(pid);
2142
2341
  }
2143
2342
  }
2144
2343
  catch {
2145
2344
  /* best effort */
2146
2345
  }
2147
2346
  }
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.
2347
+ // Take (or extend) the shell browser lock for the batch. Preempts a scan holder
2348
+ // with SIGKILL; never steals from a live non-scan holder (a peer poster) — there
2349
+ // it returns false and posting proceeds unguarded (no worse than before).
2151
2350
  async function acquireShellBrowserLock() {
2351
+ // A new post cancels any pending grace-release and EXTENDS the existing hold.
2352
+ cancelScheduledShellLockRelease();
2353
+ // Already ours? Refresh the pid + expiry and keep holding — this is the "expand
2354
+ // the lock as more cards get approved" path: consecutive per-card posts reuse
2355
+ // ONE continuous hold instead of churning the lock, which is what left a window
2356
+ // a parked scan stale-reclaimed between cards.
2357
+ if (shellLockHolderPid() === process.pid) {
2358
+ try {
2359
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
2360
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
2361
+ }
2362
+ catch {
2363
+ /* best effort */
2364
+ }
2365
+ return true;
2366
+ }
2152
2367
  for (let attempt = 0; attempt < 8; attempt++) {
2153
2368
  try {
2154
2369
  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
- }
2370
+ // Write the pid IMMEDIATELY (sync) so the dir is never observably pid-less.
2371
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
2372
+ fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
2167
2373
  console.error(`[post] holding twitter-browser shell lock pid=${process.pid} — scans queue behind the post`);
2168
2374
  return true;
2169
2375
  }
2170
2376
  catch {
2171
- // Dir exists. Reclaim if the holder is dead; preempt if it's a scan;
2377
+ // Dir exists. Reclaim if the holder is dead; SIGKILL-preempt if it's a scan;
2172
2378
  // otherwise (a live peer poster) leave it and post unguarded.
2173
2379
  const pid = shellLockHolderPid();
2174
2380
  if (!pid || !pidAlive(pid)) {
2175
2381
  rmShellLockDir();
2176
2382
  }
2177
2383
  else if (pidIsScan(pid)) {
2178
- try {
2179
- process.kill(pid, "SIGTERM");
2180
- }
2181
- catch {
2182
- /* already gone */
2183
- }
2184
- await sleepMs(400);
2384
+ sigkillScanTree(pid); // SIGKILL — scans trap SIGTERM and survive it
2385
+ await sleepMs(300);
2185
2386
  rmShellLockDir();
2186
2387
  }
2187
2388
  else {
2188
2389
  return false; // a real peer holds it — don't steal; proceed
2189
2390
  }
2190
- await sleepMs(250);
2391
+ await sleepMs(200);
2191
2392
  }
2192
2393
  }
2193
2394
  return false;
@@ -2213,13 +2414,8 @@ function releaseShellBrowserLock() {
2213
2414
  function preemptScanForPost() {
2214
2415
  try {
2215
2416
  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
- }
2417
+ console.error(`[post] preempting in-flight plugin scan (pid ${scanChild.pid}) so the approved post takes the browser — SIGKILL tree`);
2418
+ sigkillScanTree(scanChild.pid); // SIGKILL — scan bash traps SIGTERM
2223
2419
  }
2224
2420
  }
2225
2421
  catch {
@@ -2247,6 +2443,14 @@ function scanBusy() {
2247
2443
  "twitter-cycle slot). This is CONTENTION, not an empty result. Call scan_candidates again in " +
2248
2444
  "~20s to retry — do NOT conclude there are no candidates, and do NOT sleep; just re-call.");
2249
2445
  }
2446
+ // A post batch is in progress: scanning is deferred so posting has exclusive use
2447
+ // of the one shared browser (mutual exclusion at the source). Not an error.
2448
+ function scanDeferredForPost() {
2449
+ return textContent("A post is in progress on this machine, so scanning is deferred — only one task drives X at a " +
2450
+ "time and posting has priority. This is EXPECTED, not an error or an empty result. Call " +
2451
+ "scan_candidates again shortly; it runs as soon as the current post batch finishes. Do NOT " +
2452
+ "sleep or conclude there are no candidates; just re-call.");
2453
+ }
2250
2454
  // Resolve to {done:true,value} the moment `p` settles, or {done:false} after
2251
2455
  // `ms` — whichever is first. The job promise keeps running either way, so a
2252
2456
  // later poll re-attaches to it.
@@ -2363,6 +2567,14 @@ tool("scan_candidates", {
2363
2567
  "re-calling until it returns candidates. Never sleep or use a background wait to bridge the gap.",
2364
2568
  inputSchema: { project: z.string().optional() },
2365
2569
  }, async (args) => {
2570
+ // MUTUAL EXCLUSION (the real hijack fix): while a post batch is in progress
2571
+ // (or its grace-hold is active), DEFER scanning entirely rather than launching
2572
+ // a scan that races the post for the one shared browser. Both the scan and the
2573
+ // post are children of THIS MCP, so this in-process gate is exact — the
2574
+ // autopilot never even starts a scan to interrupt a post. Posting always wins;
2575
+ // the autopilot just re-calls and runs once the batch drains.
2576
+ if (postingActive)
2577
+ return scanDeferredForPost();
2366
2578
  // Long-poll the single in-flight scan job (see the ScanJob registry above).
2367
2579
  // A finished-but-unconsumed job: hand back its result and clear the slot so a
2368
2580
  // later call starts a fresh scan.
@@ -2469,7 +2681,7 @@ tool("submit_drafts", {
2469
2681
  // drafts are appended; a thread already in the queue (by URL) is skipped (one
2470
2682
  // draft per thread). Posted entries are KEPT in place so the 1-based card
2471
2683
  // numbering stays stable across runs — the menu bar, the chat table, and
2472
- // post_drafts all index the full array and filter on the `posted` flag.
2684
+ // post_drafts all index the full array and filter finished rows.
2473
2685
  const queue = [
2474
2686
  ...(readPlan(REVIEW_QUEUE_ID)?.candidates ?? []),
2475
2687
  ];
@@ -2484,7 +2696,7 @@ tool("submit_drafts", {
2484
2696
  added++;
2485
2697
  }
2486
2698
  writePlan(REVIEW_QUEUE_ID, { candidates: queue });
2487
- const pending = queue.filter((c) => c.posted !== true);
2699
+ const pending = queue.filter((c) => c.posted !== true && c.terminal !== true);
2488
2700
  // Drafts queued = the pipeline verified end-to-end without posting. This is the
2489
2701
  // onboarding "draft_verified" terminal goal (formerly completed by draft_cycle).
2490
2702
  if (added > 0)
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.91",
3
- "installedAt": "2026-06-23T19:56:23.942Z"
2
+ "version": "1.6.93",
3
+ "installedAt": "2026-06-23T20:32:39.303Z"
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.91",
5
+ "version": "1.6.93",
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": {
@@ -164,6 +164,8 @@ class S4LMenuBar(rumps.App):
164
164
  self._review_lock = threading.Lock()
165
165
  self._panel_open = False
166
166
  self._posts_outstanding = 0
167
+ self._posting_batch_total = 0
168
+ self._posting_batch_done = 0
167
169
  self._spin_i = 0
168
170
  self._spinner = None # fast rumps.Timer animating the title while busy
169
171
  # Reliable self-check of our own Accessibility (TCC) grant — this is the
@@ -376,13 +378,17 @@ class S4LMenuBar(rumps.App):
376
378
  act = st.read_activity()
377
379
  label = act.get("label") if act else None
378
380
  if label:
381
+ # The update arrow must stay visible even while a tool runs, so the
382
+ # "update available" signal is never masked by activity. _tick skips the
383
+ # title repaint while the spinner owns it, so the arrow is injected here.
384
+ head = "S4L ⬆" if self._update_available else "S4L"
379
385
  # A "✓" label (e.g. "posted 3/10 ✓") is a momentary confirmation, not
380
386
  # ongoing work — show it without the spinner glyph so it reads as done.
381
387
  if "✓" in label:
382
- self.title = f"S4L {label}"
388
+ self.title = f"{head} {label}"
383
389
  else:
384
390
  self._spin_i = (self._spin_i + 1) % len(SPINNER)
385
- self.title = f"S4L {label} {SPINNER[self._spin_i]}"
391
+ self.title = f"{head} {label} {SPINNER[self._spin_i]}"
386
392
  return
387
393
  try:
388
394
  if self._spinner is not None:
@@ -395,10 +401,12 @@ class S4LMenuBar(rumps.App):
395
401
 
396
402
  # ---- tick: read state, set title, (re)build menu ----------------------
397
403
  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
404
+ # The activity spinner owns the TITLE while a tool runs (we don't fight it at
405
+ # 0.12s), but the menu + update indicator must still refresh mid-run
406
+ # otherwise the "Please update now" item never appears on a box that's always
407
+ # busy (continuous autopilot). So we no longer bail out wholesale when busy;
408
+ # we only skip the title repaint and the review pop-up.
409
+ busy = self._spinner is not None
402
410
  snap = st.snapshot()
403
411
  ob = snap.get("onboarding") or st.read_onboarding()
404
412
  runtime_ready = bool(snap.get("runtime_ready"))
@@ -414,7 +422,9 @@ class S4LMenuBar(rumps.App):
414
422
  blocker = (ob or {}).get("current_blocker")
415
423
  blocker_code = (blocker or {}).get("code")
416
424
 
417
- self._render_title(setup_complete, ob, blocker)
425
+ # Spinner owns the title while busy; _spin already keeps the ⬆ visible there.
426
+ if not busy:
427
+ self._render_title(setup_complete, ob, blocker)
418
428
 
419
429
  # Blocker notification only on transition into a new blocker.
420
430
  if blocker and blocker_code != self._last_blocker_code:
@@ -431,6 +441,8 @@ class S4LMenuBar(rumps.App):
431
441
  if ob
432
442
  else 0
433
443
  )
444
+ # _update_available / _latest_version are in the signature so a freshly
445
+ # detected update rebuilds the menu (adding "Please update now") even mid-run.
434
446
  sig = (
435
447
  runtime_ready,
436
448
  setup_complete,
@@ -439,6 +451,8 @@ class S4LMenuBar(rumps.App):
439
451
  bool(snap.get("autopilot_on")),
440
452
  snap.get("version"),
441
453
  snap.get("update_available"),
454
+ self._update_available,
455
+ self._latest_version,
442
456
  snap.get("x_handle"),
443
457
  snap.get("projects_ready"),
444
458
  snap.get("projects_total"),
@@ -448,10 +462,37 @@ class S4LMenuBar(rumps.App):
448
462
  self._build_menu(runtime_ready, setup_complete, ob, blocker, snap)
449
463
 
450
464
  # 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()
465
+ # cards. Don't start a review mid-run (the spinner means a tool is active).
466
+ if not busy:
467
+ self._maybe_start_review()
453
468
 
454
469
  # ---- draft review pop-ups ---------------------------------------------
470
+ def _posting_activity_label_locked(self):
471
+ """Progress for the current menu-bar approval burst.
472
+
473
+ The server receives one post_drafts call per approved card, so its native
474
+ view is always 1/1. The menu bar owns the burst queue and can show the
475
+ useful progress: current approved post / total approved so far.
476
+ """
477
+ if self._posts_outstanding <= 0:
478
+ return None
479
+ total = max(
480
+ self._posting_batch_total,
481
+ self._posting_batch_done + self._posts_outstanding,
482
+ )
483
+ current = min(total, self._posting_batch_done + 1)
484
+ return f"posting {current}/{total}"
485
+
486
+ def _write_posting_activity_locked(self):
487
+ label = self._posting_activity_label_locked()
488
+ if label:
489
+ st.write_activity("posting", label)
490
+ return label
491
+
492
+ def _reset_posting_progress_locked(self):
493
+ self._posting_batch_total = 0
494
+ self._posting_batch_done = 0
495
+
455
496
  def _maybe_start_review(self):
456
497
  if self._review_active:
457
498
  return
@@ -478,8 +519,10 @@ class S4LMenuBar(rumps.App):
478
519
  sig = tuple((d.get("n"), d.get("reply_text") or "") for d in drafts)
479
520
  if sig == self._last_review_sig:
480
521
  return
481
- self._review_active = True
482
- self._panel_open = True
522
+ with self._review_lock:
523
+ self._reset_posting_progress_locked()
524
+ self._review_active = True
525
+ self._panel_open = True
483
526
  try:
484
527
  import s4l_card
485
528
 
@@ -508,7 +551,9 @@ class S4LMenuBar(rumps.App):
508
551
  return
509
552
  with self._review_lock:
510
553
  self._posts_outstanding += 1
554
+ self._posting_batch_total += 1
511
555
  self._review_active = True
556
+ self._write_posting_activity_locked()
512
557
  self._post_q.put((batch, decision))
513
558
  self._ensure_post_worker()
514
559
 
@@ -521,6 +566,7 @@ class S4LMenuBar(rumps.App):
521
566
  self._panel_open = False
522
567
  if self._posts_outstanding <= 0:
523
568
  self._review_active = False
569
+ self._reset_posting_progress_locked()
524
570
  st.clear_review_request()
525
571
  if not any(d.get("approved") for d in decisions):
526
572
  self._notify("S4L", "No drafts approved — nothing posted.")
@@ -535,19 +581,24 @@ class S4LMenuBar(rumps.App):
535
581
 
536
582
  def _post_worker_loop(self):
537
583
  # 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.
584
+ # the shared harness Chrome simultaneously. The menu bar passes a burst
585
+ # progress label into post_drafts, so the spinner shows e.g. "posting 17/95"
586
+ # even though each server call is still one approved draft.
540
587
  while True:
541
588
  batch, decision = self._post_q.get() # blocks until a card is approved
542
589
  n = decision.get("n")
543
590
  try:
544
591
  self._notify("S4L", f"Posting draft {n}…")
592
+ with self._review_lock:
593
+ activity_label = self._posting_activity_label_locked()
545
594
  if decision.get("edited"):
546
595
  res = st.post_drafts(
547
- batch, edits=[{"n": n, "text": decision.get("text") or ""}]
596
+ batch,
597
+ edits=[{"n": n, "text": decision.get("text") or ""}],
598
+ activity_label=activity_label,
548
599
  )
549
600
  else:
550
- res = st.post_drafts(batch, post=[n])
601
+ res = st.post_drafts(batch, post=[n], activity_label=activity_label)
551
602
  if res is None:
552
603
  self._notify(
553
604
  "S4L", "Couldn't post — open Claude Desktop and try the draft again."
@@ -564,9 +615,13 @@ class S4LMenuBar(rumps.App):
564
615
  _capture(e, phase="post_card")
565
616
  finally:
566
617
  with self._review_lock:
618
+ self._posting_batch_done += 1
567
619
  self._posts_outstanding -= 1
568
- if self._posts_outstanding <= 0 and not self._panel_open:
620
+ if self._posts_outstanding > 0:
621
+ self._write_posting_activity_locked()
622
+ elif not self._panel_open:
569
623
  self._review_active = False
624
+ self._reset_posting_progress_locked()
570
625
  self._post_q.task_done()
571
626
 
572
627
  def _render_title(self, setup_complete, ob, blocker):
@@ -299,6 +299,36 @@ def read_activity():
299
299
  return read_json("activity.json")
300
300
 
301
301
 
302
+ def write_activity(state: str, label: str):
303
+ """Best-effort local activity update. The MCP server normally owns this file,
304
+ but the menu-bar posting queue knows the whole approved-card burst while the
305
+ server only sees one post_drafts call at a time."""
306
+ try:
307
+ p = Path(state_dir()) / "activity.json"
308
+ p.parent.mkdir(parents=True, exist_ok=True)
309
+ p.write_text(
310
+ json.dumps(
311
+ {
312
+ "state": state,
313
+ "label": label,
314
+ "since": time_iso(),
315
+ }
316
+ )
317
+ + "\n"
318
+ )
319
+ except Exception:
320
+ pass
321
+
322
+
323
+ def time_iso():
324
+ try:
325
+ import datetime
326
+
327
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
328
+ except Exception:
329
+ return ""
330
+
331
+
302
332
  def read_review_request():
303
333
  return read_json("review-request.json")
304
334
 
@@ -320,10 +350,10 @@ def read_plan(plan_path):
320
350
 
321
351
 
322
352
  def review_drafts(plan):
323
- """Flatten a plan into the card model: only candidates not already posted."""
353
+ """Flatten a plan into the card model: only unfinished candidates."""
324
354
  out = []
325
355
  for i, c in enumerate(((plan or {}).get("candidates") or [])):
326
- if c.get("posted") is True:
356
+ if c.get("posted") is True or c.get("terminal") is True:
327
357
  continue
328
358
  out.append(
329
359
  {
@@ -334,15 +364,17 @@ def review_drafts(plan):
334
364
  "link_url": c.get("link_url"),
335
365
  }
336
366
  )
367
+ # The review queue is append-only, so the highest stable index is newest and
368
+ # most likely to still be live on X.
369
+ out.sort(key=lambda d: d["n"], reverse=True)
337
370
  return out
338
371
 
339
372
 
340
- def post_drafts(batch_id, post=None, edits=None, timeout=900):
373
+ def post_drafts(batch_id, post=None, edits=None, timeout=900, activity_label=None):
341
374
  """Post approved drafts via the loopback tool. `post` = 1-based numbers to
342
375
  post as-is; `edits` = [{n, text}] to rewrite then post. Returns the parsed
343
376
  result, or None if the loopback is unreachable (Claude Desktop closed)."""
344
- return loopback_tool(
345
- "post_drafts",
346
- {"batch_id": batch_id, "post": post or [], "edits": edits or []},
347
- timeout=timeout,
348
- )
377
+ args = {"batch_id": batch_id, "post": post or [], "edits": edits or []}
378
+ if activity_label:
379
+ args["__saps_activity_label"] = activity_label
380
+ return loopback_tool("post_drafts", args, timeout=timeout)
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.91",
3
+ "version": "1.6.93",
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.91",
3
+ "version": "1.6.93",
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()
@@ -8,6 +8,7 @@ instance built via object.__new__ (bypassing rumps.App.__init__). Verifies:
8
8
  3. plain approvals -> post=[n]; edited approvals -> edits=[{n,text}]
9
9
  4. rejected cards never post
10
10
  5. _posts_outstanding / _review_active settle to idle once drained
11
+ 6. activity progress reflects the approved burst total, not each 1-item call
11
12
  """
12
13
  import os
13
14
  import queue
@@ -41,13 +42,21 @@ overlap_detected = []
41
42
  inflight = {"n": 0}
42
43
  inflight_lock = threading.Lock()
43
44
  calls = []
45
+ activity_events = []
44
46
 
45
- def fake_post_drafts(batch_id, post=None, edits=None, timeout=900):
47
+ def fake_post_drafts(batch_id, post=None, edits=None, timeout=900, activity_label=None):
46
48
  with inflight_lock:
47
49
  inflight["n"] += 1
48
50
  if inflight["n"] > 1:
49
51
  overlap_detected.append(True)
50
- calls.append({"batch": batch_id, "post": post or [], "edits": edits or []})
52
+ calls.append(
53
+ {
54
+ "batch": batch_id,
55
+ "post": post or [],
56
+ "edits": edits or [],
57
+ "activity_label": activity_label,
58
+ }
59
+ )
51
60
  time.sleep(0.15) # simulate a slow post so overlaps would be caught
52
61
  with inflight_lock:
53
62
  inflight["n"] -= 1
@@ -57,6 +66,7 @@ def fake_post_drafts(batch_id, post=None, edits=None, timeout=900):
57
66
 
58
67
  st = types.ModuleType("s4l_state")
59
68
  st.post_drafts = fake_post_drafts
69
+ st.write_activity = lambda state, label: activity_events.append((state, label))
60
70
  st.accessibility_trusted = lambda: True
61
71
  st.clear_review_request = lambda: None
62
72
  sys.modules["s4l_state"] = st
@@ -70,6 +80,8 @@ app._post_worker = None
70
80
  app._review_lock = threading.Lock()
71
81
  app._panel_open = True
72
82
  app._posts_outstanding = 0
83
+ app._posting_batch_total = 0
84
+ app._posting_batch_done = 0
73
85
  app._review_active = False
74
86
  app._notify = lambda title, msg: None # silence Notification Center
75
87
 
@@ -106,6 +118,13 @@ posted_ns = [(c["post"], c["edits"]) for c in calls]
106
118
  expected = [([1], []), ([], [{"n": 2, "text": "edited two"}]), ([4], [])]
107
119
  if posted_ns != expected:
108
120
  fail.append(f"wrong calls/order: got {posted_ns}\n expected {expected}")
121
+ labels = [c["activity_label"] for c in calls if c.get("activity_label")]
122
+ if not any(label == "posting 2/3" for label in labels):
123
+ fail.append(f"second post did not carry burst progress 2/3: labels={labels}")
124
+ if not any(label == "posting 3/3" for label in labels):
125
+ fail.append(f"third post did not carry burst progress 3/3: labels={labels}")
126
+ if not any(event == ("posting", "posting 1/3") for event in activity_events):
127
+ fail.append(f"activity never expanded first post to 1/3: events={activity_events}")
109
128
  if any(c["post"] == [3] or any(e.get("n") == 3 for e in c["edits"]) for c in calls):
110
129
  fail.append("rejected card #3 was posted")
111
130
  with app._review_lock: