@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 CHANGED
@@ -2244,7 +2244,17 @@ tool("post_drafts", {
2244
2244
  return;
2245
2245
  }
2246
2246
  c.terminal = true;
2247
- c.terminal_reason = "rejected";
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
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.1",
3
- "installedAt": "2026-07-09T00:28:03.233Z"
2
+ "version": "1.7.2-rc.10",
3
+ "installedAt": "2026-07-09T05:38:51.644Z"
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.7.1",
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": {
@@ -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
- if (d.get("reply_text_en") or "").strip():
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
- self._notify(
2404
- "S4L draft autopilot not scheduled",
2405
- "No draft tasks are running on this Claude account (switching "
2406
- "accounts clears them). Open the S4L menu → “Set up draft schedule”.",
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(runtime_ready, setup_complete, ob, blocker, snap, attention, schedule_state)
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
- items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
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
- items.append(rumps.MenuItem("Uninstall S4L…", callback=self._reset_machine))
3066
- items.append(rumps.MenuItem("Quit", callback=self._quit_app))
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
- return []
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__":
@@ -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.1",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.1",
3
+ "version": "1.7.2-rc.10",
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",