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 +51 -11
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +49 -7
- package/mcp/menubar/s4l_state.py +35 -6
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/test_percard_posting.py +21 -2
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
|
-
|
|
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
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
// through the whole posting phase, then releasing at the end).
|
|
700
|
-
|
|
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
|
|
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)
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.6.
|
|
5
|
+
"version": "1.6.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.
|
|
495
|
-
|
|
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
|
|
552
|
-
#
|
|
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,
|
|
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
|
|
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):
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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.
|
|
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
|
@@ -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(
|
|
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:
|