social-autoposter 1.6.92 → 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);
@@ -640,6 +649,12 @@ async function postApproved(batchId, plan) {
640
649
  "or set accounts.twitter.handle in config.json.",
641
650
  };
642
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;
643
658
  // Posting is a priority over scanning: abort any in-flight plugin scan so the
644
659
  // approved post takes the browser immediately instead of waiting on the lock.
645
660
  // Plugin pipeline only — never affects the plist autopilot.
@@ -692,13 +707,12 @@ async function postApproved(batchId, plan) {
692
707
  });
693
708
  }
694
709
  finally {
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).
700
- if (heldShellLock)
701
- scheduleShellLockRelease();
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();
702
716
  }
703
717
  })();
704
718
  // Persist the poster's own stdout/stderr to a dated log. Without this the post
@@ -2293,6 +2307,15 @@ function sigkillAllScans() {
2293
2307
  // hold EXPANDS as more cards get approved and there is never a gap between cards.
2294
2308
  const SHELL_LOCK_GRACE_MS = Number(process.env.SAPS_POST_LOCK_GRACE_MS) || 60_000;
2295
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;
2296
2319
  function cancelScheduledShellLockRelease() {
2297
2320
  if (shellLockReleaseTimer) {
2298
2321
  clearTimeout(shellLockReleaseTimer);
@@ -2303,6 +2326,7 @@ function scheduleShellLockRelease() {
2303
2326
  cancelScheduledShellLockRelease();
2304
2327
  shellLockReleaseTimer = setTimeout(() => {
2305
2328
  shellLockReleaseTimer = null;
2329
+ postingActive = false; // posting drained -> the autopilot may scan again
2306
2330
  releaseShellBrowserLock();
2307
2331
  }, SHELL_LOCK_GRACE_MS);
2308
2332
  }
@@ -2419,6 +2443,14 @@ function scanBusy() {
2419
2443
  "twitter-cycle slot). This is CONTENTION, not an empty result. Call scan_candidates again in " +
2420
2444
  "~20s to retry — do NOT conclude there are no candidates, and do NOT sleep; just re-call.");
2421
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
+ }
2422
2454
  // Resolve to {done:true,value} the moment `p` settles, or {done:false} after
2423
2455
  // `ms` — whichever is first. The job promise keeps running either way, so a
2424
2456
  // later poll re-attaches to it.
@@ -2535,6 +2567,14 @@ tool("scan_candidates", {
2535
2567
  "re-calling until it returns candidates. Never sleep or use a background wait to bridge the gap.",
2536
2568
  inputSchema: { project: z.string().optional() },
2537
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();
2538
2578
  // Long-poll the single in-flight scan job (see the ScanJob registry above).
2539
2579
  // A finished-but-unconsumed job: hand back its result and clear the slot so a
2540
2580
  // later call starts a fresh scan.
@@ -2641,7 +2681,7 @@ tool("submit_drafts", {
2641
2681
  // drafts are appended; a thread already in the queue (by URL) is skipped (one
2642
2682
  // draft per thread). Posted entries are KEPT in place so the 1-based card
2643
2683
  // numbering stays stable across runs — the menu bar, the chat table, and
2644
- // 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.
2645
2685
  const queue = [
2646
2686
  ...(readPlan(REVIEW_QUEUE_ID)?.candidates ?? []),
2647
2687
  ];
@@ -2656,7 +2696,7 @@ tool("submit_drafts", {
2656
2696
  added++;
2657
2697
  }
2658
2698
  writePlan(REVIEW_QUEUE_ID, { candidates: queue });
2659
- const pending = queue.filter((c) => c.posted !== true);
2699
+ const pending = queue.filter((c) => c.posted !== true && c.terminal !== true);
2660
2700
  // Drafts queued = the pipeline verified end-to-end without posting. This is the
2661
2701
  // onboarding "draft_verified" terminal goal (formerly completed by draft_cycle).
2662
2702
  if (added > 0)
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.92",
3
- "installedAt": "2026-06-23T20:21:02.288Z"
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.92",
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
@@ -465,6 +467,32 @@ class S4LMenuBar(rumps.App):
465
467
  self._maybe_start_review()
466
468
 
467
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
+
468
496
  def _maybe_start_review(self):
469
497
  if self._review_active:
470
498
  return
@@ -491,8 +519,10 @@ class S4LMenuBar(rumps.App):
491
519
  sig = tuple((d.get("n"), d.get("reply_text") or "") for d in drafts)
492
520
  if sig == self._last_review_sig:
493
521
  return
494
- self._review_active = True
495
- 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
496
526
  try:
497
527
  import s4l_card
498
528
 
@@ -521,7 +551,9 @@ class S4LMenuBar(rumps.App):
521
551
  return
522
552
  with self._review_lock:
523
553
  self._posts_outstanding += 1
554
+ self._posting_batch_total += 1
524
555
  self._review_active = True
556
+ self._write_posting_activity_locked()
525
557
  self._post_q.put((batch, decision))
526
558
  self._ensure_post_worker()
527
559
 
@@ -534,6 +566,7 @@ class S4LMenuBar(rumps.App):
534
566
  self._panel_open = False
535
567
  if self._posts_outstanding <= 0:
536
568
  self._review_active = False
569
+ self._reset_posting_progress_locked()
537
570
  st.clear_review_request()
538
571
  if not any(d.get("approved") for d in decisions):
539
572
  self._notify("S4L", "No drafts approved — nothing posted.")
@@ -548,19 +581,24 @@ class S4LMenuBar(rumps.App):
548
581
 
549
582
  def _post_worker_loop(self):
550
583
  # Serialized poster: one approved card at a time so two posts never drive
551
- # the shared harness Chrome simultaneously. The server's post_drafts writes
552
- # "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.
553
587
  while True:
554
588
  batch, decision = self._post_q.get() # blocks until a card is approved
555
589
  n = decision.get("n")
556
590
  try:
557
591
  self._notify("S4L", f"Posting draft {n}…")
592
+ with self._review_lock:
593
+ activity_label = self._posting_activity_label_locked()
558
594
  if decision.get("edited"):
559
595
  res = st.post_drafts(
560
- 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,
561
599
  )
562
600
  else:
563
- res = st.post_drafts(batch, post=[n])
601
+ res = st.post_drafts(batch, post=[n], activity_label=activity_label)
564
602
  if res is None:
565
603
  self._notify(
566
604
  "S4L", "Couldn't post — open Claude Desktop and try the draft again."
@@ -577,9 +615,13 @@ class S4LMenuBar(rumps.App):
577
615
  _capture(e, phase="post_card")
578
616
  finally:
579
617
  with self._review_lock:
618
+ self._posting_batch_done += 1
580
619
  self._posts_outstanding -= 1
581
- 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:
582
623
  self._review_active = False
624
+ self._reset_posting_progress_locked()
583
625
  self._post_q.task_done()
584
626
 
585
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
 
@@ -340,12 +370,11 @@ def review_drafts(plan):
340
370
  return out
341
371
 
342
372
 
343
- 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):
344
374
  """Post approved drafts via the loopback tool. `post` = 1-based numbers to
345
375
  post as-is; `edits` = [{n, text}] to rewrite then post. Returns the parsed
346
376
  result, or None if the loopback is unreachable (Claude Desktop closed)."""
347
- return loopback_tool(
348
- "post_drafts",
349
- {"batch_id": batch_id, "post": post or [], "edits": edits or []},
350
- timeout=timeout,
351
- )
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.92",
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.92",
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"
@@ -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: