@m13v/s4l 1.7.5-rc.2 → 1.7.5-rc.21

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.
Files changed (48) hide show
  1. package/mcp/dist/index.js +571 -79
  2. package/mcp/dist/panel.html +2 -3
  3. package/mcp/dist/redditAuth.js +115 -0
  4. package/mcp/dist/runtime.js +83 -2
  5. package/mcp/dist/setup.js +142 -5
  6. package/mcp/dist/telemetry.js +13 -3
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +1 -1
  9. package/mcp/menubar/s4l_card.py +32 -6
  10. package/mcp/menubar/s4l_menubar.py +42 -111
  11. package/mcp/menubar/s4l_state.py +54 -187
  12. package/mcp/package.json +1 -1
  13. package/mcp/shared/onboarding-ledger.cjs +63 -4
  14. package/package.json +1 -1
  15. package/scripts/autopilot_stall_watch.py +3 -6
  16. package/scripts/browser_mutex.py +316 -0
  17. package/scripts/claude_job.py +21 -0
  18. package/scripts/config.py +118 -0
  19. package/scripts/counterparty_history.py +44 -6
  20. package/scripts/engage_github.py +7 -3
  21. package/scripts/engage_reddit.py +9 -3
  22. package/scripts/engagement_signal_feedback.py +256 -0
  23. package/scripts/learned_preferences.py +37 -14
  24. package/scripts/memory_snapshot.py +45 -0
  25. package/scripts/merge_review_queue.py +67 -3
  26. package/scripts/post_reddit.py +289 -24
  27. package/scripts/reddit_browser.py +62 -63
  28. package/scripts/reddit_browser_fetch.py +32 -0
  29. package/scripts/release-mcpb.sh +17 -0
  30. package/scripts/reset-test-machine.sh +13 -7
  31. package/scripts/scan_twitter_thread_followups.py +7 -1
  32. package/scripts/schedule_state.py +1 -1
  33. package/scripts/setup_reddit_auth.py +925 -0
  34. package/scripts/snapshot.py +96 -4
  35. package/scripts/stats.py +37 -13
  36. package/scripts/test_no_silent_fallbacks.py +281 -0
  37. package/scripts/trigger_promo_lane_test.sh +91 -0
  38. package/scripts/twitter_browser.py +183 -342
  39. package/scripts/twitter_scan.py +75 -10
  40. package/scripts/watchdog_hung_runs.py +15 -9
  41. package/skill/lib/harness-common.sh +344 -0
  42. package/skill/lib/linkedin-backend.sh +61 -161
  43. package/skill/lib/reddit-backend.sh +39 -125
  44. package/skill/lib/twitter-backend.sh +51 -273
  45. package/skill/lock.sh +57 -0
  46. package/skill/run-reddit-search-launchd.sh +4 -1
  47. package/skill/run-reddit-search.sh +51 -2
  48. package/skill/run-twitter-cycle.sh +44 -21
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-rc.2",
5
+ "version": "1.7.5-rc.21",
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": {
@@ -1180,20 +1180,45 @@ class _ReviewController(NSObject):
1180
1180
  age_label.setAlignment_(NSTextAlignmentRight)
1181
1181
  content.addSubview_(age_label)
1182
1182
  right_x -= age_w + 4
1183
- handle_w = right_x - (M + 78) - 4
1183
+ # Platform mark (brand identification, inline with the author row):
1184
+ # Reddit's orange "r/" vs X's glyph, so a mixed-platform review queue
1185
+ # reads at a glance which network each card posts to.
1186
+ _plat = (d.get("platform") or "twitter").lower()
1187
+ _mark = "r/" if _plat == "reddit" else "\U0001d54f"
1188
+ _mark_w = 18
1189
+ _mark_label = _label(
1190
+ NSMakeRect(M + 78, H - 70, _mark_w, 18), _mark, size=12, bold=True
1191
+ )
1192
+ if _plat == "reddit":
1193
+ try:
1194
+ # Reddit orangered (#FF4500); best effort, falls back to the
1195
+ # default label color on any AppKit hiccup.
1196
+ _mark_label.setTextColor_(
1197
+ NSColor.colorWithSRGBRed_green_blue_alpha_(1.0, 0.27, 0.0, 1.0)
1198
+ )
1199
+ except Exception:
1200
+ pass
1201
+ content.addSubview_(_mark_label)
1202
+ _author_x = M + 78 + _mark_w
1203
+ handle_w = right_x - _author_x - 4
1184
1204
  if handle:
1185
1205
  # Size the link to its text so the follower count can sit right
1186
1206
  # after the handle instead of at a fixed column.
1187
- text = f"@{handle}"
1207
+ text = f"u/{handle}" if _plat == "reddit" else f"@{handle}"
1188
1208
  measured = NSAttributedString.alloc().initWithString_attributes_(
1189
1209
  text, {NSFontAttributeName: _font(12, True)}
1190
1210
  ).size().width
1191
1211
  link_w = min(int(measured) + 8, handle_w)
1212
+ _prof_url = (
1213
+ f"https://www.reddit.com/user/{handle}"
1214
+ if _plat == "reddit"
1215
+ else f"https://x.com/{handle}"
1216
+ )
1192
1217
  self._add_link(
1193
1218
  content,
1194
- NSMakeRect(M + 78, H - 70, link_w, 18),
1219
+ NSMakeRect(_author_x, H - 70, link_w, 18),
1195
1220
  text,
1196
- f"https://x.com/{handle}",
1221
+ _prof_url,
1197
1222
  bold=True,
1198
1223
  kind="profile_click",
1199
1224
  )
@@ -1202,7 +1227,7 @@ class _ReviewController(NSObject):
1202
1227
  if followers and fol_w > 20:
1203
1228
  content.addSubview_(
1204
1229
  _label(
1205
- NSMakeRect(M + 78 + link_w, H - 70, fol_w, 18),
1230
+ NSMakeRect(_author_x + link_w, H - 70, fol_w, 18),
1206
1231
  f"· {followers}",
1207
1232
  size=11,
1208
1233
  muted=True,
@@ -1210,7 +1235,7 @@ class _ReviewController(NSObject):
1210
1235
  )
1211
1236
  else:
1212
1237
  content.addSubview_(
1213
- _label(NSMakeRect(M + 78, H - 70, handle_w, 18), "thread", size=12, bold=True, muted=True)
1238
+ _label(NSMakeRect(_author_x, H - 70, handle_w, 18), "thread", size=12, bold=True, muted=True)
1214
1239
  )
1215
1240
  # Non-English drafts: the prep step stamps display-only English
1216
1241
  # translations (thread_text_en / reply_text_en) on the candidate. The
@@ -1859,6 +1884,7 @@ class _ReviewController(NSObject):
1859
1884
  # decision can be shipped to /api/v1/review-events without
1860
1885
  # re-reading the plan.
1861
1886
  "candidate_id": d.get("candidate_id"),
1887
+ "platform": d.get("platform"),
1862
1888
  "project": d.get("project"),
1863
1889
  "thread_url": d.get("thread_url"),
1864
1890
  "thread_author": d.get("thread_author"),
@@ -334,7 +334,6 @@ SETUP_PROMPT = (
334
334
  "Keep going without asking me to approve each safe setup step. Ask only if I "
335
335
  "must interactively sign in or no product can be identified."
336
336
  )
337
- UPDATE_PROMPT = "Update the S4L plugin to the latest version."
338
337
  # Re-arm goes through the HOST create_scheduled_task path (the same one onboarding
339
338
  # uses) — it registers the routines under whatever account is logged in and shows
340
339
  # up in Routines. The host tool only runs inside an agent chat, so the menu bar
@@ -494,42 +493,6 @@ def _glyph(status):
494
493
  return GLYPH.get(status, "·")
495
494
 
496
495
 
497
- def _osa_quote(s):
498
- """Escape a Python string for an AppleScript double-quoted literal."""
499
- return s.replace("\\", "\\\\").replace('"', '\\"')
500
-
501
-
502
- def _claude_send_script(prompt):
503
- """AppleScript that focuses Claude, pastes `prompt` into the focused composer,
504
- and presses Return. Uses the clipboard (saved + restored) rather than slow
505
- per-character keystrokes, and waits longer on a cold launch so the window is
506
- ready before pasting."""
507
- p = _osa_quote(prompt)
508
- return "\n".join(
509
- [
510
- 'set prevClip to ""',
511
- "try",
512
- " set prevClip to (the clipboard as text)",
513
- "end try",
514
- f'set the clipboard to "{p}"',
515
- 'tell application "System Events" to set wasRunning to (exists process "Claude")',
516
- 'tell application "Claude" to activate',
517
- "if wasRunning then",
518
- " delay 0.5",
519
- "else",
520
- " delay 2.5",
521
- "end if",
522
- 'tell application "System Events"',
523
- ' keystroke "v" using {command down}',
524
- " delay 0.15",
525
- " key code 36",
526
- "end tell",
527
- "delay 0.3",
528
- 'if prevClip is not "" then set the clipboard to prevClip',
529
- ]
530
- )
531
-
532
-
533
496
  class S4LMenuBar(rumps.App):
534
497
  def __init__(self):
535
498
  super().__init__("S4L", quit_button=None)
@@ -710,23 +673,6 @@ class S4LMenuBar(rumps.App):
710
673
  except Exception:
711
674
  return False
712
675
 
713
- def _manual_paste_fallback(self, prompt, reason):
714
- """Automation couldn't paste (no Accessibility, or osascript failed). Don't
715
- dead-end: drop the prompt on the clipboard and open Claude so the user can
716
- paste it themselves (Cmd+V, Enter). This is what makes re-arm/setup usable
717
- even when the TCC grant is stale (granted but the running process still
718
- reads untrusted until restart)."""
719
- copied = self._copy_to_clipboard(prompt)
720
- self._open_claude()
721
- if copied:
722
- self._notify(
723
- "S4L · prompt copied to clipboard",
724
- f"{reason} Paste it into Claude (⌘V) and press Enter to continue.",
725
- )
726
- else:
727
- self._notify("S4L", f"{reason} Open Claude and type your request there.")
728
- return False
729
-
730
676
  def _clipboard_prompt(self, prompt, title, action_desc):
731
677
  """The menu bar's UNIVERSAL way to hand an agent-driven action to Claude
732
678
  without depending on the MCP/loopback being up (the menu bar can't inject
@@ -751,20 +697,13 @@ class S4LMenuBar(rumps.App):
751
697
  self._open_claude()
752
698
  return True
753
699
 
754
- def _send_to_claude(self, prompt):
755
- """Back-compat shim: every agent-driven menu action now uses the reliable
756
- clipboard-prompt model (no flaky auto-type). Delegates to _clipboard_prompt."""
757
- return self._clipboard_prompt(
758
- prompt, "Send to Claude", "Claude will take it from there"
759
- )
760
-
761
700
  # Agent-driven action: hand the full setup prompt to Claude via the clipboard.
762
701
  def _setup(self, _=None):
763
702
  self._clipboard_prompt(
764
703
  SETUP_PROMPT,
765
704
  "Set up S4L in Claude",
766
- "Claude will set up your runtime, connect X, configure your project, and "
767
- "schedule the autopilot",
705
+ "Claude will set up your runtime, connect X (or Reddit), configure your "
706
+ "project, and schedule the autopilot",
768
707
  )
769
708
 
770
709
 
@@ -1151,13 +1090,14 @@ class S4LMenuBar(rumps.App):
1151
1090
  )
1152
1091
 
1153
1092
  # Posting volume (2026-07-13): server-side per-install throttle for the
1154
- # twitter cycle's virality bar. None = driver default. The labels carry
1155
- # ballpark rates; exact per-install estimates live on the dashboard's
1156
- # Posting Volume section (Stats tab). Internal values stay high|medium|low
1157
- # (DB CHECK + API enum); Aggressive/Steady/Chill are display names only
1158
- # (renamed 2026-07-14 per user).
1093
+ # twitter cycle's virality bar. The labels carry ballpark rates; exact
1094
+ # per-install estimates live on the dashboard's Posting Volume section
1095
+ # (Stats tab). Internal values stay high|medium|low (DB CHECK + API enum);
1096
+ # Aggressive/Steady/Chill are display names only (renamed 2026-07-14).
1097
+ # There is NO separate "default" state (removed 2026-07-14 per user): an
1098
+ # install that never chose resolves to Steady server-side, so the menu
1099
+ # shows Steady checked.
1159
1100
  POSTING_MODE_PRESETS = (
1160
- (None, "Default (cycle setting)"),
1161
1101
  ("high", "Aggressive (100+ posts/day)"),
1162
1102
  ("medium", "Steady (~30 posts/day)"),
1163
1103
  ("low", "Chill (~5 posts/day)"),
@@ -1188,11 +1128,11 @@ class S4LMenuBar(rumps.App):
1188
1128
  sys.stderr.flush()
1189
1129
 
1190
1130
  def _posting_mode_label(self, mode):
1191
- """Menu copy for a posting-volume mode value."""
1131
+ """Menu copy for a posting-volume mode value (unknown/unset = Steady)."""
1192
1132
  for preset, label in self.POSTING_MODE_PRESETS:
1193
1133
  if preset == mode:
1194
1134
  return label
1195
- return "Default (cycle setting)"
1135
+ return "Steady (~30 posts/day)"
1196
1136
 
1197
1137
  def _on_posting_mode_preset(self, mode, _sender=None):
1198
1138
  """Set the server-side posting-volume mode (network POST; a click may
@@ -1304,26 +1244,6 @@ class S4LMenuBar(rumps.App):
1304
1244
  except Exception:
1305
1245
  pass
1306
1246
 
1307
- # ---- disable scheduled tasks (menu-bar driven) ------------------------
1308
- def _has_scheduled_tasks(self):
1309
- """Read-only: True if any S4L worker/autopilot task is registered in any
1310
- scheduled-tasks.json. Gates whether the 'Disable scheduled tasks' item is
1311
- worth showing."""
1312
- try:
1313
- wanted = set(WORKER_TASK_IDS) | set(DEPRECATED_TASK_IDS)
1314
- for f in glob.glob(SCHED_REGISTRY_GLOB):
1315
- try:
1316
- with open(f) as fh:
1317
- d = json.load(fh)
1318
- except Exception:
1319
- continue
1320
- for t in d.get("scheduledTasks", []):
1321
- if t.get("id") in wanted:
1322
- return True
1323
- except Exception:
1324
- pass
1325
- return False
1326
-
1327
1247
  def _pause_toggle(self, _=None):
1328
1248
  """Pause/Resume: the lighter, fully reversible alternative to Quit. Stops
1329
1249
  S4L's own scan/draft/post pipeline (the launchd kicker + its support
@@ -1475,9 +1395,6 @@ class S4LMenuBar(rumps.App):
1475
1395
  # exit as final. os._exit because we're on a background thread.
1476
1396
  os._exit(0)
1477
1397
 
1478
- def _update(self, _=None):
1479
- self._send_to_claude(UPDATE_PROMPT)
1480
-
1481
1398
  # ---- .mcpb self-update (menu-bar driven) ------------------------------
1482
1399
  @staticmethod
1483
1400
  def _ext_dir():
@@ -1594,13 +1511,6 @@ class S4LMenuBar(rumps.App):
1594
1511
  pass
1595
1512
  return dirs
1596
1513
 
1597
- @classmethod
1598
- def _claude_user_data_dir(cls):
1599
- """First custom --user-data-dir among running instances, or None.
1600
- Prefer _claude_user_data_dirs when relaunching after a kill — the
1601
- kill takes every profile down, not just this one."""
1602
- return next((d for d in cls._claude_user_data_dirs() if d), None)
1603
-
1604
1514
  @staticmethod
1605
1515
  def _claude_running():
1606
1516
  """True while a Claude Desktop main process is alive. pgrep -x matches
@@ -2213,6 +2123,14 @@ class S4LMenuBar(rumps.App):
2213
2123
  ):
2214
2124
  label = self._posting_label
2215
2125
  if label:
2126
+ # Menu bar stays CONCISE (user rule 2026-07-14): strip the trailing
2127
+ # elapsed-duration token the producer encodes in the label ("draft
2128
+ # 2m", "draft ⧖18m") from the TITLE only. The full label (with the
2129
+ # duration the stall detector's _label_elapsed_secs parses) still
2130
+ # lives in activity.json — this is display-side trimming, never a
2131
+ # change to the data.
2132
+ import re as _re
2133
+ label = _re.sub(r"\s*⧖?\s*\d+\s*[sm]\s*$", "", str(label)).strip() or label
2216
2134
  # The update arrow must stay visible even while a tool runs, so the
2217
2135
  # "update available" signal is never masked by activity. _tick skips the
2218
2136
  # title repaint while the spinner owns it, so the arrow is injected here.
@@ -3056,7 +2974,11 @@ class S4LMenuBar(rumps.App):
3056
2974
  cid = None
3057
2975
  st.review_event_add(
3058
2976
  {
3059
- "platform": "twitter",
2977
+ # reddit cards ship platform=reddit (2026-07-14); their
2978
+ # candidate_id is rd-prefixed and int()s to None above, so
2979
+ # the server's twitter_candidates row flip never fires for
2980
+ # them (by design — see merge_review_queue.py).
2981
+ "platform": decision.get("platform") or "twitter",
3060
2982
  "project": decision.get("project"),
3061
2983
  "candidate_id": cid,
3062
2984
  "batch_id": batch,
@@ -3135,17 +3057,19 @@ class S4LMenuBar(rumps.App):
3135
3057
  self._ship_review_event(batch, decision)
3136
3058
  if not decision.get("approved"):
3137
3059
  n = decision.get("n")
3138
- # Durable local record FIRST, mirroring approved_queue_add for approvals.
3060
+ # Durable local record FIRST, mirroring the approve branch below.
3139
3061
  # review_drafts() consults this, so the rejected card is suppressed from
3140
3062
  # re-review IMMEDIATELY and even if the loopback is down when the
3141
3063
  # background plan-flag write below runs. Without this, a reject was a
3142
3064
  # fire-and-forget loopback call with a swallowed exception, so rejects
3143
3065
  # silently vanished and the card "came back" — unlike durable approvals.
3066
+ # A failure here is captured (not silently swallowed) so a broken
3067
+ # durable write is visible instead of looking identical to success.
3144
3068
  try:
3145
3069
  st.store_stamp_decision(batch, decision)
3146
3070
  self._session_decisions.append(dict(decision))
3147
- except Exception:
3148
- pass
3071
+ except Exception as e:
3072
+ _capture(e, phase="reject_stamp_decision")
3149
3073
 
3150
3074
  def _persist_reject():
3151
3075
  try:
@@ -3160,8 +3084,15 @@ class S4LMenuBar(rumps.App):
3160
3084
  # restart resumes the drain instead of stranding it and re-presenting the
3161
3085
  # card. The in-memory _post_q below is just the fast path; this file is the
3162
3086
  # source of truth review_drafts() consults to avoid re-showing it.
3163
- st.store_stamp_decision(batch, decision)
3164
- self._session_decisions.append(dict(decision))
3087
+ # Same try/except shape as the reject branch above (was previously
3088
+ # unguarded here, an asymmetry with no intentional reason): a failure is
3089
+ # captured, not silently swallowed, and the post still gets enqueued below
3090
+ # either way — this write is a resume/dedup aid, not a gate on posting.
3091
+ try:
3092
+ st.store_stamp_decision(batch, decision)
3093
+ self._session_decisions.append(dict(decision))
3094
+ except Exception as e:
3095
+ _capture(e, phase="approve_stamp_decision")
3165
3096
  with self._review_lock:
3166
3097
  self._posts_outstanding += 1
3167
3098
  self._posting_batch_total += 1
@@ -3571,10 +3502,10 @@ class S4LMenuBar(rumps.App):
3571
3502
  items.append(cadence_menu)
3572
3503
 
3573
3504
  # Posting volume: server-side per-install throttle (virality bar).
3574
- # read_posting_mode() is cached and non-blocking; until the first
3575
- # refresh lands the submenu shows Default checked, then corrects on
3576
- # the next rebuild.
3577
- pv_mode = st.read_posting_mode()
3505
+ # read_posting_mode() is cached and non-blocking; unknown/unset shows
3506
+ # as Steady (the server resolves unset the same way), corrected on the
3507
+ # rebuild after the first refresh lands.
3508
+ pv_mode = st.read_posting_mode() or "medium"
3578
3509
  pv_menu = rumps.MenuItem(
3579
3510
  f"Posting volume: {self._posting_mode_label(pv_mode).split(' (')[0].lower()}"
3580
3511
  )