@m13v/s4l 1.7.1-rc.21 → 1.7.1-rc.22

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.1-rc.21",
3
- "installedAt": "2026-07-08T19:14:19.569Z"
2
+ "version": "1.7.1-rc.22",
3
+ "installedAt": "2026-07-08T20:01:27.584Z"
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-rc.21",
5
+ "version": "1.7.1-rc.22",
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": {
@@ -619,7 +619,7 @@ class _ReviewController(NSObject):
619
619
  # Two-draft cards (2026-07-08 redesign, no-recommendation pass same
620
620
  # day): both drafts render at once as separate editable boxes;
621
621
  # `_selected_draft` (0=a, 1=b) is "whichever box the reviewer is
622
- # currently in", driven by focus (see textDidBeginEditing_ below),
622
+ # currently in", driven by caret movement (see textViewDidChangeSelection_ below),
623
623
  # not a button. None = not yet chosen this card, _render() defaults
624
624
  # it to slot 0 (Draft A) — the model never picks a favorite, so
625
625
  # there's no recommendation to default to instead. Reset to None on
@@ -1076,9 +1076,9 @@ class _ReviewController(NSObject):
1076
1076
  # day): show BOTH drafts at once, stacked, each independently
1077
1077
  # editable, rather than a toggle that swaps one field's content. No
1078
1078
  # buttons: selection is "whichever box the reviewer is currently in",
1079
- # shown via that box's own border (an accent outline vs a plain
1080
- # hairline, mirroring a standard focus ring) and updated live by
1081
- # textDidBeginEditing_ below. The model never picks a favorite
1079
+ # shown via a dedicated outline view wrapping that box (an accent
1080
+ # outline vs a plain hairline, mirroring a standard focus ring) and
1081
+ # updated live by textViewDidChangeSelection_ below. The model never picks a favorite
1082
1082
  # (removed 2026-07-08 per user: no ask-the-model-to-recommend), so
1083
1083
  # Draft A (slot 0) is simply the fixed default until the reviewer
1084
1084
  # clicks into Draft B. Absent/short (reused stale draft, legacy plan)
@@ -1089,7 +1089,7 @@ class _ReviewController(NSObject):
1089
1089
  if dual:
1090
1090
  self._selected_draft = sel_idx
1091
1091
 
1092
- edit_top = H - 172 - 6
1092
+ edit_top = H - 172 - 3
1093
1093
  link = d.get("link_url")
1094
1094
 
1095
1095
  # Tail link baked at draft time is normally already in each draft's
@@ -1102,10 +1102,15 @@ class _ReviewController(NSObject):
1102
1102
 
1103
1103
  self._draft_textviews = {}
1104
1104
  self._draft_scrolls = {}
1105
+ self._draft_outlines = {}
1105
1106
  if dual:
1106
1107
  avail_h = edit_top - M
1107
1108
  gap = 5
1108
- label_h = 15
1109
+ # Reserved only for foreign-language cards, which show a muted
1110
+ # "EN:" line above each box; English drafts (the common case)
1111
+ # have nothing to put there, so reserving it unconditionally used
1112
+ # to leave a dead 15px gap above every box for no reason.
1113
+ label_h = 15 if is_foreign else 0
1109
1114
  unit = (avail_h - gap) / 2.0
1110
1115
  box_h = unit - label_h
1111
1116
  for slot in (0, 1):
@@ -1126,14 +1131,33 @@ class _ReviewController(NSObject):
1126
1131
  truncates=True,
1127
1132
  )
1128
1133
  )
1134
+ # The selection ring can't be drawn on the scroll view's own
1135
+ # layer: NSScrollView's opaque clip/document view fully paints
1136
+ # over its parent layer's border, so setBorderWidth/Color on
1137
+ # scroll.layer() is accepted (no error) but never visibly
1138
+ # renders, at any width or color (verified empirically). A
1139
+ # separate outline view, sized to the box and holding the
1140
+ # scroll view inset a few px inside it, keeps the ring
1141
+ # unobstructed since nothing opaque reaches its edge.
1142
+ outline_frame = NSMakeRect(M, box_y, W - 2 * M, box_h)
1143
+ outline = NSView.alloc().initWithFrame_(outline_frame)
1144
+ _round_rect(outline)
1145
+ inset = 3
1129
1146
  scroll, tv = _editable_scroll(
1130
- NSMakeRect(M, box_y, W - 2 * M, box_h), _compose(draft.get("text"))
1147
+ NSMakeRect(
1148
+ inset,
1149
+ inset,
1150
+ outline_frame.size.width - 2 * inset,
1151
+ outline_frame.size.height - 2 * inset,
1152
+ ),
1153
+ _compose(draft.get("text")),
1131
1154
  )
1132
1155
  tv.setDelegate_(self)
1133
- content.addSubview_(scroll)
1156
+ outline.addSubview_(scroll)
1157
+ content.addSubview_(outline)
1134
1158
  self._draft_scrolls[slot] = scroll
1159
+ self._draft_outlines[slot] = outline
1135
1160
  self._draft_textviews[slot] = tv
1136
- self._update_draft_borders()
1137
1161
  tv = self._draft_textviews[sel_idx]
1138
1162
  else:
1139
1163
  reply = d.get("reply_text") or ""
@@ -1161,6 +1185,13 @@ class _ReviewController(NSObject):
1161
1185
  self._textview = tv
1162
1186
 
1163
1187
  self._panel.setContentView_(_frosted(content))
1188
+ if dual:
1189
+ # Layer border properties set before a view is attached to its
1190
+ # eventual window get silently dropped when AppKit backs the view
1191
+ # for real on attach, so the accent outline must be (re)applied
1192
+ # only after setContentView_ installs the view tree, not during
1193
+ # construction above.
1194
+ self._update_draft_borders()
1164
1195
  # Counter lives in the native title bar, not inside the content, with
1165
1196
  # the product name so a stray card is identifiable at a glance.
1166
1197
  self._panel.setTitle_(
@@ -1342,14 +1373,18 @@ class _ReviewController(NSObject):
1342
1373
  pass
1343
1374
  return True
1344
1375
 
1345
- # NSTextView/NSText delegate: fires when a text view becomes the one
1346
- # being edited, i.e. right when it gains focus (click or tab), before any
1347
- # keystroke. Two-draft cards use this as the ONLY selection mechanism:
1348
- # whichever draft box the reviewer is in IS the selected one. Only the
1349
- # two draft boxes set self as delegate for this notification (the
1350
- # read-only thread quote never fires it), so no candidate_id lookup is
1351
- # needed, just a slot match against self._draft_textviews.
1352
- def textDidBeginEditing_(self, notification):
1376
+ # NSTextView delegate: fires the instant the caret/selection moves inside
1377
+ # a text view, which includes a plain click-to-place-cursor with no
1378
+ # keystroke (unlike NSTextDidBeginEditingNotification/textDidBeginEditing_,
1379
+ # which only fires once an actual edit starts and does NOT fire from
1380
+ # clicking into a box to merely place the cursor, verified empirically —
1381
+ # that gap was the "clicking the other draft doesn't select it" bug).
1382
+ # Two-draft cards use this as the ONLY selection mechanism: whichever
1383
+ # draft box the reviewer is in IS the selected one. Only the two draft
1384
+ # boxes set self as delegate for this notification (the read-only thread
1385
+ # quote never fires it), so no candidate_id lookup is needed, just a slot
1386
+ # match against self._draft_textviews.
1387
+ def textViewDidChangeSelection_(self, notification):
1353
1388
  try:
1354
1389
  tv = notification.object()
1355
1390
  except Exception:
@@ -1363,15 +1398,16 @@ class _ReviewController(NSObject):
1363
1398
 
1364
1399
  @objc.python_method
1365
1400
  def _update_draft_borders(self):
1366
- """Redraw the two draft boxes' borders in place (no re-render, so an
1367
- in-progress edit/caret in either box is never disturbed): the
1368
- selected box gets a thicker system-accent outline (the platform's own
1369
- selection color, respects the user's macOS accent choice and
1370
- light/dark mode), the other a plain hairline, matching the existing
1371
- thread-quote/editable-field border look."""
1372
- for slot, scroll in (self._draft_scrolls or {}).items():
1401
+ """Redraw the two draft boxes' selection ring in place (no re-render,
1402
+ so an in-progress edit/caret in either box is never disturbed): the
1403
+ selected box's outline view gets a thicker system-accent outline (the
1404
+ platform's own selection color, respects the user's macOS accent
1405
+ choice and light/dark mode), the other a plain hairline. Applied to
1406
+ the dedicated outline wrapper, not the scroll view itself; see the
1407
+ comment at its construction in _render for why."""
1408
+ for slot, outline in (self._draft_outlines or {}).items():
1373
1409
  try:
1374
- layer = scroll.layer()
1410
+ layer = outline.layer()
1375
1411
  if layer is None:
1376
1412
  continue
1377
1413
  if slot == self._selected_draft:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.1-rc.21",
3
+ "version": "1.7.1-rc.22",
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-rc.21",
3
+ "version": "1.7.1-rc.22",
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",
@@ -155,6 +155,7 @@
155
155
  "node": ">=16"
156
156
  },
157
157
  "dependencies": {
158
+ "@sentry/node": "^10.64.0",
158
159
  "firebase-admin": "^13.8.0",
159
160
  "pg": "^8.20.0",
160
161
  "ws": "^8.0.0"
@@ -262,22 +262,48 @@ def main() -> int:
262
262
  plan_obj = {"candidates": merged}
263
263
  if plan_created_at:
264
264
  plan_obj["created_at"] = plan_created_at
265
- _atomic_write(dst, plan_obj)
266
- ensure_store_symlink()
267
-
268
- # Refresh the review-request marker the menu bar polls (count = pending, not posted).
269
- pending = len([c for c in merged if not c.get("posted")])
270
- project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
271
- _atomic_write(
272
- review_request_path(),
273
- {
274
- "batch_id": REVIEW_QUEUE_ID,
275
- "project": project,
276
- "count": pending,
277
- "plan_path": dst,
278
- "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
279
- },
280
- )
265
+
266
+ # This is the actual delivery: if anything below throws, the cycle's drafts
267
+ # were computed but never reached the store the menu bar reads, and the
268
+ # wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
269
+ # stderr with `|| true`, so a crash here previously vanished into a local
270
+ # log nothing central reads the exact blind spot that cost the 2026-07-08
271
+ # Karol investigation its root cause. Report it like any other handled
272
+ # pipeline failure (see twitter_post_plan.py's post-failure capture).
273
+ try:
274
+ _atomic_write(dst, plan_obj)
275
+ ensure_store_symlink()
276
+
277
+ # Refresh the review-request marker the menu bar polls (count = pending, not posted).
278
+ pending = len([c for c in merged if not c.get("posted")])
279
+ project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
280
+ _atomic_write(
281
+ review_request_path(),
282
+ {
283
+ "batch_id": REVIEW_QUEUE_ID,
284
+ "project": project,
285
+ "count": pending,
286
+ "plan_path": dst,
287
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
288
+ },
289
+ )
290
+ except Exception as e:
291
+ print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
292
+ try:
293
+ import sentry_init
294
+
295
+ sentry_init.init()
296
+ sentry_init.capture_message(
297
+ f"merge_review_queue delivery failed: {e}",
298
+ level="error",
299
+ tags={"component": "merge_review_queue", "added": str(added)},
300
+ extra={"plan_src": src},
301
+ )
302
+ sentry_init.flush(2.0)
303
+ except Exception:
304
+ pass
305
+ return 1
306
+
281
307
  print(
282
308
  f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
283
309
  f"({pending} pending total) from {os.path.basename(src)}",
@@ -56,6 +56,14 @@ SECRET_PATTERNS = [
56
56
  # Absolute home path leak. Placeholder forms (/Users/<you>, /Users/USERNAME) pass.
57
57
  HOME_PATH_RE = re.compile(r"/Users/(?!<|USER|USERNAME|you\b|me\b|name\b)[a-z0-9._-]{2,}", re.I)
58
58
 
59
+
60
+ def _home_path_exempt(path: str) -> bool:
61
+ """launchd .plist files legitimately require absolute paths — launchd does not
62
+ expand ~ or $HOME in ProgramArguments/StandardOutPath/etc. The repo already
63
+ tracks 70+ of them, so exempt this file class from the absolute-home-path rule.
64
+ Secrets, PII, and image checks still apply."""
65
+ return path.startswith("launchd/") and path.endswith(".plist")
66
+
59
67
  IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic", ".bmp", ".tiff"}
60
68
 
61
69
 
@@ -146,7 +154,7 @@ def scan(paths: list[str], staged: bool) -> tuple[list[str], list[str]]:
146
154
  if deny_re and deny_re.search(text):
147
155
  m = deny_re.search(text)
148
156
  hard.append(f"{path}:{lineno}: PII denylist match ('{m.group(0)}')")
149
- if HOME_PATH_RE.search(text):
157
+ if HOME_PATH_RE.search(text) and not _home_path_exempt(path):
150
158
  m = HOME_PATH_RE.search(text)
151
159
  (hard if staged else soft).append(
152
160
  f"{path}:{lineno}: absolute home path ('{m.group(0)}...')"