@m13v/s4l 1.7.5-rc.23 → 1.7.5-rc.26

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.5-rc.23",
3
- "installedAt": "2026-07-15T22:57:05.841Z"
2
+ "version": "1.7.5-rc.26",
3
+ "installedAt": "2026-07-15T23:48:20.436Z"
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.5-rc.23",
5
+ "version": "1.7.5-rc.26",
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": {
@@ -14,7 +14,7 @@
14
14
  "type": "node",
15
15
  "entry_point": "dist/index.js",
16
16
  "mcp_config": {
17
- "command": "node",
17
+ "command": "${__dirname}/vendor/node-darwin-arm64/bin/node",
18
18
  "args": [
19
19
  "${__dirname}/dist/index.js"
20
20
  ],
@@ -0,0 +1,1023 @@
1
+ """Large centered multi-select canvas for draft review (AppKit / pyobjc).
2
+
3
+ `present_review_canvas(drafts, on_decision, on_complete, focus)` is the canvas
4
+ counterpart to `s4l_card.present_review`: same `drafts` input shape (see
5
+ s4l_state.review_drafts), same decision-dict contract fired via
6
+ on_decision/on_complete -- the menu bar's existing wiring
7
+ (_on_card_decision / _on_review_closed) drives either surface unchanged.
8
+ Where the corner card walks the backlog one draft at a time, this shows the
9
+ WHOLE pending backlog at once in one large centered window: one scrollable
10
+ row per thread (full thread text + its draft(s)), a checkbox per row, and a
11
+ bulk "Approve selected" / "Discard selected" action bar, for a reviewer who
12
+ wants to skim many threads and act on a handful rather than read one card
13
+ at a time. Rows the reviewer never touches are simply left pending when the
14
+ window closes (same as closing the corner card mid-stack); the menu bar's
15
+ existing snooze handling re-presents them later.
16
+
17
+ Rows are sorted by stats.virality_score descending when a candidate has one
18
+ (Twitter only, today, per score_twitter_candidates.py); candidates without a
19
+ score sort as -1 and keep review_drafts()'s existing newest-first order
20
+ among themselves (Python's stable sort) at the bottom of the list -- no new
21
+ scoring is invented for platforms that don't have one.
22
+
23
+ Two-draft threads (candidate["drafts"], Draft A / Draft B) render as two
24
+ side-by-side editable boxes in the same row (the wider canvas has room,
25
+ unlike the corner card's stacked layout, per 2026-07-15 product direction);
26
+ whichever box the reviewer's caret is in is the "armed" draft for that row,
27
+ via the exact same textViewDidChangeSelection_ convention the corner card
28
+ uses (see s4l_card.py's _update_draft_borders), just duplicated per-row.
29
+
30
+ Decided rows (approved/discarded) stay visible but frozen -- disabled
31
+ controls, dimmed, a status line -- rather than being removed and the rest
32
+ reflowed: correctness and a stable scroll position matter more here than
33
+ tidying the list.
34
+
35
+ Deliberately NOT reimplemented here (present on the corner card, cut for
36
+ this surface's first pass, none of them decision-contract changes so any
37
+ can land later): per-second live expiry countdowns (age/expiry is a static
38
+ label computed at render/extend time), hover-dwell telemetry on two-draft
39
+ boxes (draft_choice ships with hover_*_ms=0 and visited_other=False), the
40
+ stats/details hover popovers (folded into a single tooltip), and the
41
+ categorized reject-reason picker (discard here always ships
42
+ reject_category=None, same as the corner card's own "Reject commits with
43
+ no category" fallback path).
44
+
45
+ Must be driven on the main thread (mirrors s4l_card.py).
46
+ """
47
+
48
+ import datetime
49
+ import re
50
+ import time
51
+
52
+ import objc
53
+ from Foundation import NSObject, NSMakeRect, NSMakeSize, NSURL
54
+ from AppKit import (
55
+ NSApp,
56
+ NSButton,
57
+ NSFont,
58
+ NSScrollView,
59
+ NSTextView,
60
+ NSView,
61
+ NSColor,
62
+ NSWorkspace,
63
+ NSWindowOcclusionStateVisible,
64
+ NSBackingStoreBuffered,
65
+ NSFloatingWindowLevel,
66
+ NSApplicationActivationPolicyAccessory,
67
+ NSWindowStyleMaskTitled,
68
+ NSWindowStyleMaskClosable,
69
+ NSWindowStyleMaskUtilityWindow,
70
+ NSTextAlignmentRight,
71
+ NSBezelStyleRounded,
72
+ )
73
+
74
+ try:
75
+ from AppKit import NSButtonTypeSwitch
76
+ except Exception:
77
+ try:
78
+ from AppKit import NSSwitchButton as NSButtonTypeSwitch
79
+ except Exception:
80
+ NSButtonTypeSwitch = 3 # raw NSSwitchButton value, pre-10.12 AppKit
81
+ try:
82
+ from AppKit import NSBezelStyleInline
83
+ except Exception:
84
+ NSBezelStyleInline = None
85
+
86
+ # Reuse the corner card's panel class (Cmd+V/C/X/A/Z routing for a status-bar
87
+ # app with no Edit menu) and its styling/rendering helpers instead of
88
+ # duplicating them -- this module is a second PRESENTATION only, the drafts
89
+ # data, decision contract, and low-level AppKit skinning stay single-sourced
90
+ # in s4l_card.py.
91
+ from s4l_card import (
92
+ _ReviewPanel,
93
+ _label,
94
+ _editable_scroll,
95
+ _round_rect,
96
+ _frosted,
97
+ _fill_color,
98
+ _solid,
99
+ _fit_thread_body,
100
+ _engagement_line,
101
+ _details_lines,
102
+ _reply_heading_suffix,
103
+ _followers_str,
104
+ _age_expiry_display,
105
+ _mouse_screen,
106
+ _write_review_state,
107
+ _log,
108
+ NSWindowStyleMaskNonactivatingPanel,
109
+ )
110
+
111
+ # Strong reference to the live controller so pyobjc doesn't GC it mid-review,
112
+ # mirroring s4l_card._active. Separate global -- only one of the two surfaces
113
+ # is ever open at a time (the menu bar's _review_active/_panel_open flags
114
+ # already enforce that), but each module owns its own reference regardless.
115
+ _active = None
116
+
117
+ WIN_MARGIN = 20
118
+ HEADER_H = 46
119
+ ROW_H = 258
120
+ ROW_GAP = 16
121
+ ROW_PAD = 14
122
+
123
+
124
+ def _now_iso():
125
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
126
+
127
+
128
+ def _sort_key(d):
129
+ """virality_score descending when present (Twitter only today); -1 for
130
+ everything else so unscored candidates sort last, keeping their existing
131
+ review_drafts() order among themselves (stable sort). No score is
132
+ invented for platforms that don't have one."""
133
+ v = ((d or {}).get("stats") or {}).get("virality_score")
134
+ try:
135
+ return float(v)
136
+ except (TypeError, ValueError):
137
+ return -1.0
138
+
139
+
140
+ def _center_frame(screen):
141
+ """Large centered frame on the given screen -- the canvas is a
142
+ deliberate review session, not a glanceable corner widget."""
143
+ vf = screen.visibleFrame() if screen is not None else NSMakeRect(0, 0, 1440, 900)
144
+ w = min(1180.0, vf.size.width * 0.88)
145
+ h = vf.size.height * 0.86
146
+ x = vf.origin.x + (vf.size.width - w) / 2.0
147
+ y = vf.origin.y + (vf.size.height - h) / 2.0
148
+ return NSMakeRect(x, y, w, h)
149
+
150
+
151
+ class _FlippedView(NSView):
152
+ """Top-down document view: origin at the top-left, y grows downward.
153
+ Makes row stacking (and appending more rows via extend_drafts) simple
154
+ arithmetic independent of the document's current height, and the scroll
155
+ view opens showing the top of the list by default."""
156
+
157
+ def isFlipped(self):
158
+ return True
159
+
160
+
161
+ class _CanvasController(NSObject):
162
+ def initWithDrafts_onDecision_onComplete_focus_(
163
+ self, drafts, on_decision, on_complete, focus
164
+ ):
165
+ self = objc.super(_CanvasController, self).init()
166
+ if self is None:
167
+ return None
168
+ self._sorted = sorted(list(drafts), key=_sort_key, reverse=True)
169
+ self._on_decision = on_decision
170
+ self._on_complete = on_complete
171
+ self._focus = bool(focus)
172
+ self._decisions = []
173
+ self._rows = []
174
+ self._panel = None
175
+ self._scroll = None
176
+ self._doc = None
177
+ self._select_all_btn = None
178
+ self._count_label = None
179
+ self._approve_btn = None
180
+ self._discard_btn = None
181
+ self._presented_at = time.time()
182
+ self._last_decision_at = None
183
+ self._last_interaction_at = None
184
+ self._last_move_log = 0.0
185
+ self._build()
186
+ return self
187
+
188
+ # ---- surface lifecycle -------------------------------------------------
189
+
190
+ @objc.python_method
191
+ def _build(self):
192
+ frame = _center_frame(_mouse_screen())
193
+ style = (
194
+ NSWindowStyleMaskTitled
195
+ | NSWindowStyleMaskClosable
196
+ | NSWindowStyleMaskUtilityWindow
197
+ | NSWindowStyleMaskNonactivatingPanel
198
+ )
199
+ panel = _ReviewPanel.alloc().initWithContentRect_styleMask_backing_defer_(
200
+ frame, style, NSBackingStoreBuffered, False
201
+ )
202
+ panel.setLevel_(NSFloatingWindowLevel)
203
+ panel.setFloatingPanel_(True)
204
+ panel.setBecomesKeyOnlyIfNeeded_(True)
205
+ panel.setHidesOnDeactivate_(False)
206
+ panel.setReleasedWhenClosed_(False)
207
+ panel.setDelegate_(self)
208
+ self._panel = panel
209
+ self._render()
210
+ panel.orderFrontRegardless()
211
+ self._log_surface("presented")
212
+ if self._focus:
213
+ try:
214
+ NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
215
+ NSApp.activateIgnoringOtherApps_(True)
216
+ except Exception:
217
+ pass
218
+ panel.makeKeyAndOrderFront_(None)
219
+ panel.orderFrontRegardless()
220
+
221
+ @objc.python_method
222
+ def _render(self):
223
+ frame = self._panel.frame()
224
+ w, h = frame.size.width, frame.size.height
225
+ content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, w, h))
226
+
227
+ header_y = h - HEADER_H
228
+ select_all = NSButton.alloc().initWithFrame_(
229
+ NSMakeRect(WIN_MARGIN, header_y + 12, 110, 22)
230
+ )
231
+ select_all.setButtonType_(NSButtonTypeSwitch)
232
+ select_all.setTitle_("Select all")
233
+ select_all.setTarget_(self)
234
+ select_all.setAction_("selectAllToggled:")
235
+ content.addSubview_(select_all)
236
+ self._select_all_btn = select_all
237
+
238
+ self._count_label = _label(
239
+ NSMakeRect(WIN_MARGIN + 130, header_y + 14, 320, 18), "", size=12, bold=True
240
+ )
241
+ content.addSubview_(self._count_label)
242
+
243
+ approve_w, discard_w, btn_gap = 150.0, 140.0, 10.0
244
+ approve_x = w - WIN_MARGIN - approve_w
245
+ discard_x = approve_x - btn_gap - discard_w
246
+
247
+ discard_btn = NSButton.alloc().initWithFrame_(
248
+ NSMakeRect(discard_x, header_y + 8, discard_w, 28)
249
+ )
250
+ discard_btn.setTitle_("Discard selected")
251
+ discard_btn.setBezelStyle_(NSBezelStyleRounded)
252
+ try:
253
+ discard_btn.setHasDestructiveAction_(True)
254
+ except Exception:
255
+ pass
256
+ discard_btn.setTarget_(self)
257
+ discard_btn.setAction_("discardSelected:")
258
+ content.addSubview_(discard_btn)
259
+ self._discard_btn = discard_btn
260
+
261
+ approve_btn = NSButton.alloc().initWithFrame_(
262
+ NSMakeRect(approve_x, header_y + 8, approve_w, 28)
263
+ )
264
+ approve_btn.setTitle_("Approve selected")
265
+ approve_btn.setBezelStyle_(NSBezelStyleRounded)
266
+ approve_btn.setKeyEquivalent_("\r")
267
+ approve_btn.setTarget_(self)
268
+ approve_btn.setAction_("approveSelected:")
269
+ content.addSubview_(approve_btn)
270
+ self._approve_btn = approve_btn
271
+
272
+ body_y = WIN_MARGIN
273
+ body_h = header_y - WIN_MARGIN - 8
274
+ body_w = w - 2 * WIN_MARGIN
275
+ scroll = NSScrollView.alloc().initWithFrame_(
276
+ NSMakeRect(WIN_MARGIN, body_y, body_w, body_h)
277
+ )
278
+ scroll.setHasVerticalScroller_(True)
279
+ scroll.setAutohidesScrollers_(True)
280
+ scroll.setDrawsBackground_(False)
281
+ content.addSubview_(scroll)
282
+ self._scroll = scroll
283
+
284
+ self._rows = []
285
+ self._build_rows(scroll)
286
+
287
+ self._panel.setContentView_(_frosted(content))
288
+ self._refresh_header()
289
+
290
+ @objc.python_method
291
+ def _build_rows(self, scroll):
292
+ cs = scroll.contentSize()
293
+ doc_w = cs.width
294
+ n = len(self._sorted)
295
+ doc_h = max(cs.height, ROW_PAD + n * (ROW_H + ROW_GAP))
296
+ doc = _FlippedView.alloc().initWithFrame_(NSMakeRect(0, 0, doc_w, doc_h))
297
+ self._doc = doc
298
+ for i, d in enumerate(self._sorted):
299
+ row_y = ROW_PAD + i * (ROW_H + ROW_GAP)
300
+ self._add_row(doc, d, i, NSMakeRect(0, row_y, doc_w, ROW_H))
301
+ scroll.setDocumentView_(doc)
302
+
303
+ @objc.python_method
304
+ def _add_row(self, doc, d, idx, frame):
305
+ row_view = NSView.alloc().initWithFrame_(frame)
306
+ _round_rect(row_view)
307
+ try:
308
+ row_view.layer().setBackgroundColor_(_fill_color().CGColor())
309
+ except Exception:
310
+ pass
311
+ doc.addSubview_(row_view)
312
+
313
+ rw, rh = frame.size.width, frame.size.height
314
+ rm = 14
315
+
316
+ checkbox = NSButton.alloc().initWithFrame_(NSMakeRect(rm, rh - 30, 20, 20))
317
+ checkbox.setButtonType_(NSButtonTypeSwitch)
318
+ checkbox.setTitle_("")
319
+ checkbox.setTag_(idx)
320
+ checkbox.setTarget_(self)
321
+ checkbox.setAction_("rowCheckToggled:")
322
+ row_view.addSubview_(checkbox)
323
+
324
+ content_x = rm + 28
325
+ content_w = rw - content_x - rm
326
+
327
+ handle = (d.get("thread_author") or "").lstrip("@").strip()
328
+ followers = _followers_str(d.get("stats"))
329
+ heading = f"@{handle}" if handle else "(unknown author)"
330
+ if followers:
331
+ heading += f" · {followers}"
332
+ tag = _reply_heading_suffix(d)
333
+ if tag:
334
+ heading += f" — {tag}"
335
+ age_text, urgent = _age_expiry_display(
336
+ (d.get("stats") or {}).get("tweet_posted_at"), d.get("platform")
337
+ )
338
+ age_w = 150
339
+ age_x = rw - rm - age_w
340
+ heading_w = (age_x - 10 - content_x) if age_text else (content_w - 10)
341
+ row_view.addSubview_(
342
+ _label(
343
+ NSMakeRect(content_x, rh - 28, max(60, heading_w), 18),
344
+ heading,
345
+ size=12,
346
+ bold=True,
347
+ truncates=True,
348
+ )
349
+ )
350
+ if age_text:
351
+ age_label = _label(
352
+ NSMakeRect(age_x, rh - 28, age_w, 18),
353
+ age_text,
354
+ size=11,
355
+ bold=urgent,
356
+ muted=not urgent,
357
+ truncates=True,
358
+ )
359
+ age_label.setAlignment_(NSTextAlignmentRight)
360
+ row_view.addSubview_(age_label)
361
+
362
+ tooltip_bits = []
363
+ eng = _engagement_line(d.get("stats"))
364
+ if eng:
365
+ tooltip_bits.append(eng)
366
+ tooltip_bits.extend(_details_lines(d))
367
+ if tooltip_bits:
368
+ info_btn = NSButton.alloc().initWithFrame_(NSMakeRect(content_x, rh - 50, 18, 16))
369
+ if NSBezelStyleInline is not None:
370
+ try:
371
+ info_btn.setBezelStyle_(NSBezelStyleInline)
372
+ except Exception:
373
+ pass
374
+ info_btn.setTitle_("ⓘ")
375
+ info_btn.setFont_(NSFont.systemFontOfSize_(10))
376
+ try:
377
+ info_btn.setToolTip_("\n".join(tooltip_bits))
378
+ except Exception:
379
+ pass
380
+ row_view.addSubview_(info_btn)
381
+
382
+ thread_top = rh - 54
383
+ thread_h = 56
384
+ thread_scroll = NSScrollView.alloc().initWithFrame_(
385
+ NSMakeRect(content_x, thread_top - thread_h, content_w, thread_h)
386
+ )
387
+ thread_scroll.setHasVerticalScroller_(False)
388
+ thread_scroll.setDrawsBackground_(False)
389
+ thread_scroll.setBorderType_(0)
390
+ tcs = thread_scroll.contentSize()
391
+ thread_tv = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, tcs.width, tcs.height))
392
+ thread_tv.setEditable_(False)
393
+ thread_tv.setSelectable_(True)
394
+ thread_tv.setDrawsBackground_(False)
395
+ thread_tv.setDelegate_(self)
396
+ thread_tv.textContainer().setWidthTracksTextView_(True)
397
+ thread_scroll.setDocumentView_(thread_tv)
398
+ row_view.addSubview_(thread_scroll)
399
+ # thread_text_en (stamped at draft time for non-English drafts) is
400
+ # shown here so the reviewer reads English; the editable box below
401
+ # always keeps the ORIGINAL-language reply_text, which is what posts.
402
+ thread_text = (d.get("thread_text_en") or d.get("thread_text") or "").strip()
403
+ _fit_thread_body(thread_tv, thread_text, d.get("thread_url"), font_size=12, step=15, floor=10)
404
+
405
+ row = {
406
+ "d": d,
407
+ "n": d.get("n"),
408
+ "idx": idx,
409
+ "container": row_view,
410
+ "checkbox": checkbox,
411
+ "selected": False,
412
+ "decided": False,
413
+ "hidden": False,
414
+ "dual": False,
415
+ "armed_slot": 0,
416
+ "outlines": {},
417
+ "textviews": {},
418
+ "orig_texts": {},
419
+ "thread_tv": thread_tv,
420
+ "thread_url": d.get("thread_url"),
421
+ "interactions": [],
422
+ "discard_btn": None,
423
+ "status_label": None,
424
+ }
425
+
426
+ drafts_field = d.get("drafts")
427
+ dual = isinstance(drafts_field, list) and len(drafts_field) == 2
428
+ row["dual"] = dual
429
+ link = d.get("link_url")
430
+
431
+ def _compose(text):
432
+ text = text or ""
433
+ return text if (not link or link in text) else f"{text} {link}"
434
+
435
+ bottom_bar_h = 26
436
+ edit_bottom = bottom_bar_h + 4
437
+ edit_top = thread_top - thread_h - 8
438
+ label_h = 14 if dual else 0
439
+ edit_h = edit_top - edit_bottom - label_h
440
+
441
+ if dual:
442
+ gap2 = 10
443
+ box_w = (content_w - gap2) / 2.0
444
+ for slot in (0, 1):
445
+ box_x = content_x + slot * (box_w + gap2)
446
+ outline = NSView.alloc().initWithFrame_(NSMakeRect(box_x, edit_bottom, box_w, edit_h))
447
+ _round_rect(outline)
448
+ inset = 4
449
+ escroll, etv = _editable_scroll(
450
+ NSMakeRect(inset, inset, box_w - 2 * inset, edit_h - 2 * inset),
451
+ _compose(drafts_field[slot].get("text")),
452
+ )
453
+ etv.setDelegate_(self)
454
+ outline.addSubview_(escroll)
455
+ row_view.addSubview_(outline)
456
+ row_view.addSubview_(
457
+ _label(
458
+ NSMakeRect(box_x, edit_bottom + edit_h, box_w, label_h),
459
+ "Draft A" if slot == 0 else "Draft B",
460
+ size=10,
461
+ muted=True,
462
+ )
463
+ )
464
+ row["outlines"][slot] = outline
465
+ row["textviews"][slot] = etv
466
+ row["orig_texts"][slot] = (drafts_field[slot].get("text") or "").strip()
467
+ else:
468
+ outline = NSView.alloc().initWithFrame_(NSMakeRect(content_x, edit_bottom, content_w, edit_h))
469
+ _round_rect(outline)
470
+ inset = 4
471
+ escroll, etv = _editable_scroll(
472
+ NSMakeRect(inset, inset, content_w - 2 * inset, edit_h - 2 * inset),
473
+ _compose(d.get("reply_text")),
474
+ )
475
+ etv.setDelegate_(self)
476
+ outline.addSubview_(escroll)
477
+ row_view.addSubview_(outline)
478
+ row["outlines"][0] = outline
479
+ row["textviews"][0] = etv
480
+ row["orig_texts"][0] = (d.get("reply_text") or "").strip()
481
+
482
+ discard_btn = NSButton.alloc().initWithFrame_(NSMakeRect(content_x, 2, 90, 22))
483
+ discard_btn.setTitle_("Discard")
484
+ if NSBezelStyleInline is not None:
485
+ try:
486
+ discard_btn.setBezelStyle_(NSBezelStyleInline)
487
+ except Exception:
488
+ discard_btn.setBezelStyle_(NSBezelStyleRounded)
489
+ else:
490
+ discard_btn.setBezelStyle_(NSBezelStyleRounded)
491
+ try:
492
+ discard_btn.setHasDestructiveAction_(True)
493
+ except Exception:
494
+ pass
495
+ discard_btn.setFont_(NSFont.systemFontOfSize_(11))
496
+ discard_btn.setTag_(idx)
497
+ discard_btn.setTarget_(self)
498
+ discard_btn.setAction_("rowDiscard:")
499
+ row_view.addSubview_(discard_btn)
500
+ row["discard_btn"] = discard_btn
501
+
502
+ status_label = _label(
503
+ NSMakeRect(content_x + 100, 2, content_w - 100, 22), "", size=11, bold=True
504
+ )
505
+ status_label.setHidden_(True)
506
+ row_view.addSubview_(status_label)
507
+ row["status_label"] = status_label
508
+
509
+ self._rows.append(row)
510
+ if dual:
511
+ self._update_row_borders(row)
512
+
513
+ # ---- two-draft arm selection --------------------------------------------
514
+
515
+ def textViewDidChangeSelection_(self, notification):
516
+ # Same convention as the corner card (s4l_card._ReviewController):
517
+ # whichever draft box the reviewer's caret is in becomes the armed
518
+ # slot for that row. Linear `is` scan, not a dict keyed by the
519
+ # NSTextView itself -- mirrors the corner card's own defensive
520
+ # pattern rather than trusting pyobjc object identity for hashing.
521
+ try:
522
+ tv = notification.object()
523
+ except Exception:
524
+ return
525
+ for row in self._rows:
526
+ if not row["dual"] or row["decided"] or row["hidden"]:
527
+ continue
528
+ for slot, cand_tv in row["textviews"].items():
529
+ if cand_tv is not tv:
530
+ continue
531
+ if slot != row["armed_slot"]:
532
+ row["armed_slot"] = slot
533
+ self._update_row_borders(row)
534
+ return
535
+
536
+ @objc.python_method
537
+ def _update_row_borders(self, row):
538
+ if not row["dual"]:
539
+ return
540
+ selected_color = NSColor.blackColor()
541
+ for slot, outline in row["outlines"].items():
542
+ try:
543
+ layer = outline.layer()
544
+ if layer is None:
545
+ continue
546
+ if slot == row["armed_slot"]:
547
+ layer.setBorderWidth_(3.0)
548
+ layer.setBorderColor_(selected_color.CGColor())
549
+ tint = _solid(NSColor.textBackgroundColor()).blendedColorWithFraction_ofColor_(
550
+ 0.30, selected_color
551
+ )
552
+ layer.setBackgroundColor_((tint or selected_color).CGColor())
553
+ else:
554
+ layer.setBorderWidth_(1.0)
555
+ layer.setBorderColor_(_solid(NSColor.separatorColor()).CGColor())
556
+ layer.setBackgroundColor_(_solid(NSColor.textBackgroundColor()).CGColor())
557
+ except Exception:
558
+ pass
559
+
560
+ # ---- thread link click ---------------------------------------------------
561
+
562
+ def textView_clickedOnLink_atIndex_(self, tv, link, idx):
563
+ url = None
564
+ for row in self._rows:
565
+ if row.get("thread_tv") is tv:
566
+ url = row.get("thread_url")
567
+ break
568
+ try:
569
+ NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url or str(link)))
570
+ except Exception:
571
+ pass
572
+ self._last_interaction_at = time.time()
573
+ return True
574
+
575
+ # ---- selection / decisions ------------------------------------------------
576
+
577
+ def rowCheckToggled_(self, sender):
578
+ try:
579
+ idx = int(sender.tag())
580
+ except Exception:
581
+ return
582
+ if not (0 <= idx < len(self._rows)):
583
+ return
584
+ row = self._rows[idx]
585
+ if row["decided"] or row["hidden"]:
586
+ return
587
+ row["selected"] = bool(sender.state())
588
+ row["interactions"].append(
589
+ {"type": "canvas_select" if row["selected"] else "canvas_deselect", "ts": _now_iso()}
590
+ )
591
+ self._last_interaction_at = time.time()
592
+ self._refresh_header()
593
+
594
+ def selectAllToggled_(self, sender):
595
+ want = bool(sender.state())
596
+ for row in self._rows:
597
+ if row["decided"] or row["hidden"]:
598
+ continue
599
+ row["selected"] = want
600
+ try:
601
+ row["checkbox"].setState_(1 if want else 0)
602
+ except Exception:
603
+ pass
604
+ self._last_interaction_at = time.time()
605
+ self._refresh_header()
606
+
607
+ def rowDiscard_(self, sender):
608
+ try:
609
+ idx = int(sender.tag())
610
+ except Exception:
611
+ return
612
+ if not (0 <= idx < len(self._rows)):
613
+ return
614
+ self._apply_decision_to_row(self._rows[idx], False)
615
+ self._refresh_header()
616
+
617
+ def approveSelected_(self, sender):
618
+ acted = [r for r in self._rows if not r["decided"] and not r["hidden"] and r["selected"]]
619
+ for row in acted:
620
+ self._apply_decision_to_row(row, True)
621
+ self._refresh_header()
622
+
623
+ def discardSelected_(self, sender):
624
+ acted = [r for r in self._rows if not r["decided"] and not r["hidden"] and r["selected"]]
625
+ for row in acted:
626
+ self._apply_decision_to_row(row, False)
627
+ self._refresh_header()
628
+
629
+ @objc.python_method
630
+ def _current_text(self, row):
631
+ slot = row["armed_slot"] if row["dual"] else 0
632
+ tv = row["textviews"].get(slot)
633
+ if tv is not None:
634
+ try:
635
+ return str(tv.string())
636
+ except Exception:
637
+ pass
638
+ d = row["d"]
639
+ if row["dual"]:
640
+ drafts = d.get("drafts")
641
+ return (drafts[slot].get("text") or "") if drafts else ""
642
+ return d.get("reply_text") or ""
643
+
644
+ @objc.python_method
645
+ def _build_decision(self, row, approved, loved=False, reject_category=None, reject_note=None):
646
+ # Faithful port of s4l_card._ReviewController._record, adapted to
647
+ # per-row state instead of a single current-card index. Same field
648
+ # shape end to end so _ship_review_event / post_drafts need no
649
+ # changes to consume either surface's decisions.
650
+ d = row["d"]
651
+ dual = row["dual"]
652
+ if dual:
653
+ sel_idx = row["armed_slot"] if row["armed_slot"] in (0, 1) else 0
654
+ drafts = d.get("drafts")
655
+ chosen = drafts[sel_idx]
656
+ orig = (row["orig_texts"].get(sel_idx) or "").strip()
657
+ draft_variant = chosen.get("variant") or ("a" if sel_idx == 0 else "b")
658
+ other = drafts[1 - sel_idx]
659
+ draft_choice = {
660
+ "variant": draft_variant,
661
+ "index": sel_idx,
662
+ "auto_selected": bool(sel_idx == 0),
663
+ "style": chosen.get("style") or None,
664
+ "unchosen_text": (other.get("text") or "").strip() or None,
665
+ "unchosen_style": other.get("style") or None,
666
+ # No per-box hover tracking on this surface (see module
667
+ # docstring) -- shipped as known-zero, not fabricated.
668
+ "hover_a_ms": 0,
669
+ "hover_b_ms": 0,
670
+ "visited_other": False,
671
+ }
672
+ else:
673
+ sel_idx = None
674
+ orig = (row["orig_texts"].get(0) or "").strip()
675
+ draft_variant = None
676
+ draft_choice = None
677
+ link = d.get("link_url") or ""
678
+ drop_link = False
679
+ if approved:
680
+ text = self._current_text(row)
681
+ if link and link not in text:
682
+ drop_link = True
683
+ body = re.sub(r"[ \t]{2,}", " ", text).strip()
684
+ else:
685
+ body = orig
686
+ return {
687
+ "n": d.get("n"),
688
+ "approved": bool(approved),
689
+ "loved": bool(approved and loved),
690
+ "text": body,
691
+ "edited": bool(approved and body != orig),
692
+ "original_text": orig if (approved and body != orig) else None,
693
+ "drop_link": bool(approved and drop_link),
694
+ "reject_category": reject_category,
695
+ "reject_note": (reject_note or "").strip() or None,
696
+ "interactions": list(row.get("interactions") or []),
697
+ "dwell_ms": self._dwell_ms(),
698
+ "candidate_id": d.get("candidate_id"),
699
+ "platform": d.get("platform"),
700
+ "project": d.get("project"),
701
+ "thread_url": d.get("thread_url"),
702
+ "thread_author": d.get("thread_author"),
703
+ "language": d.get("language"),
704
+ "draft_variant": draft_variant,
705
+ "draft_index": sel_idx,
706
+ "draft_auto_selected": bool(dual and sel_idx == 0),
707
+ "draft_choice": draft_choice,
708
+ }
709
+
710
+ @objc.python_method
711
+ def _apply_decision_to_row(self, row, approved, loved=False, reject_category=None, reject_note=None):
712
+ if row["decided"] or row["hidden"]:
713
+ return
714
+ decision = self._build_decision(
715
+ row, approved, loved=loved, reject_category=reject_category, reject_note=reject_note
716
+ )
717
+ self._decisions.append(decision)
718
+ row["decided"] = True
719
+ self._freeze_row(row, approved)
720
+ self._last_decision_at = time.time()
721
+ self._log_surface("decision")
722
+ cb = self._on_decision
723
+ if cb is not None:
724
+ try:
725
+ cb(dict(decision))
726
+ except Exception:
727
+ pass
728
+
729
+ @objc.python_method
730
+ def _freeze_row(self, row, approved):
731
+ try:
732
+ row["checkbox"].setEnabled_(False)
733
+ row["checkbox"].setState_(0)
734
+ except Exception:
735
+ pass
736
+ for tv in row["textviews"].values():
737
+ try:
738
+ tv.setEditable_(False)
739
+ except Exception:
740
+ pass
741
+ try:
742
+ row["discard_btn"].setHidden_(True)
743
+ except Exception:
744
+ pass
745
+ try:
746
+ row["status_label"].setStringValue_("Approved ✓" if approved else "Discarded ✕")
747
+ row["status_label"].setHidden_(False)
748
+ except Exception:
749
+ pass
750
+ try:
751
+ row["container"].setAlphaValue_(0.55)
752
+ except Exception:
753
+ pass
754
+ row["selected"] = False
755
+
756
+ @objc.python_method
757
+ def _refresh_header(self):
758
+ pending = [r for r in self._rows if not r["decided"] and not r["hidden"]]
759
+ selected = [r for r in pending if r["selected"]]
760
+ try:
761
+ self._count_label.setStringValue_(f"{len(selected)} selected of {len(pending)} pending")
762
+ except Exception:
763
+ pass
764
+ try:
765
+ self._approve_btn.setEnabled_(bool(selected))
766
+ self._discard_btn.setEnabled_(bool(selected))
767
+ except Exception:
768
+ pass
769
+ try:
770
+ self._panel.setTitle_(f"s4l · Review {len(pending)} drafts (canvas)")
771
+ except Exception:
772
+ pass
773
+
774
+ # ---- live backlog changes -------------------------------------------------
775
+
776
+ @objc.python_method
777
+ def extend_drafts(self, drafts):
778
+ """Append newly-queued drafts as new rows below the current bottom.
779
+ Never touches an existing row's frame or content -- an in-progress
780
+ edit/selection on screen is never disturbed, mirroring the corner
781
+ card's extend_drafts. New drafts are sorted among THEMSELVES only
782
+ (not merged back into the whole list's order)."""
783
+ if self._panel is None or self._doc is None:
784
+ return 0
785
+ have = {r["n"] for r in self._rows}
786
+ added = [d for d in drafts if d.get("n") not in have]
787
+ if not added:
788
+ return 0
789
+ added.sort(key=_sort_key, reverse=True)
790
+ self._sorted.extend(added)
791
+ doc = self._doc
792
+ doc_w = doc.frame().size.width
793
+ base_idx = len(self._rows)
794
+ new_h = ROW_PAD + (base_idx + len(added)) * (ROW_H + ROW_GAP)
795
+ doc.setFrameSize_(NSMakeSize(doc_w, new_h))
796
+ for j, d in enumerate(added):
797
+ i = base_idx + j
798
+ row_y = ROW_PAD + i * (ROW_H + ROW_GAP)
799
+ self._add_row(doc, d, i, NSMakeRect(0, row_y, doc_w, ROW_H))
800
+ self._refresh_header()
801
+ self._log_surface("extended")
802
+ return len(added)
803
+
804
+ @objc.python_method
805
+ def prune_drafts(self, ns):
806
+ """Hide not-yet-decided rows whose plan index `n` is in `ns` (a
807
+ candidate that expired backend-side mid-review). The row's vertical
808
+ slot is left in place rather than reflowed -- a small visible gap is
809
+ an acceptable trade for never risking a mislaid, still-editable row
810
+ underneath it."""
811
+ if self._panel is None:
812
+ return 0
813
+ ns = set(ns)
814
+ removed = 0
815
+ for row in self._rows:
816
+ if row["decided"] or row["hidden"]:
817
+ continue
818
+ if row["n"] in ns:
819
+ row["hidden"] = True
820
+ row["selected"] = False
821
+ try:
822
+ row["container"].setHidden_(True)
823
+ except Exception:
824
+ pass
825
+ removed += 1
826
+ if removed:
827
+ self._refresh_header()
828
+ self._log_surface("pruned")
829
+ return removed
830
+
831
+ # ---- status / observability (same shape as s4l_card, see _write_review_state) --
832
+
833
+ @objc.python_method
834
+ def _dwell_ms(self):
835
+ if not self._presented_at:
836
+ return None
837
+ return int((time.time() - self._presented_at) * 1000)
838
+
839
+ @objc.python_method
840
+ def _occlusion_visible(self):
841
+ try:
842
+ return bool(self._panel.occlusionState() & NSWindowOcclusionStateVisible)
843
+ except Exception:
844
+ return None
845
+
846
+ @objc.python_method
847
+ def status_dict(self):
848
+ frame = None
849
+ try:
850
+ fr = self._panel.frame()
851
+ frame = [int(fr.origin.x), int(fr.origin.y), int(fr.size.width), int(fr.size.height)]
852
+ except Exception:
853
+ pass
854
+ screen_name = None
855
+ try:
856
+ scr = self._panel.screen()
857
+ if scr is not None:
858
+ screen_name = str(scr.localizedName())
859
+ except Exception:
860
+ pass
861
+ pending = sum(1 for r in self._rows if not r["decided"] and not r["hidden"])
862
+ return {
863
+ "open": self._panel is not None,
864
+ "total": len(self._rows),
865
+ "pending": pending,
866
+ "decided": len(self._decisions),
867
+ "presented_at": self._presented_at,
868
+ "last_decision_at": self._last_decision_at,
869
+ "last_interaction_at": self._last_interaction_at,
870
+ "occlusion_visible": self._occlusion_visible(),
871
+ "frame": frame,
872
+ "screen": screen_name,
873
+ "layout": "canvas",
874
+ }
875
+
876
+ @objc.python_method
877
+ def _log_surface(self, event):
878
+ s = self.status_dict()
879
+ _log(
880
+ f"canvas {event}: {s['pending']} pending of {s['total']}, "
881
+ f"frame={s['frame']} screen={s['screen']} visible={s['occlusion_visible']}"
882
+ )
883
+ _write_review_state(controller=self, last_event=event)
884
+
885
+ def windowDidMove_(self, notification):
886
+ now = time.time()
887
+ if now - self._last_move_log < 1.0:
888
+ return
889
+ self._last_move_log = now
890
+ self._log_surface("moved")
891
+
892
+ def windowDidChangeOcclusionState_(self, notification):
893
+ self._log_surface("occlusion_changed")
894
+
895
+ def windowShouldClose_(self, sender):
896
+ # Closing leaves undecided rows pending (not posted); the menu bar
897
+ # treats this exactly like an unfinished corner-card stack (snooze,
898
+ # re-present later), same as the corner card's own windowShouldClose_.
899
+ self._finish()
900
+ return True
901
+
902
+ @objc.python_method
903
+ def _finish(self):
904
+ global _active
905
+ try:
906
+ if self._panel is not None:
907
+ self._panel.setDelegate_(None)
908
+ self._panel.close()
909
+ except Exception:
910
+ pass
911
+ self._panel = None
912
+ cb, self._on_complete = self._on_complete, None
913
+ if cb is not None:
914
+ try:
915
+ cb(list(self._decisions))
916
+ except Exception:
917
+ pass
918
+ _active = None
919
+ _log(f"canvas closed: {len(self._decisions)} decided of {len(self._rows)}")
920
+ _write_review_state(controller=self, last_event="closed")
921
+
922
+
923
+ # ---- module-level API, mirrors s4l_card.py's public surface -----------------
924
+
925
+
926
+ def present_review_canvas(drafts, on_decision=None, on_complete=None, focus=False):
927
+ """Show the canvas review surface (main thread only). Same `drafts` shape
928
+ and decision contract as s4l_card.present_review -- see that docstring
929
+ and this module's for the differences. focus=True (the menu's "Review N
930
+ pending drafts") activates the app; a fresh/auto presentation does not
931
+ steal focus, same posture as the corner card."""
932
+ global _active
933
+ if not drafts:
934
+ if on_complete is not None:
935
+ on_complete([])
936
+ return
937
+ _active = _CanvasController.alloc().initWithDrafts_onDecision_onComplete_focus_(
938
+ drafts, on_decision, on_complete, focus
939
+ )
940
+
941
+
942
+ def extend_active(drafts):
943
+ if _active is None:
944
+ return 0
945
+ try:
946
+ return _active.extend_drafts(drafts)
947
+ except Exception:
948
+ return 0
949
+
950
+
951
+ def prune_active(ns):
952
+ if _active is None or not ns:
953
+ return 0
954
+ try:
955
+ return _active.prune_drafts(ns)
956
+ except Exception:
957
+ return 0
958
+
959
+
960
+ def active_status():
961
+ c = _active
962
+ if c is None or c._panel is None:
963
+ return None
964
+ try:
965
+ return c.status_dict()
966
+ except Exception:
967
+ return None
968
+
969
+
970
+ def heal_active():
971
+ """Move the open canvas window to the pointer's current screen and raise
972
+ it. Returns True if a window was open."""
973
+ c = _active
974
+ if c is None or c._panel is None:
975
+ return False
976
+ try:
977
+ c._panel.setFrame_display_(_center_frame(_mouse_screen()), True)
978
+ c._panel.orderFrontRegardless()
979
+ c._log_surface("healed")
980
+ return True
981
+ except Exception:
982
+ return False
983
+
984
+
985
+ def focus_active():
986
+ c = _active
987
+ if c is None or c._panel is None:
988
+ return False
989
+ try:
990
+ NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
991
+ NSApp.activateIgnoringOtherApps_(True)
992
+ except Exception:
993
+ pass
994
+ try:
995
+ c._panel.makeKeyAndOrderFront_(None)
996
+ c._panel.orderFrontRegardless()
997
+ c._panel.makeMainWindow()
998
+ return True
999
+ except Exception:
1000
+ return False
1001
+
1002
+
1003
+ def dismiss_active():
1004
+ """Force-close the open canvas WITHOUT firing on_decision/on_complete,
1005
+ for a bulk discard whose fate for every remaining row was already decided
1006
+ elsewhere (mirrors s4l_card.dismiss_active). Returns True if a window was
1007
+ actually open."""
1008
+ global _active
1009
+ c = _active
1010
+ if c is None or c._panel is None:
1011
+ return False
1012
+ try:
1013
+ c._panel.setDelegate_(None)
1014
+ c._panel.close()
1015
+ except Exception:
1016
+ pass
1017
+ c._panel = None
1018
+ c._on_complete = None
1019
+ c._on_decision = None
1020
+ _active = None
1021
+ _log(f"canvas closed: dismissed (bulk discard, {len(c._decisions)} decided of {len(c._rows)})")
1022
+ _write_review_state(controller=c, last_event="dismissed")
1023
+ return True
@@ -516,6 +516,13 @@ class S4LMenuBar(rumps.App):
516
516
  self._post_worker = None
517
517
  self._review_lock = threading.Lock()
518
518
  self._panel_open = False
519
+ # Which review-surface module ("cards" | "canvas") is driving the
520
+ # CURRENTLY open panel/window, None when nothing is open. Set at
521
+ # presentation time from st.read_review_layout(); kept fixed for the
522
+ # life of that surface so flipping the "Canvas review" menu checkbox
523
+ # mid-session never talks to the wrong module for an already-open
524
+ # panel. See _review_mod().
525
+ self._review_mod_name = None
519
526
  # Review snooze: epoch until which draft cards must not auto-present
520
527
  # (0 = not snoozed). Loaded from disk so a menubar restart mid-snooze
521
528
  # doesn't re-pop the card the user just put away.
@@ -1263,6 +1263,43 @@ def write_reveal_cadence(secs):
1263
1263
  return secs
1264
1264
 
1265
1265
 
1266
+ # Review surface layout (2026-07-15): which UI presents pending drafts.
1267
+ # "cards" (default) is the one-at-a-time corner-panel carousel (s4l_card.py);
1268
+ # "canvas" is the large centered multi-select grid (s4l_card_canvas.py). Lives
1269
+ # in mode.json next to reveal_cadence_secs, same read/write shape. Additive —
1270
+ # the menu bar picks the module to call per-review based on this value, both
1271
+ # modules share the exact same drafts list and decision contract, so nothing
1272
+ # about drafting/posting/the ledger changes when this flips.
1273
+ REVIEW_LAYOUT_DEFAULT = "cards"
1274
+ REVIEW_LAYOUTS = ("cards", "canvas")
1275
+
1276
+
1277
+ def read_review_layout():
1278
+ d = read_json(MODE_FILE)
1279
+ layout = d.get("review_layout") if isinstance(d, dict) else None
1280
+ return layout if layout in REVIEW_LAYOUTS else REVIEW_LAYOUT_DEFAULT
1281
+
1282
+
1283
+ def write_review_layout(layout):
1284
+ """Persist the review layout, preserving every other mode.json key. Returns
1285
+ the written value. Never raises (a menu click must not crash)."""
1286
+ if layout not in REVIEW_LAYOUTS:
1287
+ return read_review_layout()
1288
+ try:
1289
+ payload = read_json(MODE_FILE)
1290
+ if not isinstance(payload, dict):
1291
+ payload = {}
1292
+ payload["review_layout"] = layout
1293
+ p = Path(state_dir()) / MODE_FILE
1294
+ p.parent.mkdir(parents=True, exist_ok=True)
1295
+ tmp = p.with_suffix(".json.tmp")
1296
+ tmp.write_text(json.dumps(payload))
1297
+ os.replace(str(tmp), str(p))
1298
+ except Exception:
1299
+ pass
1300
+ return layout
1301
+
1302
+
1266
1303
  # --- Posting volume mode (2026-07-13) ---------------------------------------
1267
1304
  # Server-side per-install throttle for the twitter cycle's virality bar:
1268
1305
  # high|medium|low|None (None = driver default). Source of truth is
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.5-rc.23",
3
+ "version": "1.7.5-rc.26",
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.5-rc.23",
3
+ "version": "1.7.5-rc.26",
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",
@@ -19,6 +19,7 @@
19
19
  "scripts/*.py",
20
20
  "scripts/patches/",
21
21
  "!scripts/db_direct.py",
22
+ "!scripts/admin_fetch_install_config.py",
22
23
  "!scripts/sync_blocked_authors.py",
23
24
  "!scripts/backfill_real_clicks.py",
24
25
  "!scripts/historical_engagement.py",
@@ -359,6 +359,57 @@ fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
359
359
  console.log(' manifest tools ('+tools.length+'): '+tools.map(t=>t.name).join(', '));
360
360
  "
361
361
 
362
+ # ---- 3d. Vendor an owned Node runtime (bare .mcpb installs) ------------------
363
+ # manifest.json's mcp_config.command used to be the bare string "node", resolved
364
+ # by whatever spawns the extension. Claude Desktop's own chat bridge covers this
365
+ # with an internal "built-in Node" shortcut (an Electron-hosted UtilityProcess),
366
+ # but a DIFFERENT Desktop session type (agent-mode/Cowork, which spawns a real
367
+ # `claude` CLI subprocess with --mcp-config) does a literal subprocess spawn of
368
+ # "node" resolved via system PATH instead. On a box with no system Node/Homebrew
369
+ # (true of every MacStadium QA box we've checked), that spawn fails with ENOENT
370
+ # and that session type sees zero S4L tools — even though Desktop's own bridge
371
+ # reports "connected" for the SAME extension. Confirmed 2026-07-15: installing a
372
+ # real Node on a broken box fixed it immediately, no other change. Root cause is
373
+ # Desktop's own internal built-in-node eligibility, which is undocumented and
374
+ # apparently shifted between app versions without any change on our side (the
375
+ # manifest's "node" declaration is unchanged since the very first commit). Owning
376
+ # our Node the same way we already own Python (uv) and Chromium removes the
377
+ # dependency on Desktop's internal behavior entirely: manifest.json's command now
378
+ # points at this vendored binary's absolute path, so every session type — main
379
+ # chat, agent-mode, any future session type — spawns the SAME real binary.
380
+ # macOS arm64 only for now (matches every deployed box); revisit if x64 support
381
+ # is ever needed.
382
+ NODE_RUNTIME_VERSION="v24.18.0"
383
+ NODE_RUNTIME_PLATFORM="darwin-arm64"
384
+ NODE_VENDOR_DIR="$MCP_DIR/vendor/node-$NODE_RUNTIME_PLATFORM"
385
+ NODE_VENDOR_BIN="$NODE_VENDOR_DIR/bin/node"
386
+ NODE_CACHE_DIR="$REPO_ROOT/.cache/node-runtime"
387
+ say "Vendoring Node $NODE_RUNTIME_VERSION ($NODE_RUNTIME_PLATFORM) for the .mcpb"
388
+ if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
389
+ NODE_TARBALL="node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM.tar.gz"
390
+ NODE_CACHED_TARBALL="$NODE_CACHE_DIR/$NODE_TARBALL"
391
+ mkdir -p "$NODE_CACHE_DIR"
392
+ if [[ ! -f "$NODE_CACHED_TARBALL" ]]; then
393
+ say " downloading $NODE_TARBALL (not cached)"
394
+ curl -sL "https://nodejs.org/dist/$NODE_RUNTIME_VERSION/$NODE_TARBALL" -o "$NODE_CACHED_TARBALL.tmp" \
395
+ || die "Node download failed"
396
+ mv "$NODE_CACHED_TARBALL.tmp" "$NODE_CACHED_TARBALL"
397
+ else
398
+ say " using cached $NODE_CACHED_TARBALL"
399
+ fi
400
+ rm -rf "$NODE_VENDOR_DIR"
401
+ mkdir -p "$NODE_VENDOR_DIR/bin"
402
+ # Extract ONLY the node binary itself (no npm/npx/corepack — the server is
403
+ # invoked directly as `node dist/index.js`, nothing here ever needs npm).
404
+ tar -xzf "$NODE_CACHED_TARBALL" -O "node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM/bin/node" > "$NODE_VENDOR_BIN" \
405
+ || die "failed to extract node binary from $NODE_CACHED_TARBALL"
406
+ chmod +x "$NODE_VENDOR_BIN"
407
+ fi
408
+ [[ -x "$NODE_VENDOR_BIN" ]] || die "vendored node binary missing/not executable at $NODE_VENDOR_BIN"
409
+ VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
410
+ [[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
411
+ echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
412
+
362
413
  # ---- 4. Pack the .mcpb ------------------------------------------------------
363
414
  say "Packing $BUNDLE"
364
415
  rm -f "$BUNDLE"
@@ -407,13 +458,26 @@ for sub in mcp/dist/version.json mcp/package.json; do
407
458
  echo " pipeline.tgz $sub: $SUBV ok"
408
459
  done
409
460
 
410
- for f in "dist/index.js" "dist/runtime.js" "manifest.json"; do
461
+ for f in "dist/index.js" "dist/runtime.js" "manifest.json" "vendor/node-$NODE_RUNTIME_PLATFORM/bin/node"; do
411
462
  # grep -c reads all input (no SIGPIPE); anchor on the time column + 3-space
412
463
  # gutter so node_modules/.../dist/index.js does not false-match the top-level.
413
464
  n=$(printf '%s\n' "$LISTING" | grep -c "[0-9:] $f\$" || true)
414
465
  [[ "$n" -ge 1 ]] || die "bundle missing $f"
415
466
  done
416
- echo " runtime + server + manifest: ok"
467
+ echo " runtime + server + manifest + vendored node: ok"
468
+
469
+ # The vendored binary must survive the zip round-trip with its executable bit
470
+ # intact (unzip -x extracts to a temp file and runs --version; a permissions
471
+ # regression here would silently produce a Node that Desktop can spawn but that
472
+ # refuses to execute, or vice versa).
473
+ VENDOR_CHECK_DIR="$(mktemp -d)"
474
+ unzip -q -o "$BUNDLE" "vendor/node-$NODE_RUNTIME_PLATFORM/bin/node" -d "$VENDOR_CHECK_DIR"
475
+ VENDOR_CHECK_BIN="$VENDOR_CHECK_DIR/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node"
476
+ [[ -x "$VENDOR_CHECK_BIN" ]] || die "vendored node in bundle lost its executable bit"
477
+ VENDOR_CHECK_VER="$("$VENDOR_CHECK_BIN" --version)"
478
+ [[ "$VENDOR_CHECK_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node in bundle reports $VENDOR_CHECK_VER, expected $NODE_RUNTIME_VERSION"
479
+ rm -rf "$VENDOR_CHECK_DIR"
480
+ echo " vendored node runs from bundle: $VENDOR_CHECK_VER ok"
417
481
 
418
482
  if [[ "$DO_RELEASE" == "0" ]]; then
419
483
  say "Done (--no-release). Bundle ready at: $BUNDLE"
@@ -10,6 +10,15 @@ Usage:
10
10
  python3 scripts/twitter_prompt_sandbox.py --project fazm --status posted --limit 5 --out /tmp/sandbox.txt
11
11
  python3 scripts/twitter_prompt_sandbox.py --urls https://x.com/.../1,https://x.com/.../2 --out /tmp/sandbox.txt
12
12
 
13
+ # Another install's data (matched_project alone is NOT tenant-safe -- see
14
+ # --install-id's help text): pass --install-id, --project becomes optional
15
+ # (any of that install's candidates) or still scopes to one of their
16
+ # projects. Pair with admin_fetch_install_config.py for their real config
17
+ # + persona_corpus.txt too, if testing voice fidelity rather than just
18
+ # replaying their real threads:
19
+ python3 scripts/twitter_prompt_sandbox.py --install-id ba6519ca-edaf-4fee-95b9-446da86bd346 \\
20
+ --project PersonalBrand --out /tmp/sandbox_karol.txt
21
+
13
22
  Then run the cycle against it:
14
23
  S4L_SANDBOX_CANDIDATES_FILE=/tmp/sandbox.txt S4L_DRAFT_PROMPT_VARIANT=treatment_v4 \\
15
24
  bash skill/run-twitter-cycle.sh
@@ -57,7 +66,7 @@ def _age_hours(row: dict) -> float:
57
66
  return 0.0
58
67
 
59
68
 
60
- def fetch(project, status, limit, since, urls):
69
+ def fetch(project, status, limit, since, urls, install_id):
61
70
  query = {"limit": limit}
62
71
  if project:
63
72
  query["matched_project"] = project
@@ -67,6 +76,8 @@ def fetch(project, status, limit, since, urls):
67
76
  query["since"] = since
68
77
  if urls:
69
78
  query["tweet_urls"] = ",".join(urls)
79
+ if install_id:
80
+ query["install_id"] = install_id
70
81
  resp = api_get("/api/v1/twitter-candidates", query=query)
71
82
  return (resp.get("data") or {}).get("candidates") or []
72
83
 
@@ -102,15 +113,20 @@ def main() -> int:
102
113
  ap.add_argument("--limit", type=int, default=10)
103
114
  ap.add_argument("--since", help="ISO8601 discovered_at lower bound")
104
115
  ap.add_argument("--urls", help="comma-separated tweet_urls, explicit selection instead of a filtered pull")
116
+ ap.add_argument(
117
+ "--install-id",
118
+ help="scope the pull to one install (required for another install's data -- "
119
+ "matched_project alone is NOT tenant-safe, e.g. 'PersonalBrand' spans 7 installs)",
120
+ )
105
121
  ap.add_argument("--out", required=True, help="output path for the pipe-separated candidates file")
106
122
  args = ap.parse_args()
107
123
 
108
- if not args.project and not args.urls:
109
- print("[twitter_prompt_sandbox] pass --project or --urls to scope the pull", file=sys.stderr)
124
+ if not args.project and not args.urls and not args.install_id:
125
+ print("[twitter_prompt_sandbox] pass --project, --urls, or --install-id to scope the pull", file=sys.stderr)
110
126
  return 1
111
127
 
112
128
  urls = [u.strip() for u in args.urls.split(",") if u.strip()] if args.urls else None
113
- rows = fetch(args.project, args.status, args.limit, args.since, urls)
129
+ rows = fetch(args.project, args.status, args.limit, args.since, urls, args.install_id)
114
130
  if not rows:
115
131
  print("[twitter_prompt_sandbox] no candidates matched the given filters", file=sys.stderr)
116
132
  return 1
@@ -43,7 +43,12 @@ json.dump(d, open(p, "w"))' "$_bl_prof_dir" 2>/dev/null || true
43
43
  *.app/Contents/MacOS/*) _bl_app_bundle="${_bl_chrome_bin%%/Contents/MacOS/*}" ;;
44
44
  esac
45
45
  if [ "$(uname -s)" = "Darwin" ] && [ -n "$_bl_app_bundle" ] && [ -d "$_bl_app_bundle" ]; then
46
- open -n -g -a "$_bl_app_bundle" --args "$@" >/dev/null 2>&1 || true
46
+ # -j launches HIDDEN on top of -g's no-foreground: -g alone still let
47
+ # every wedge-heal relaunch register a `cause: launched` activation in
48
+ # the browser-foreground telemetry (the last remaining window-pop
49
+ # class, 2026-07-15). Occlusion/backgrounding flags keep a hidden
50
+ # Chrome rendering, so the pipeline is unaffected.
51
+ open -n -g -j -a "$_bl_app_bundle" --args "$@" >/dev/null 2>&1 || true
47
52
  else
48
53
  "${S4L_PYTHON:-python3}" -c 'import os,sys
49
54
  os.setsid()
@@ -1737,7 +1737,15 @@ ALL_PROJECTS_JSON=$(python3 -c "
1737
1737
  import json, os, sys
1738
1738
  repo_dir = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
1739
1739
  sys.path.insert(0, os.path.join(repo_dir, 'scripts'))
1740
- config = json.load(open(os.path.join(repo_dir, 'config.json')))
1740
+ # Prompt-sandbox config override (2026-07-15): a sandbox run testing another
1741
+ # install's real voice/persona (scripts/admin_fetch_install_config.py
1742
+ # materializes it) points this at a fetched copy instead of the operator's
1743
+ # own config.json. Checked BEFORE the real path since the repo-root
1744
+ # config.json is a symlink to the state dir -- the override must never
1745
+ # follow that symlink back to the operator's own config.
1746
+ _sandbox_dir = os.environ.get('S4L_SANDBOX_CONFIG_DIR')
1747
+ _config_path = os.path.join(_sandbox_dir, 'config.json') if _sandbox_dir else os.path.join(repo_dir, 'config.json')
1748
+ config = json.load(open(_config_path))
1741
1749
  projects = config.get('projects', [])
1742
1750
  # learned_preferences is a SINGLE install-wide block since 2026-07-08 (see
1743
1751
  # scripts/learned_preferences.py). Since 2026-07-14 it is NO LONGER stamped
@@ -1797,7 +1805,10 @@ import json, os, sys
1797
1805
  repo_dir = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
1798
1806
  sys.path.insert(0, os.path.join(repo_dir, 'scripts'))
1799
1807
  import learned_preferences as lp
1800
- config = json.load(open(os.path.join(repo_dir, 'config.json')))
1808
+ # Same sandbox config override as ALL_PROJECTS_JSON above (see that comment).
1809
+ _sandbox_dir = os.environ.get('S4L_SANDBOX_CONFIG_DIR')
1810
+ _config_path = os.path.join(_sandbox_dir, 'config.json') if _sandbox_dir else os.path.join(repo_dir, 'config.json')
1811
+ config = json.load(open(_config_path))
1801
1812
  block = lp.get_global_block(config)
1802
1813
  block.pop('history', None)
1803
1814
  print(json.dumps(block, indent=2))
@@ -2207,12 +2218,17 @@ release_lock "twitter-browser" 2>>"$LOG_FILE"
2207
2218
  # withholding real examples from control would confound "does showing real
2208
2219
  # examples help" with "does emphasizing them differently help."
2209
2220
  CORPUS_BLOCK=""
2210
- if [ -f "$REPO_DIR/persona_corpus.txt" ]; then
2221
+ # Same sandbox override as the config.json reads above: persona_corpus.txt is
2222
+ # a flat repo-root file (not symlinked), so it needs its own override branch
2223
+ # rather than following $REPO_DIR.
2224
+ _PERSONA_CORPUS_PATH="${S4L_SANDBOX_CONFIG_DIR:+$S4L_SANDBOX_CONFIG_DIR/persona_corpus.txt}"
2225
+ _PERSONA_CORPUS_PATH="${_PERSONA_CORPUS_PATH:-$REPO_DIR/persona_corpus.txt}"
2226
+ if [ -f "$_PERSONA_CORPUS_PATH" ]; then
2211
2227
  CORPUS_BLOCK="## ACCOUNT VOICE CORPUS (raw first-hand material — ground your reply in THIS)
2212
2228
  This is the account holder's own public writing and work, verbatim. Quote and draw real specifics from it: actual projects, real numbers, sharp opinions, real failures. Do NOT invent anything not supported here or in the project's voice block. Use it to make the reply concrete and unmistakably human, and as ground truth for HOW they write (capitalization, punctuation, contractions, length) regardless of which project this reply is for.
2213
- $(cat "$REPO_DIR/persona_corpus.txt")
2229
+ $(cat "$_PERSONA_CORPUS_PATH")
2214
2230
  "
2215
- log "Phase 2b-prep: injected account voice corpus ($(wc -c < "$REPO_DIR/persona_corpus.txt" | tr -d ' ') bytes)."
2231
+ log "Phase 2b-prep: injected account voice corpus ($(wc -c < "$_PERSONA_CORPUS_PATH" | tr -d ' ') bytes)."
2216
2232
  fi
2217
2233
 
2218
2234
  log "Phase 2b-prep: Claude reading threads and drafting replies (no post cap)..."