@m13v/s4l 1.7.1 → 1.7.2-rc.10
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 +21 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +92 -1
- package/mcp/menubar/s4l_menubar.py +218 -20
- package/mcp/menubar/s4l_state.py +72 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/claude_job.py +33 -5
- package/scripts/merge_review_queue.py +51 -21
- package/scripts/producer_deathwatch.py +210 -0
- package/scripts/run_claude.sh +33 -0
- package/scripts/salvage_orphaned_prep_results.py +69 -6
- package/scripts/scheduled_task_selfheal.py +26 -0
- package/skill/run-twitter-cycle.sh +44 -152
package/mcp/dist/index.js
CHANGED
|
@@ -2244,7 +2244,17 @@ tool("post_drafts", {
|
|
|
2244
2244
|
return;
|
|
2245
2245
|
}
|
|
2246
2246
|
c.terminal = true;
|
|
2247
|
-
|
|
2247
|
+
// Preserve a more specific reason the menu bar already stamped locally
|
|
2248
|
+
// (store_stamp_decision writes "human_rejected" for a card reject,
|
|
2249
|
+
// discard_all_pending writes "human_discarded_all" for the bulk-discard
|
|
2250
|
+
// button) BEFORE this loopback call ever fires, so by the time it lands
|
|
2251
|
+
// here the local reason is already the more informative one. Only
|
|
2252
|
+
// default to "rejected" when nothing more specific got there first
|
|
2253
|
+
// (e.g. a reject driven straight from chat, with no menu-bar card
|
|
2254
|
+
// involved at all).
|
|
2255
|
+
if (!c.terminal_reason) {
|
|
2256
|
+
c.terminal_reason = "rejected";
|
|
2257
|
+
}
|
|
2248
2258
|
c.approved = false;
|
|
2249
2259
|
rejected.push(n);
|
|
2250
2260
|
});
|
|
@@ -2837,6 +2847,16 @@ async function autopilotLoaded() {
|
|
|
2837
2847
|
// Claude turn, writes the result back, and stops.
|
|
2838
2848
|
// ===========================================================================
|
|
2839
2849
|
const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
|
|
2850
|
+
// v9 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
|
|
2851
|
+
// sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
|
|
2852
|
+
// within one continuous worker session) instead of drafting inline. Validated
|
|
2853
|
+
// via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
|
|
2854
|
+
// delegated work, survives); the one hard constraint proven: the delegated
|
|
2855
|
+
// sub-agent must never fully idle-wait (e.g. background + wait on a Monitor
|
|
2856
|
+
// notification) or the host kills the whole parent+child chain in 1-3 min.
|
|
2857
|
+
// Never live-fire tested against a real production job. Full design, what's
|
|
2858
|
+
// validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
|
|
2859
|
+
// Bump this constant to 9 only once that plan is actually implemented.
|
|
2840
2860
|
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
2841
2861
|
// How long ONE `next --wait-seconds` call polls before giving up and exiting.
|
|
2842
2862
|
// 240s (4 min): comfortably inside the 900s single-Bash-call survival verified
|
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.7.
|
|
5
|
+
"version": "1.7.2-rc.10",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -418,7 +418,21 @@ def _details_lines(d):
|
|
|
418
418
|
lines.append(
|
|
419
419
|
f"Original thread ({lang.lower()}): {_truncate(d.get('thread_text'), 280)}"
|
|
420
420
|
)
|
|
421
|
-
|
|
421
|
+
# Two-draft cards: reply_text_en only ever mirrors Draft A (the
|
|
422
|
+
# canonical single-draft field), so Draft B's translation would be
|
|
423
|
+
# silently dropped from the popover unless we read each slot's own
|
|
424
|
+
# text_en directly off the drafts array.
|
|
425
|
+
dual_drafts = d.get("drafts")
|
|
426
|
+
if isinstance(dual_drafts, list) and len(dual_drafts) == 2:
|
|
427
|
+
slot_labels = {"a": "Draft A", "b": "Draft B"}
|
|
428
|
+
for draft in dual_drafts:
|
|
429
|
+
text_en = (draft.get("text_en") or "").strip()
|
|
430
|
+
if text_en:
|
|
431
|
+
label = slot_labels.get(
|
|
432
|
+
(draft.get("variant") or "").strip().lower(), "Draft"
|
|
433
|
+
)
|
|
434
|
+
lines.append(f"{label} in English: {_truncate(text_en, 280)}")
|
|
435
|
+
elif (d.get("reply_text_en") or "").strip():
|
|
422
436
|
lines.append(
|
|
423
437
|
f"Draft in English: {_truncate(d.get('reply_text_en'), 280)}"
|
|
424
438
|
)
|
|
@@ -1586,6 +1600,37 @@ class _ReviewController(NSObject):
|
|
|
1586
1600
|
self._log_surface(f"extended +{len(added)}")
|
|
1587
1601
|
return len(added)
|
|
1588
1602
|
|
|
1603
|
+
@objc.python_method
|
|
1604
|
+
def prune_drafts(self, ns):
|
|
1605
|
+
"""Drop not-yet-reached drafts (plan index `n` in `ns`) from the stack,
|
|
1606
|
+
e.g. a card the backend already retired (expired freshness gate, etc.)
|
|
1607
|
+
while it was still waiting to be shown. Only ever removes entries AFTER
|
|
1608
|
+
the current index, so the card on screen right now and everything
|
|
1609
|
+
already decided are untouched -- nothing visible disappears out from
|
|
1610
|
+
under the user. Refreshes the title-bar counter live. Returns the
|
|
1611
|
+
count actually removed."""
|
|
1612
|
+
if self._panel is None or not ns:
|
|
1613
|
+
return 0
|
|
1614
|
+
ns = set(ns)
|
|
1615
|
+
kept = []
|
|
1616
|
+
removed = 0
|
|
1617
|
+
for i, d in enumerate(self._drafts):
|
|
1618
|
+
if i > self._idx and d.get("n") in ns:
|
|
1619
|
+
removed += 1
|
|
1620
|
+
continue
|
|
1621
|
+
kept.append(d)
|
|
1622
|
+
if not removed:
|
|
1623
|
+
return 0
|
|
1624
|
+
self._drafts = kept
|
|
1625
|
+
try:
|
|
1626
|
+
self._panel.setTitle_(
|
|
1627
|
+
f"s4l · Review draft {self._idx + 1} of {len(self._drafts)}"
|
|
1628
|
+
)
|
|
1629
|
+
except Exception:
|
|
1630
|
+
pass
|
|
1631
|
+
self._log_surface(f"pruned {removed} expired")
|
|
1632
|
+
return removed
|
|
1633
|
+
|
|
1589
1634
|
@objc.python_method
|
|
1590
1635
|
def _fire_decision(self):
|
|
1591
1636
|
# Fire the per-card callback the instant a decision is made, so an
|
|
@@ -1818,6 +1863,22 @@ def extend_active(drafts):
|
|
|
1818
1863
|
return 0
|
|
1819
1864
|
|
|
1820
1865
|
|
|
1866
|
+
def prune_active(ns):
|
|
1867
|
+
"""Remove not-yet-reached drafts (by plan index `n`) from the open review
|
|
1868
|
+
card, if one is up -- e.g. a card that expired on the backend mid-review
|
|
1869
|
+
(see merge_review_queue.py's backend sync). Never touches the card
|
|
1870
|
+
currently on screen or any already-decided one, so nothing visible is
|
|
1871
|
+
yanked out from under the user. Returns the count actually removed (0 if
|
|
1872
|
+
no card is open or none of `ns` are still ahead in the stack). Main thread
|
|
1873
|
+
only (called from the menu bar's rumps timer)."""
|
|
1874
|
+
if _active is None or not ns:
|
|
1875
|
+
return 0
|
|
1876
|
+
try:
|
|
1877
|
+
return _active.prune_drafts(ns)
|
|
1878
|
+
except Exception:
|
|
1879
|
+
return 0
|
|
1880
|
+
|
|
1881
|
+
|
|
1821
1882
|
def active_status():
|
|
1822
1883
|
"""Live review-surface snapshot for the menu bar's unattended-review
|
|
1823
1884
|
watchdog, or None when no card is open. Main thread only."""
|
|
@@ -1830,6 +1891,36 @@ def active_status():
|
|
|
1830
1891
|
return None
|
|
1831
1892
|
|
|
1832
1893
|
|
|
1894
|
+
def dismiss_active():
|
|
1895
|
+
"""Force-close the open review panel WITHOUT firing on_decision/on_complete,
|
|
1896
|
+
for a bulk discard whose fate for every remaining card was already decided
|
|
1897
|
+
elsewhere (the menu bar's store-level "Discard all pending drafts"). A
|
|
1898
|
+
normal windowShouldClose_ close still fires on_complete (leftover cards are
|
|
1899
|
+
just undecided); this path skips both callbacks entirely so the bulk
|
|
1900
|
+
discard's own bookkeeping is the only thing that runs. Returns True if a
|
|
1901
|
+
panel was actually open."""
|
|
1902
|
+
global _active
|
|
1903
|
+
c = _active
|
|
1904
|
+
if c is None or c._panel is None:
|
|
1905
|
+
return False
|
|
1906
|
+
try:
|
|
1907
|
+
c._close_stats_popover()
|
|
1908
|
+
except Exception:
|
|
1909
|
+
pass
|
|
1910
|
+
try:
|
|
1911
|
+
c._panel.setDelegate_(None)
|
|
1912
|
+
c._panel.close()
|
|
1913
|
+
except Exception:
|
|
1914
|
+
pass
|
|
1915
|
+
c._panel = None
|
|
1916
|
+
c._on_complete = None
|
|
1917
|
+
c._on_decision = None
|
|
1918
|
+
_active = None
|
|
1919
|
+
_log(f"closed: dismissed (bulk discard, {len(c._decisions)} decided of {len(c._drafts)})")
|
|
1920
|
+
_write_review_state(last_event="dismissed")
|
|
1921
|
+
return True
|
|
1922
|
+
|
|
1923
|
+
|
|
1833
1924
|
def heal_active():
|
|
1834
1925
|
"""Self-heal an unattended card: move it to the top-right of the screen the
|
|
1835
1926
|
pointer is on and raise it, WITHOUT stealing keyboard focus (the user is
|
|
@@ -775,6 +775,67 @@ class S4LMenuBar(rumps.App):
|
|
|
775
775
|
"that schedules the draft tasks for this account",
|
|
776
776
|
)
|
|
777
777
|
|
|
778
|
+
def _finish_schedule_setup(self, _=None):
|
|
779
|
+
"""One-click fix for schedule_state == 'missing' when
|
|
780
|
+
scheduled_task_selfheal.can_create_for_active_account() confirms a
|
|
781
|
+
session directory already exists for the active account (2026-07-08):
|
|
782
|
+
quit Claude, create the registration via a direct file write (the
|
|
783
|
+
same heal() the update flow uses — see its module docstring for why
|
|
784
|
+
this is equivalent to what create_scheduled_task would produce), then
|
|
785
|
+
relaunch. Primary action for this case now instead of re-arm: no
|
|
786
|
+
clipboard paste, no chat turn required. Re-arm remains the fallback
|
|
787
|
+
for the rarer case where no session directory exists yet for the
|
|
788
|
+
active account (see _build_menu) — fix 5 never fabricates one.
|
|
789
|
+
|
|
790
|
+
No confirm dialog: the restart is already disclosed in the menu item
|
|
791
|
+
label itself ("...(restarts Claude)", _build_menu) — a modal repeating
|
|
792
|
+
that would just be a second click for information the user already
|
|
793
|
+
has (2026-07-09). The notify toast below still tells them it started."""
|
|
794
|
+
_capture_msg(
|
|
795
|
+
"S4L finish-schedule-setup clicked",
|
|
796
|
+
phase="draft_schedule",
|
|
797
|
+
reason="missing",
|
|
798
|
+
_extra={"scheduled_tasks": _registry_summary_for_capture()},
|
|
799
|
+
)
|
|
800
|
+
self._notify("S4L", "Restarting Claude Desktop… setting up your draft schedule.")
|
|
801
|
+
threading.Thread(target=self._finish_schedule_setup_work, daemon=True).start()
|
|
802
|
+
|
|
803
|
+
def _finish_schedule_setup_work(self):
|
|
804
|
+
try:
|
|
805
|
+
user_data_dirs = self._claude_user_data_dirs()
|
|
806
|
+
self._quit_claude_and_wait()
|
|
807
|
+
# Claude is down: the same safe window _rewrite_scheduled_task_cwd
|
|
808
|
+
# always required. In-process is fine here (unlike the update
|
|
809
|
+
# flow's _fresh variant) — no new bundle was just downloaded, so
|
|
810
|
+
# the currently-running code IS the current code; nothing to go
|
|
811
|
+
# stale against.
|
|
812
|
+
self._rewrite_scheduled_task_cwd()
|
|
813
|
+
self._relaunch_claude(user_data_dirs)
|
|
814
|
+
self._sig = None
|
|
815
|
+
# Verify rather than claim success unconditionally — the earlier,
|
|
816
|
+
# now-deleted silent restart-fix drew a direct complaint for
|
|
817
|
+
# giving zero feedback either way (2026-07-08). CREATED_GRACE in
|
|
818
|
+
# schedule_state.py means a freshly-created, never-yet-fired task
|
|
819
|
+
# already reads "ok", so this doesn't need to wait for an actual
|
|
820
|
+
# first fire — just for Claude to be back up and the file to have
|
|
821
|
+
# landed.
|
|
822
|
+
time.sleep(12)
|
|
823
|
+
state = self._schedule_state()
|
|
824
|
+
if state == "ok":
|
|
825
|
+
self._notify(
|
|
826
|
+
"S4L drafts set up",
|
|
827
|
+
"Your draft schedule is registered — drafting starts within a few minutes.",
|
|
828
|
+
)
|
|
829
|
+
else:
|
|
830
|
+
self._notify(
|
|
831
|
+
"S4L couldn't finish setup",
|
|
832
|
+
"Restarting didn't register the schedule. Open the S4L menu → "
|
|
833
|
+
"“Set up draft schedule” to finish it manually.",
|
|
834
|
+
)
|
|
835
|
+
except Exception as e:
|
|
836
|
+
self._notify("S4L setup restart failed", str(e)[:140])
|
|
837
|
+
_capture(e, phase="finish_schedule_setup")
|
|
838
|
+
|
|
778
839
|
# ---- schedule-state detection ----------------------------------------
|
|
779
840
|
def _schedule_state(self):
|
|
780
841
|
"""Is the draft schedule registered AND running for the live account?
|
|
@@ -1079,7 +1140,7 @@ class S4LMenuBar(rumps.App):
|
|
|
1079
1140
|
"Uninstall: keep your X login + browser layer (quick uninstall).\n"
|
|
1080
1141
|
"Deep wipe: also remove the shared browser profiles + toolchain."
|
|
1081
1142
|
),
|
|
1082
|
-
ok="Uninstall & Restart Claude", cancel="Cancel", other="Deep wipe",
|
|
1143
|
+
ok="Uninstall & Restart Claude", cancel="Cancel", other="Deep wipe & Restart Claude",
|
|
1083
1144
|
)
|
|
1084
1145
|
if choice == 0: # cancel
|
|
1085
1146
|
return
|
|
@@ -2400,11 +2461,24 @@ class S4LMenuBar(rumps.App):
|
|
|
2400
2461
|
"schedule” to re-register it.",
|
|
2401
2462
|
)
|
|
2402
2463
|
else:
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2464
|
+
can_selfheal = False
|
|
2465
|
+
try:
|
|
2466
|
+
import scheduled_task_selfheal
|
|
2467
|
+
can_selfheal = scheduled_task_selfheal.can_create_for_active_account()
|
|
2468
|
+
except Exception:
|
|
2469
|
+
pass
|
|
2470
|
+
if can_selfheal:
|
|
2471
|
+
self._notify(
|
|
2472
|
+
"S4L draft autopilot not scheduled",
|
|
2473
|
+
"No draft tasks are running on this Claude account (switching "
|
|
2474
|
+
"accounts clears them). Open the S4L menu → “Finish setting up drafts”.",
|
|
2475
|
+
)
|
|
2476
|
+
else:
|
|
2477
|
+
self._notify(
|
|
2478
|
+
"S4L draft autopilot not scheduled",
|
|
2479
|
+
"No draft tasks are running on this Claude account (switching "
|
|
2480
|
+
"accounts clears them). Open the S4L menu → “Set up draft schedule”.",
|
|
2481
|
+
)
|
|
2408
2482
|
self._stall_notified = True
|
|
2409
2483
|
elif not attention and self._stall_notified:
|
|
2410
2484
|
if self._attention_clear_since is None:
|
|
@@ -2428,6 +2502,11 @@ class S4LMenuBar(rumps.App):
|
|
|
2428
2502
|
if ob
|
|
2429
2503
|
else 0
|
|
2430
2504
|
)
|
|
2505
|
+
# Pending draft-card count, for the bulk-discard menu item (visibility +
|
|
2506
|
+
# label). Cheap local JSON reads, same source _maybe_start_review uses.
|
|
2507
|
+
_, pending_drafts = self._pending_review()
|
|
2508
|
+
pending_count = len(pending_drafts)
|
|
2509
|
+
|
|
2431
2510
|
# _update_available / _latest_version are in the signature so a freshly
|
|
2432
2511
|
# detected update rebuilds the menu (adding "Update now & restart Claude Desktop") even mid-run.
|
|
2433
2512
|
sig = (
|
|
@@ -2449,10 +2528,14 @@ class S4LMenuBar(rumps.App):
|
|
|
2449
2528
|
schedule_state,
|
|
2450
2529
|
self._stall_reason_info,
|
|
2451
2530
|
os.path.exists(PAUSE_FLAG),
|
|
2531
|
+
pending_count,
|
|
2452
2532
|
)
|
|
2453
2533
|
if sig != self._sig:
|
|
2454
2534
|
self._sig = sig
|
|
2455
|
-
self._build_menu(
|
|
2535
|
+
self._build_menu(
|
|
2536
|
+
runtime_ready, setup_complete, ob, blocker, snap, attention, schedule_state,
|
|
2537
|
+
pending_count=pending_count,
|
|
2538
|
+
)
|
|
2456
2539
|
|
|
2457
2540
|
# Draft-review pop-ups: if a draft cycle left a review request, present the
|
|
2458
2541
|
# cards. Don't start a review mid-run (the spinner means a tool is active).
|
|
@@ -2504,6 +2587,21 @@ class S4LMenuBar(rumps.App):
|
|
|
2504
2587
|
self._posting_batch_total = 0
|
|
2505
2588
|
self._posting_batch_done = 0
|
|
2506
2589
|
|
|
2590
|
+
def _pending_review(self):
|
|
2591
|
+
"""(batch_id, undecided draft cards) from the durable review store —
|
|
2592
|
+
the same source _maybe_start_review presents from. Cheap (small local
|
|
2593
|
+
JSON reads); drives the menu's bulk-discard item, both its visibility
|
|
2594
|
+
and what it acts on."""
|
|
2595
|
+
try:
|
|
2596
|
+
req = st.read_review_request()
|
|
2597
|
+
batch = (req or {}).get("batch_id")
|
|
2598
|
+
if not batch:
|
|
2599
|
+
return None, []
|
|
2600
|
+
plan = st.read_plan(req.get("plan_path") or "")
|
|
2601
|
+
return batch, st.review_drafts(plan)
|
|
2602
|
+
except Exception:
|
|
2603
|
+
return None, []
|
|
2604
|
+
|
|
2507
2605
|
def _maybe_start_review(self):
|
|
2508
2606
|
req = st.read_review_request()
|
|
2509
2607
|
if not req:
|
|
@@ -2534,6 +2632,13 @@ class S4LMenuBar(rumps.App):
|
|
|
2534
2632
|
# live. This is the fix for the "card froze at 1 of 4 while 137 piled
|
|
2535
2633
|
# up" bug — drafts that arrived after the card opened used to be
|
|
2536
2634
|
# stranded because this method returned early on _review_active.
|
|
2635
|
+
# Also prune the other direction: any `n` this same stack used to
|
|
2636
|
+
# carry but that dropped out of the fresh `drafts` list (merge_review_
|
|
2637
|
+
# queue.py's backend sync just marked it terminal, most commonly the
|
|
2638
|
+
# freshness gate expiring it) is removed from the not-yet-reached part
|
|
2639
|
+
# of the stack, so an old card can't sit there waiting to be approved
|
|
2640
|
+
# into a silent no-op (see the 2026-07-09 "approved 3, nothing
|
|
2641
|
+
# posted" investigation).
|
|
2537
2642
|
# - Posting is DRAINING with no panel up (_review_active but not
|
|
2538
2643
|
# _panel_open): leave the signature untouched so the full pending set
|
|
2539
2644
|
# is presented fresh once the drain completes (don't pop a card mid-post).
|
|
@@ -2542,6 +2647,11 @@ class S4LMenuBar(rumps.App):
|
|
|
2542
2647
|
try:
|
|
2543
2648
|
import s4l_card
|
|
2544
2649
|
|
|
2650
|
+
prev_ns = {n for n, _ in (self._last_review_sig or ())}
|
|
2651
|
+
cur_ns = {d.get("n") for d in drafts}
|
|
2652
|
+
vanished = prev_ns - cur_ns
|
|
2653
|
+
if vanished:
|
|
2654
|
+
s4l_card.prune_active(vanished)
|
|
2545
2655
|
s4l_card.extend_active(drafts)
|
|
2546
2656
|
except Exception as e:
|
|
2547
2657
|
sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
|
|
@@ -2800,6 +2910,66 @@ class S4LMenuBar(rumps.App):
|
|
|
2800
2910
|
if not any(d.get("approved") for d in decisions):
|
|
2801
2911
|
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
2802
2912
|
|
|
2913
|
+
def _discard_all_pending(self, _=None):
|
|
2914
|
+
"""Bulk-discard every pending draft card: rejected with NO reason,
|
|
2915
|
+
instantly, and — deliberately — without shipping a review event for any
|
|
2916
|
+
of them. A normal per-card reject always ships one (feeding the
|
|
2917
|
+
feedback-digest -> learned_preferences rail, see _ship_review_event);
|
|
2918
|
+
this bulk "clear the backlog" action carries no per-card judgment
|
|
2919
|
+
signal, so it must never reach that rail (user-specified 2026-07-08)."""
|
|
2920
|
+
batch, drafts = self._pending_review()
|
|
2921
|
+
if not batch or not drafts:
|
|
2922
|
+
return
|
|
2923
|
+
n = len(drafts)
|
|
2924
|
+
choice = _show_alert(
|
|
2925
|
+
title="Discard all pending drafts?",
|
|
2926
|
+
message=(
|
|
2927
|
+
f"Discards all {n} pending draft(s) right now, with no reason "
|
|
2928
|
+
"given. None of them will post or be shown for review again. "
|
|
2929
|
+
"This does not feed the AI feedback loop (learned_preferences) "
|
|
2930
|
+
"the way an individual reject does."
|
|
2931
|
+
),
|
|
2932
|
+
ok="Discard All", cancel="Cancel",
|
|
2933
|
+
)
|
|
2934
|
+
if choice != 1:
|
|
2935
|
+
return
|
|
2936
|
+
try:
|
|
2937
|
+
st.discard_all_pending(drafts)
|
|
2938
|
+
except Exception as e:
|
|
2939
|
+
sys.stderr.write(f"[s4l-menubar] discard all pending failed: {e}\n")
|
|
2940
|
+
sys.stderr.flush()
|
|
2941
|
+
_capture(e, phase="discard_all_pending")
|
|
2942
|
+
self._alert("Discard failed", str(e)[:200])
|
|
2943
|
+
return
|
|
2944
|
+
ns = [d.get("n") for d in drafts if d.get("n") is not None]
|
|
2945
|
+
cids = [d.get("candidate_id") for d in drafts if d.get("candidate_id") is not None]
|
|
2946
|
+
|
|
2947
|
+
def _persist_discard():
|
|
2948
|
+
try:
|
|
2949
|
+
st.post_drafts(batch, reject=ns, timeout=60)
|
|
2950
|
+
except Exception:
|
|
2951
|
+
pass
|
|
2952
|
+
try:
|
|
2953
|
+
st.flip_discarded_candidates_skipped(cids)
|
|
2954
|
+
except Exception:
|
|
2955
|
+
pass
|
|
2956
|
+
|
|
2957
|
+
threading.Thread(target=_persist_discard, daemon=True).start()
|
|
2958
|
+
try:
|
|
2959
|
+
import s4l_card
|
|
2960
|
+
|
|
2961
|
+
s4l_card.dismiss_active()
|
|
2962
|
+
except Exception:
|
|
2963
|
+
pass
|
|
2964
|
+
with self._review_lock:
|
|
2965
|
+
self._panel_open = False
|
|
2966
|
+
if self._posts_outstanding <= 0:
|
|
2967
|
+
self._review_active = False
|
|
2968
|
+
self._reset_posting_progress_locked()
|
|
2969
|
+
self._last_review_sig = None
|
|
2970
|
+
st.clear_review_request()
|
|
2971
|
+
self._notify("S4L", f"Discarded {n} pending draft(s).")
|
|
2972
|
+
|
|
2803
2973
|
def _ensure_post_worker(self):
|
|
2804
2974
|
# One persistent daemon worker drains the approved-card queue. It never
|
|
2805
2975
|
# exits (avoids an enqueue-vs-exit race) — an idle parked thread is cheap.
|
|
@@ -2905,7 +3075,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2905
3075
|
self.title = "S4L"
|
|
2906
3076
|
|
|
2907
3077
|
# ---- menu construction ------------------------------------------------
|
|
2908
|
-
def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok"):
|
|
3078
|
+
def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok", pending_count=0):
|
|
2909
3079
|
self.menu.clear()
|
|
2910
3080
|
items = []
|
|
2911
3081
|
|
|
@@ -2927,7 +3097,6 @@ class S4LMenuBar(rumps.App):
|
|
|
2927
3097
|
items.append(rumps.MenuItem("Resume S4L", callback=self._pause_toggle))
|
|
2928
3098
|
else:
|
|
2929
3099
|
items.append(rumps.MenuItem("Pause S4L", callback=self._pause_toggle))
|
|
2930
|
-
items.append(self._label(" stop drafting/posting, keep Claude + tray running"))
|
|
2931
3100
|
items.append(rumps.separator)
|
|
2932
3101
|
|
|
2933
3102
|
# Attention = the draft schedule isn't running for THIS account (missing or
|
|
@@ -2988,7 +3157,32 @@ class S4LMenuBar(rumps.App):
|
|
|
2988
3157
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
2989
3158
|
else:
|
|
2990
3159
|
items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
|
|
2991
|
-
|
|
3160
|
+
# Prefer the automatic fix (2026-07-08): if the active account
|
|
3161
|
+
# already has a session directory, _finish_schedule_setup can
|
|
3162
|
+
# create the registration directly (heal() fix 5) with no
|
|
3163
|
+
# clipboard paste needed. Fall back to re-arm only when fix 5
|
|
3164
|
+
# has nowhere to write (no session dir yet for this account —
|
|
3165
|
+
# it never fabricates one), since that's the one case where
|
|
3166
|
+
# only the live create_scheduled_task host tool can help.
|
|
3167
|
+
can_selfheal = False
|
|
3168
|
+
try:
|
|
3169
|
+
import scheduled_task_selfheal
|
|
3170
|
+
can_selfheal = scheduled_task_selfheal.can_create_for_active_account()
|
|
3171
|
+
except Exception:
|
|
3172
|
+
pass
|
|
3173
|
+
if can_selfheal:
|
|
3174
|
+
# Say "restarts Claude" in the label itself, not just the
|
|
3175
|
+
# confirm modal — a menu item should be honest about a
|
|
3176
|
+
# disruptive action (closing/reopening the whole app)
|
|
3177
|
+
# before the user has committed to clicking it, matching
|
|
3178
|
+
# what the old "Restart Claude Desktop to fix" button made
|
|
3179
|
+
# obvious upfront (2026-07-08).
|
|
3180
|
+
items.append(rumps.MenuItem(
|
|
3181
|
+
"Finish setting up drafts (restarts Claude)",
|
|
3182
|
+
callback=self._finish_schedule_setup,
|
|
3183
|
+
))
|
|
3184
|
+
else:
|
|
3185
|
+
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
2992
3186
|
items.append(rumps.separator)
|
|
2993
3187
|
|
|
2994
3188
|
if not runtime_ready:
|
|
@@ -2996,7 +3190,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2996
3190
|
elif not setup_complete:
|
|
2997
3191
|
items += self._state_b(ob, blocker)
|
|
2998
3192
|
else:
|
|
2999
|
-
items += self._state_c(snap)
|
|
3193
|
+
items += self._state_c(snap, pending_count)
|
|
3000
3194
|
|
|
3001
3195
|
# Engagement lanes — ALWAYS visible (every state), not just post-setup, so
|
|
3002
3196
|
# the user can see + flip either lane any time. Two INDEPENDENT checkmarks
|
|
@@ -3006,19 +3200,15 @@ class S4LMenuBar(rumps.App):
|
|
|
3006
3200
|
personal_on = bool(flags.get("personal_brand"))
|
|
3007
3201
|
promo_on = bool(flags.get("promotion"))
|
|
3008
3202
|
items.append(rumps.separator)
|
|
3009
|
-
items.append(self._label("Engagement lanes"))
|
|
3010
3203
|
pb_item = rumps.MenuItem("Personal brand", callback=self._toggle_personal)
|
|
3011
3204
|
pb_item.state = 1 if personal_on else 0
|
|
3012
3205
|
items.append(pb_item)
|
|
3013
|
-
items.append(self._label(" organic, link-free engagement"))
|
|
3014
3206
|
pr_item = rumps.MenuItem("Product promotion", callback=self._toggle_promotion)
|
|
3015
3207
|
pr_item.state = 1 if promo_on else 0
|
|
3016
3208
|
items.append(pr_item)
|
|
3017
|
-
items.append(self._label(" promoting your products (link replies)"))
|
|
3018
3209
|
if personal_on and promo_on:
|
|
3019
3210
|
# Both lanes on: the split becomes meaningful, so offer the presets.
|
|
3020
3211
|
share = st.read_split()
|
|
3021
|
-
items.append(self._label(f" both on · cycles split {self._split_pct(share)}"))
|
|
3022
3212
|
split_menu = rumps.MenuItem(f"Lane split: {self._split_pct(share)}")
|
|
3023
3213
|
for preset in self.SPLIT_PRESETS:
|
|
3024
3214
|
it = rumps.MenuItem(
|
|
@@ -3035,7 +3225,6 @@ class S4LMenuBar(rumps.App):
|
|
|
3035
3225
|
# carries a Feedback button); named for what it does to the pipeline,
|
|
3036
3226
|
# not the mechanism.
|
|
3037
3227
|
items.append(rumps.MenuItem("Give overall feedback to AI…", callback=self._menu_feedback))
|
|
3038
|
-
items.append(self._label(" overall guidance, steers future drafts"))
|
|
3039
3228
|
# While the update-verify marker is pending, the pipeline copy still
|
|
3040
3229
|
# resolves the OLD version (it only advances once the restarted server
|
|
3041
3230
|
# re-provisions repo/package, ~2 min), so the snapshot honestly reports
|
|
@@ -3062,8 +3251,10 @@ class S4LMenuBar(rumps.App):
|
|
|
3062
3251
|
items.append(rumps.separator)
|
|
3063
3252
|
items.append(rumps.MenuItem("Tidy autopilot history…", callback=self._prompt_relocate_tasks))
|
|
3064
3253
|
items.append(rumps.separator)
|
|
3065
|
-
|
|
3066
|
-
|
|
3254
|
+
quit_menu = rumps.MenuItem("Quit or Uninstall")
|
|
3255
|
+
quit_menu.add(rumps.MenuItem("Uninstall S4L…", callback=self._reset_machine))
|
|
3256
|
+
quit_menu.add(rumps.MenuItem("Quit", callback=self._quit_app))
|
|
3257
|
+
items.append(quit_menu)
|
|
3067
3258
|
|
|
3068
3259
|
# Collapse consecutive/edge separators so an empty section (e.g. State C
|
|
3069
3260
|
# now renders no status labels) can't leave a doubled or dangling divider.
|
|
@@ -3147,8 +3338,15 @@ class S4LMenuBar(rumps.App):
|
|
|
3147
3338
|
# The engagement-mode toggles live in _build_menu (shown in EVERY state), and
|
|
3148
3339
|
# there is deliberately no "Run draft cycle" / "Post approved drafts" item
|
|
3149
3340
|
# (the autopilot drafts on its own; approving a review card already posts it).
|
|
3150
|
-
def _state_c(self, snap):
|
|
3151
|
-
|
|
3341
|
+
def _state_c(self, snap, pending_count=0):
|
|
3342
|
+
if pending_count <= 0:
|
|
3343
|
+
return []
|
|
3344
|
+
return [
|
|
3345
|
+
rumps.MenuItem(
|
|
3346
|
+
f"Discard {pending_count} pending draft{'s' if pending_count != 1 else ''}…",
|
|
3347
|
+
callback=self._discard_all_pending,
|
|
3348
|
+
)
|
|
3349
|
+
]
|
|
3152
3350
|
|
|
3153
3351
|
|
|
3154
3352
|
if __name__ == "__main__":
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -770,6 +770,78 @@ def store_stamp_decision(batch, decision):
|
|
|
770
770
|
return bool(_store_update(mutate))
|
|
771
771
|
|
|
772
772
|
|
|
773
|
+
def discard_all_pending(drafts):
|
|
774
|
+
"""Bulk-reject an entire pending set (the menu bar's "Discard all pending
|
|
775
|
+
drafts"), durably and atomically in ONE lock acquisition. Each candidate is
|
|
776
|
+
stamped terminal with NO reason (reject_category=None), same as a card's
|
|
777
|
+
"Reject, no reason" button. Deliberately distinct from store_stamp_decision:
|
|
778
|
+
this never ships a review event, by design, so a bulk "clear the backlog"
|
|
779
|
+
click never reaches the review-events/feedback-digest rail and can't
|
|
780
|
+
pollute learned_preferences with a non-judgment. Returns how many were
|
|
781
|
+
stamped."""
|
|
782
|
+
|
|
783
|
+
def mutate(data):
|
|
784
|
+
done = 0
|
|
785
|
+
for d in drafts:
|
|
786
|
+
c = _match_candidate(data, d.get("n"), d.get("candidate_id"))
|
|
787
|
+
if c is None or c.get("posted") is True or c.get("terminal") is True:
|
|
788
|
+
continue
|
|
789
|
+
c["terminal"] = True
|
|
790
|
+
c["terminal_reason"] = "human_discarded_all"
|
|
791
|
+
c["decision"] = {
|
|
792
|
+
"approved": False,
|
|
793
|
+
"text": c.get("reply_text") or "",
|
|
794
|
+
"edited": False,
|
|
795
|
+
"drop_link": False,
|
|
796
|
+
"loved": False,
|
|
797
|
+
"reject_category": None,
|
|
798
|
+
"decided_at": time_iso(),
|
|
799
|
+
}
|
|
800
|
+
done += 1
|
|
801
|
+
return done
|
|
802
|
+
|
|
803
|
+
return _store_update(mutate) or 0
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def flip_discarded_candidates_skipped(candidate_ids):
|
|
807
|
+
"""Flip each bulk-discarded candidate's twitter_candidates row to
|
|
808
|
+
status='skipped', skip_reason='human_discarded_all' — the same DB effect a
|
|
809
|
+
normal reject gets for free as a side effect of its review-events insert
|
|
810
|
+
(see review-events/route.ts). The bulk-discard path deliberately never
|
|
811
|
+
ships a review event (that's the whole point — it must not reach the
|
|
812
|
+
feedback digest), so without this direct call the row would stay
|
|
813
|
+
'pending' until the independent age-based freshness gate happens to expire
|
|
814
|
+
it, wide open to re-discovery/re-drafting of the exact draft a human just
|
|
815
|
+
discarded. One PATCH per candidate via the SAME direct-HTTP path
|
|
816
|
+
flush_review_events already uses (http_api), so this never touches
|
|
817
|
+
review_events. Best-effort: a candidate that already expired/posted by the
|
|
818
|
+
time this runs (404, no longer 'pending') is a no-op, not a failure."""
|
|
819
|
+
if not candidate_ids:
|
|
820
|
+
return
|
|
821
|
+
try:
|
|
822
|
+
from http_api import api_patch
|
|
823
|
+
except Exception as err:
|
|
824
|
+
sys.stderr.write(
|
|
825
|
+
f"[s4l-state] flip_discarded_candidates_skipped: http_api unavailable "
|
|
826
|
+
f"({type(err).__name__}: {err}); {len(candidate_ids)} candidate(s) left 'pending'\n"
|
|
827
|
+
)
|
|
828
|
+
sys.stderr.flush()
|
|
829
|
+
return
|
|
830
|
+
for cid in candidate_ids:
|
|
831
|
+
try:
|
|
832
|
+
api_patch(
|
|
833
|
+
"/api/v1/twitter-candidates/by-id",
|
|
834
|
+
{"id": cid, "action": "mark_skipped", "reason": "human_discarded_all"},
|
|
835
|
+
ok_on_404=True,
|
|
836
|
+
)
|
|
837
|
+
except Exception as err:
|
|
838
|
+
sys.stderr.write(
|
|
839
|
+
f"[s4l-state] flip_discarded_candidates_skipped: PATCH failed for "
|
|
840
|
+
f"candidate_id={cid} ({type(err).__name__}: {err})\n"
|
|
841
|
+
)
|
|
842
|
+
sys.stderr.flush()
|
|
843
|
+
|
|
844
|
+
|
|
773
845
|
def store_mark_post_failed(batch, n, candidate_id=None, error=None):
|
|
774
846
|
"""A decided post that FAILED surfaces via notification/dashboard, not by
|
|
775
847
|
re-presenting the card and not by endless resume retries."""
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2-rc.10",
|
|
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