@m13v/s4l 1.7.6-rc.2 → 1.7.6-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 (35) hide show
  1. package/mcp/dist/index.js +256 -92
  2. package/mcp/dist/telemetry.js +2 -1
  3. package/mcp/dist/version.json +2 -2
  4. package/mcp/manifest.json +1 -1
  5. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  6. package/mcp/menubar/s4l_card.py +144 -18
  7. package/mcp/menubar/s4l_card_canvas.py +135 -14
  8. package/mcp/menubar/s4l_menubar.py +216 -73
  9. package/mcp/menubar/s4l_state.py +45 -130
  10. package/mcp/package.json +1 -1
  11. package/package.json +1 -1
  12. package/scripts/account_resolver.py +35 -1
  13. package/scripts/autopilot_stall_watch.py +8 -1
  14. package/scripts/browser_lifecycle.py +90 -0
  15. package/scripts/cdp_ready_check.py +81 -50
  16. package/scripts/draft_prompt_core.py +100 -17
  17. package/scripts/engagement_styles.py +135 -98
  18. package/scripts/merge_review_queue.py +192 -129
  19. package/scripts/platform_strike_events.py +54 -5
  20. package/scripts/post_reddit.py +10 -0
  21. package/scripts/preview_reddit_card.py +38 -0
  22. package/scripts/reap_stale_claude_sessions.py +7 -6
  23. package/scripts/reddit_ban_check.py +220 -0
  24. package/scripts/reddit_browser.py +24 -0
  25. package/scripts/release-mcpb.sh +38 -7
  26. package/scripts/setup_twitter_auth.py +20 -96
  27. package/scripts/store_patch.py +111 -0
  28. package/scripts/strike_alert.py +161 -12
  29. package/scripts/test_no_silent_fallbacks.py +11 -0
  30. package/scripts/test_stall_watch_batch_progression.py +99 -0
  31. package/scripts/top_performers.py +24 -0
  32. package/scripts/twitter_browser.py +22 -52
  33. package/skill/archive-old-logs.sh +24 -0
  34. package/skill/lib/reddit-backend.sh +1 -0
  35. package/skill/run-twitter-cycle.sh +146 -66
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic Reddit community-ban detection.
3
+
4
+ When a post is discovered removed, "was that one post moderated, or is our
5
+ account BANNED from the whole community?" is not answerable from the removal
6
+ object itself: a mod-removed comment scrubs to author=[deleted]/body=[removed]
7
+ with no reason, and a ban does NOT retroactively delete our older comments, so
8
+ "our comments are still visible there" is NOT evidence we are un-banned.
9
+
10
+ The authoritative signal is the logged-in view of `/r/<sub>/about.json`, whose
11
+ `user_is_banned` field is populated per the requesting account's session. We
12
+ already hold a logged-in Reddit browser (the reddit-agent / reddit-harness
13
+ Chrome); this module CDP-attaches to it read-only (one navigation, N same-origin
14
+ fetches, no clicks/posts) and reads that field. Mirrors the read-only carve-out
15
+ pattern used elsewhere (linkedin_browser sidebar pre-check).
16
+
17
+ Graceful degradation: if no Reddit session is reachable (customer install with
18
+ no browser, wrong account, session logged out), every checker returns None and
19
+ callers MUST treat None as "unknown, do nothing" — never as "not banned".
20
+
21
+ Usage:
22
+ python3 scripts/reddit_ban_check.py ClaudeAI theravada rust # ad-hoc
23
+ python3 scripts/reddit_ban_check.py --record --project podlog r/X # detect+persist
24
+
25
+ Public API:
26
+ banned_state(subs) -> {sub_lower: True|False|None}
27
+ is_banned(sub) -> True|False|None
28
+ record_confirmed_bans(subs, project=None)
29
+ -> {"banned": [...], "recorded": [...], "upgraded": [...], "unknown": [...]}
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import os
35
+ import re
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
+
41
+ # about.json JS: fetch each sub's logged-in metadata, return the ban flags.
42
+ _ABOUT_JS = r"""
43
+ async (subs) => {
44
+ const out = {};
45
+ for (const s of subs) {
46
+ try {
47
+ const r = await fetch(`https://old.reddit.com/r/${s}/about.json`,
48
+ {headers:{'Accept':'application/json'}, credentials:'include'});
49
+ if (!r.ok) { out[s] = {http: r.status}; continue; }
50
+ const d = (await r.json()).data || {};
51
+ out[s] = { banned: d.user_is_banned === true,
52
+ muted: d.user_is_muted === true,
53
+ has_flag: ('user_is_banned' in d) };
54
+ } catch (e) { out[s] = {err: String(e).slice(0,80)}; }
55
+ await new Promise(x => setTimeout(x, 250));
56
+ }
57
+ return out;
58
+ }
59
+ """
60
+
61
+
62
+ def _norm(sub: str) -> str:
63
+ s = (sub or "").strip()
64
+ m = re.search(r"/r/([^/]+)", s)
65
+ if m:
66
+ s = m.group(1)
67
+ return s.strip().strip("/").lower()
68
+
69
+
70
+ def _reddit_page(pw):
71
+ """Attach to the running logged-in Reddit Chrome and return (browser, page).
72
+
73
+ Prefers REDDIT_CDP_URL, else discovers the reddit-agent port via
74
+ reddit_browser.find_reddit_cdp_port. Opens a DEDICATED page so we never
75
+ fight the live harness tab. Returns (None, None) if unreachable."""
76
+ import urllib.request
77
+
78
+ ws = None
79
+ cdp_url = (os.environ.get("REDDIT_CDP_URL") or "").strip()
80
+ if cdp_url:
81
+ ws = cdp_url
82
+ else:
83
+ try:
84
+ from reddit_browser import find_reddit_cdp_port
85
+ port = find_reddit_cdp_port()
86
+ if not port:
87
+ return None, None
88
+ with urllib.request.urlopen(
89
+ f"http://127.0.0.1:{port}/json/version", timeout=3
90
+ ) as r:
91
+ ws = json.load(r)["webSocketDebuggerUrl"]
92
+ except Exception:
93
+ return None, None
94
+ try:
95
+ browser = pw.chromium.connect_over_cdp(ws)
96
+ ctx = browser.contexts[0] if browser.contexts else browser.new_context()
97
+ page = ctx.new_page()
98
+ page.set_default_timeout(20000)
99
+ return browser, page
100
+ except Exception:
101
+ return None, None
102
+
103
+
104
+ def banned_state(subs) -> dict:
105
+ """Return {sub_lower: True|False|None} for each sub. None = unknown
106
+ (no session / fetch failed): callers MUST NOT treat None as un-banned."""
107
+ norm = []
108
+ for s in subs:
109
+ n = _norm(s)
110
+ if n and n not in norm:
111
+ norm.append(n)
112
+ result = {n: None for n in norm}
113
+ if not norm:
114
+ return result
115
+ try:
116
+ from playwright.sync_api import sync_playwright
117
+ except Exception:
118
+ return result
119
+ with sync_playwright() as pw:
120
+ browser, page = _reddit_page(pw)
121
+ if page is None:
122
+ return result
123
+ try:
124
+ page.goto("https://old.reddit.com/", wait_until="domcontentloaded",
125
+ timeout=20000)
126
+ # confirm we actually have a logged-in session; a logged-out view
127
+ # reports user_is_banned=false for everything, a false negative.
128
+ me = page.evaluate(
129
+ "async () => { try { const r = await fetch("
130
+ "'https://old.reddit.com/api/me.json', {credentials:'include'}); "
131
+ "const d = await r.json(); return (d.data && d.data.name) || null; } "
132
+ "catch(e){ return null; } }"
133
+ )
134
+ if not me:
135
+ return result # not logged in -> all unknown
136
+ raw = page.evaluate(_ABOUT_JS, norm)
137
+ except Exception:
138
+ return result
139
+ finally:
140
+ try:
141
+ page.close()
142
+ except Exception:
143
+ pass
144
+ for n in norm:
145
+ v = (raw or {}).get(n) or {}
146
+ if v.get("has_flag"):
147
+ result[n] = bool(v.get("banned"))
148
+ else:
149
+ result[n] = None # http error / missing field -> unknown
150
+ return result
151
+
152
+
153
+ def is_banned(sub) -> "bool|None":
154
+ return banned_state([sub]).get(_norm(sub))
155
+
156
+
157
+ def record_confirmed_bans(subs, project=None) -> dict:
158
+ """Check each sub and persist newly-confirmed community bans into
159
+ config.json subreddit_bans.comment_blocked with reason
160
+ 'account_blocked_in_sub' (the reason platform_strike_events reads to emit
161
+ platform_banned learning events). Upgrades pre-existing weak entries
162
+ (reason=None) to the confirmed reason. Never records on None (unknown)."""
163
+ from config import config_path
164
+ from post_reddit import _make_ban_entry, _ban_entry_sub
165
+
166
+ state = banned_state(subs)
167
+ banned = [s for s, v in state.items() if v is True]
168
+ unknown = [s for s, v in state.items() if v is None]
169
+ recorded, upgraded = [], []
170
+ if not banned:
171
+ return {"banned": [], "recorded": [], "upgraded": [], "unknown": unknown}
172
+
173
+ path = config_path()
174
+ with open(path) as f:
175
+ cfg = json.load(f)
176
+ bans = cfg.setdefault("subreddit_bans", {})
177
+ blocked = bans.setdefault("comment_blocked", [])
178
+ by_sub = {(_ban_entry_sub(e) or "").lower(): e for e in blocked}
179
+
180
+ for sub in banned:
181
+ entry = by_sub.get(sub)
182
+ if entry is None:
183
+ blocked.append(_make_ban_entry(sub, "account_blocked_in_sub", project))
184
+ recorded.append(sub)
185
+ elif entry.get("reason") != "account_blocked_in_sub":
186
+ # upgrade a weak/manual entry so the digest ban event can fire
187
+ from datetime import datetime, timezone
188
+ entry["reason"] = "account_blocked_in_sub"
189
+ if not entry.get("added_at"):
190
+ entry["added_at"] = datetime.now(timezone.utc).strftime(
191
+ "%Y-%m-%dT%H:%M:%SZ")
192
+ entry.setdefault("noticed_by_project", project)
193
+ upgraded.append(sub)
194
+
195
+ if recorded or upgraded:
196
+ blocked.sort(key=lambda e: (_ban_entry_sub(e) or ""))
197
+ with open(path, "w") as f:
198
+ json.dump(cfg, f, indent=2)
199
+ f.write("\n")
200
+ return {"banned": banned, "recorded": recorded, "upgraded": upgraded,
201
+ "unknown": unknown}
202
+
203
+
204
+ def main() -> int:
205
+ import argparse
206
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
207
+ ap.add_argument("subs", nargs="+", help="subreddit names or /r/x URLs")
208
+ ap.add_argument("--record", action="store_true",
209
+ help="persist confirmed bans to config.json")
210
+ ap.add_argument("--project", default=None, help="audit breadcrumb")
211
+ args = ap.parse_args()
212
+ if args.record:
213
+ print(json.dumps(record_confirmed_bans(args.subs, args.project), indent=2))
214
+ else:
215
+ print(json.dumps(banned_state(args.subs), indent=2))
216
+ return 0
217
+
218
+
219
+ if __name__ == "__main__":
220
+ sys.exit(main())
@@ -341,6 +341,29 @@ def _touch_browser_heartbeat(*_args):
341
341
  pass
342
342
 
343
343
 
344
+ def _register_reddit_park_on_exit(cdp_base=""):
345
+ """Shared tab lifecycle (2026-07-17 unification): park reddit tabs on
346
+ reddit's own /robots.txt at process exit — the SAME single implementation
347
+ twitter_browser uses (scripts/browser_lifecycle.py). Static page = no SPA
348
+ to leak/crash while idle, URL still matches the reddit.com reuse
349
+ preference above so no new tabs (Target.createTarget is the proven
350
+ focus-steal trigger). S4L_NO_TAB_PARK=1 escape hatch inside the module.
351
+ Best-effort: parking must never fail an attach."""
352
+ try:
353
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
354
+ from browser_lifecycle import register_park_on_exit
355
+
356
+ register_park_on_exit(
357
+ (cdp_base or os.environ.get("REDDIT_CDP_URL", "").strip()
358
+ or "http://127.0.0.1:9557").rstrip("/"),
359
+ ("reddit.com",),
360
+ "https://www.reddit.com/robots.txt",
361
+ "reddit_browser",
362
+ )
363
+ except Exception:
364
+ pass
365
+
366
+
344
367
  def get_browser_and_page(playwright):
345
368
  """Instrumented entry point: attach via _get_browser_and_page_raw, then
346
369
  (L1) set default per-op/navigation timeouts so no page call can hang
@@ -395,6 +418,7 @@ def _get_browser_and_page_raw(playwright):
395
418
  try:
396
419
  cdp_browser = playwright.chromium.connect_over_cdp(cdp_url_env)
397
420
  _bh_activity_log("attach_harness", cdp_url_env)
421
+ _register_reddit_park_on_exit(cdp_url_env)
398
422
  # Prefer a context that already carries a live reddit_session; else
399
423
  # fall back to the first context (harness is single-profile, logged in).
400
424
  chosen = None
@@ -384,6 +384,28 @@ NODE_RUNTIME_PLATFORM="darwin-arm64"
384
384
  NODE_VENDOR_DIR="$MCP_DIR/vendor/node-$NODE_RUNTIME_PLATFORM"
385
385
  NODE_VENDOR_BIN="$NODE_VENDOR_DIR/bin/node"
386
386
  NODE_CACHE_DIR="$REPO_ROOT/.cache/node-runtime"
387
+ # Can THIS build host execute the vendored target binary? The .mcpb always
388
+ # vendors darwin-arm64 (that is where it runs), but the release itself is cut
389
+ # from CI on linux-x64. Executing a darwin-arm64 node on a linux-x64 runner is
390
+ # an `Exec format error` (exit 126) -- what broke every rc from 1.7.6-rc.11 on.
391
+ # Run the exec-based `--version` sanity checks ONLY when the host os/arch matches
392
+ # the vendored target; otherwise verify by presence + executable bit, which is
393
+ # all a cross-build can honestly assert.
394
+ case "$(uname -s)" in
395
+ Darwin) HOST_OS="darwin" ;;
396
+ Linux) HOST_OS="linux" ;;
397
+ *) HOST_OS="unknown" ;;
398
+ esac
399
+ case "$(uname -m)" in
400
+ arm64|aarch64) HOST_ARCH="arm64" ;;
401
+ x86_64|amd64) HOST_ARCH="x64" ;;
402
+ *) HOST_ARCH="unknown" ;;
403
+ esac
404
+ if [[ "$HOST_OS-$HOST_ARCH" == "$NODE_RUNTIME_PLATFORM" ]]; then
405
+ HOST_CAN_EXEC_VENDORED_NODE=1
406
+ else
407
+ HOST_CAN_EXEC_VENDORED_NODE=0
408
+ fi
387
409
  say "Vendoring Node $NODE_RUNTIME_VERSION ($NODE_RUNTIME_PLATFORM) for the .mcpb"
388
410
  if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
389
411
  NODE_TARBALL="node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM.tar.gz"
@@ -406,9 +428,13 @@ if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
406
428
  chmod +x "$NODE_VENDOR_BIN"
407
429
  fi
408
430
  [[ -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))"
431
+ if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
432
+ VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
433
+ [[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
434
+ echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
435
+ else
436
+ echo " vendored node: present at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1)); --version self-check skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
437
+ fi
412
438
 
413
439
  # ---- 4. Pack the .mcpb ------------------------------------------------------
414
440
  say "Packing $BUNDLE"
@@ -474,10 +500,15 @@ VENDOR_CHECK_DIR="$(mktemp -d)"
474
500
  unzip -q -o "$BUNDLE" "vendor/node-$NODE_RUNTIME_PLATFORM/bin/node" -d "$VENDOR_CHECK_DIR"
475
501
  VENDOR_CHECK_BIN="$VENDOR_CHECK_DIR/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node"
476
502
  [[ -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"
503
+ if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
504
+ VENDOR_CHECK_VER="$("$VENDOR_CHECK_BIN" --version)"
505
+ [[ "$VENDOR_CHECK_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node in bundle reports $VENDOR_CHECK_VER, expected $NODE_RUNTIME_VERSION"
506
+ rm -rf "$VENDOR_CHECK_DIR"
507
+ echo " vendored node runs from bundle: $VENDOR_CHECK_VER ok"
508
+ else
509
+ rm -rf "$VENDOR_CHECK_DIR"
510
+ echo " vendored node survives bundle round-trip with exec bit intact; --version run skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
511
+ fi
481
512
 
482
513
  if [[ "$DO_RELEASE" == "0" ]]; then
483
514
  say "Done (--no-release). Bundle ready at: $BUNDLE"
@@ -61,14 +61,8 @@ except ImportError:
61
61
  "websocket-client not installed (needed for CDP). pip install websocket-client"
62
62
  )
63
63
 
64
- # Live-handle resolver (best-effort). Lets connect_x record the real logged-in
65
- # @handle alongside the locally-mirrored cookies. Guarded so a missing dep never
66
- # breaks setup.
64
+ # Make scripts-dir siblings (twitter_cookie_mirror, etc.) importable during setup.
67
65
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
68
- try:
69
- from twitter_account import resolve_handle # noqa: E402
70
- except Exception:
71
- resolve_handle = None
72
66
 
73
67
  # Local 0600 cookie mirror — the keychain-independent durability layer (Gap B).
74
68
  # Always importable (stdlib only); guarded so a path quirk never breaks setup.
@@ -345,12 +339,12 @@ _HANDLE_PLACEHOLDERS = {"", "your-twitter-handle", "@your-twitter-handle"}
345
339
  def _resolve_live_handle(send) -> "str | None":
346
340
  """Read the logged-in @handle from the LIVE x.com session.
347
341
 
348
- resolve_handle() only reads config.json (which on a fresh install is the
349
- template placeholder), so it can't discover the real account. This reads the
350
- actual logged-in handle from the SAME authenticated session connect_x just
351
- validated, so connect_x / cmd_resolve_handle can persist ground truth instead
352
- of falling back to a hardcoded handle (which would silently mis-attribute
353
- every post). Two methods, most reliable first:
342
+ On a fresh install config.json carries no handle yet, so this reads the actual
343
+ logged-in handle from the SAME authenticated session connect_x just validated
344
+ and stamps it on the durable cookie mirror (see _persist_session). That mirror
345
+ is what account_resolver.resolve('twitter') falls back to, so attribution is
346
+ ground truth instead of a hardcoded handle that would silently mis-attribute
347
+ every post. Two methods, most reliable first:
354
348
 
355
349
  1. X's own account/settings.json (canonical `screen_name`). The web client
356
350
  calls this on every load; it is stable across DOM redesigns, unlike the
@@ -405,41 +399,6 @@ def _resolve_live_handle(send) -> "str | None":
405
399
  return None
406
400
 
407
401
 
408
- def _write_handle_to_config(handle: "str | None") -> bool:
409
- """Persist the discovered handle to config.json accounts.twitter.handle, but
410
- ONLY when the configured value is empty or the template placeholder, so we
411
- never clobber a handle the user set on purpose. Returns True if written.
412
-
413
- This is what makes account_resolver.resolve('twitter') return the REAL
414
- account, so our_account (attribution, own-reply skip, account-keyed ops) is
415
- correct instead of the poisonous 'your-twitter-handle' default. (2026-06-02)
416
- """
417
- if not handle:
418
- return False
419
- try:
420
- with open(_CONFIG_JSON, encoding="utf-8") as f:
421
- cfg = json.load(f)
422
- except Exception:
423
- return False
424
- accounts = cfg.setdefault("accounts", {})
425
- if not isinstance(accounts, dict):
426
- return False
427
- tw = accounts.setdefault("twitter", {})
428
- if not isinstance(tw, dict):
429
- return False
430
- cur = (tw.get("handle") or "").strip()
431
- if cur.lower() not in _HANDLE_PLACEHOLDERS:
432
- return False # a real handle is already set; do not overwrite
433
- tw["handle"] = "@" + handle.lstrip("@")
434
- try:
435
- with open(_CONFIG_JSON, "w", encoding="utf-8") as f:
436
- json.dump(cfg, f, indent=2)
437
- f.write("\n")
438
- return True
439
- except Exception:
440
- return False
441
-
442
-
443
402
  def _persist_session() -> None:
444
403
  """Persist the validated live X session for auto-restore after ANY logout
445
404
  (hard kill, crash, or a keychain re-lock wiping Chrome's Cookies DB).
@@ -479,17 +438,15 @@ def _persist_session() -> None:
479
438
  if not cookies:
480
439
  return
481
440
 
482
- # Fall back to the configured handle if live resolution came up empty.
483
- if handle and _write_handle_to_config(handle):
484
- print(f"setup_twitter_auth: recorded live X handle @{handle} in config.json "
485
- "(accounts.twitter.handle); attribution + own-reply dedup now scoped "
486
- "to the real account", file=sys.stderr)
487
- if not handle and resolve_handle is not None:
488
- try:
489
- handle = resolve_handle()
490
- except Exception:
491
- handle = None
492
-
441
+ # The handle's single durable home is the cookie mirror (written just below).
442
+ # We deliberately do NOT also copy it into config.json: account_resolver
443
+ # resolves env -> config override -> this mirror, so a second copy in config
444
+ # would be duplicated state that can drift — and its going missing there was the
445
+ # "posts land 0/N, no_account_configured" bug. config.accounts.twitter.handle
446
+ # stays a user-only override. When live resolution came up empty, save_cookies
447
+ # carries the previously-stamped handle forward, so a known account is never
448
+ # downgraded to null.
449
+ #
493
450
  # Local mirror — keychain-independent durability. This is the only cookie
494
451
  # store; the VM-era server store (/api/v1/twitter/session-cookies) was
495
452
  # removed 2026-06-17 when we stopped running AppMaker VMs.
@@ -499,6 +456,10 @@ def _persist_session() -> None:
499
456
  print(f"setup_twitter_auth: mirrored {n} x.com cookies to "
500
457
  f"{twitter_cookie_mirror.MIRROR_PATH} (survives keychain re-lock "
501
458
  "/ Cookies-DB wipe on relaunch)", file=sys.stderr)
459
+ if handle:
460
+ print(f"setup_twitter_auth: durable X handle @{handle} stamped on the "
461
+ "cookie mirror; account_resolver resolves it for attribution + "
462
+ "own-reply dedup", file=sys.stderr)
502
463
  except Exception as e:
503
464
  print(f"setup_twitter_auth: local mirror save skipped ({e})", file=sys.stderr)
504
465
 
@@ -1303,37 +1264,6 @@ def cmd_connect(args) -> dict:
1303
1264
  }
1304
1265
 
1305
1266
 
1306
- def cmd_resolve_handle(args) -> dict:
1307
- """Read the live logged-in @handle from the managed Chrome and persist it to
1308
- config.json accounts.twitter.handle.
1309
-
1310
- The MCP post preflight calls this to self-heal a missing handle — the onboarding
1311
- gap where connect_x's best-effort live-DOM read silently no-op'd, leaving the
1312
- install logged in but with accounts:null, so twitter_browser.py refused EVERY
1313
- reply with no_account_configured. Reading the handle from the SAME session the
1314
- poster posts through is ground truth, not a guess, so it's safe where a hardcoded
1315
- fallback would not be. Best-effort: returns state=browser_not_running / no_handle
1316
- on failure and never raises."""
1317
- try:
1318
- ws, send = _attach()
1319
- except Exception as e:
1320
- return {"ok": False, "state": "browser_not_running", "error": str(e)}
1321
- handle = None
1322
- try:
1323
- handle = _resolve_live_handle(send)
1324
- except Exception:
1325
- handle = None
1326
- finally:
1327
- try:
1328
- ws.close()
1329
- except Exception:
1330
- pass
1331
- if not handle:
1332
- return {"ok": False, "state": "no_handle"}
1333
- persisted = _write_handle_to_config(handle)
1334
- return {"ok": True, "state": "resolved", "handle": handle, "persisted": persisted}
1335
-
1336
-
1337
1267
  def main() -> int:
1338
1268
  ap = argparse.ArgumentParser(description="Twitter/X session bootstrap for MCP setup.")
1339
1269
  sub = ap.add_subparsers(dest="cmd", required=True)
@@ -1341,10 +1271,6 @@ def main() -> int:
1341
1271
  sub.add_parser("detect-sources",
1342
1272
  help="List browsers/profiles to import the X session from "
1343
1273
  "(JSON, for the panel dropdown). No keychain prompt.")
1344
- sub.add_parser("resolve-handle",
1345
- help="Read the live logged-in @handle from the managed Chrome and "
1346
- "persist it to config.json accounts.twitter.handle. Idempotent "
1347
- "self-heal for the post preflight; never overwrites a real handle.")
1348
1274
  c = sub.add_parser("connect", help="Ensure browser + import/validate the X session.")
1349
1275
  c.add_argument("--source", default=None,
1350
1276
  help="Browser profile to import from (e.g. chrome:Default, arc:Default), "
@@ -1375,8 +1301,6 @@ def main() -> int:
1375
1301
  out = {"ok": False, "state": "error", "error": _WEBSOCKET_IMPORT_ERROR}
1376
1302
  elif args.cmd == "status":
1377
1303
  out = cmd_status(args)
1378
- elif args.cmd == "resolve-handle":
1379
- out = cmd_resolve_handle(args)
1380
1304
  else:
1381
1305
  out = cmd_connect(args)
1382
1306
  print(json.dumps(out, indent=2))
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Locked field-patcher for the review-queue store.
3
+
4
+ The review store (review-queue.json) has three writers: the menubar
5
+ (s4l_state._store_update), merge_review_queue.py, and the MCP server. The
6
+ first two hold an fcntl.flock on `<store>.lock` around their read-modify-write;
7
+ the MCP server is Node, which has no native flock, so it routes its store
8
+ writes through THIS script instead of writing the file itself. That closes the
9
+ last unlocked writer (the last-writer-wins race that erased posted stamps on
10
+ 2026-07-17 — six live replies rendered as duplicate_thread_pre_post kills).
11
+
12
+ Protocol-compatible with s4l_state._store_update: same lock file, LOCK_EX for
13
+ the whole read-mutate-replace span, atomic os.replace. fcntl (not an O_EXCL
14
+ lockfile) on purpose: the kernel drops the lock when the holder dies, so there
15
+ is no stale-lock stealing logic to get wrong.
16
+
17
+ Usage: store_patch.py <patches.json> (or '-' to read the JSON from stdin)
18
+
19
+ Input shape:
20
+ {"patches": [
21
+ {"candidate_id": 123, # match ALL rows with this id (ids are NOT
22
+ # unique: sandbox reruns/variant drafts
23
+ # append sibling rows; a stamp that hits
24
+ # only one sibling leaves a drainable twin)
25
+ "n": 4, # 1-based index fallback when candidate_id
26
+ # is absent/None (legacy rows)
27
+ "set": {"approved": true}, # fields to assign
28
+ "unset": ["discard_reason"]} # fields to delete
29
+ ]}
30
+
31
+ Merge rules (mirror mergeApprovedStampsIntoStore):
32
+ - posted is sticky: a patch may set posted=true, but `set.terminal=true` is
33
+ ignored on a row whose posted is already true, and posted is never unset.
34
+ Prints {"ok": true, "patched": N} on stdout. Exit 0 even when N=0 (a patch
35
+ matching nothing is not an error; the row may have been absorbed elsewhere).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import fcntl
41
+ import json
42
+ import os
43
+ import sys
44
+
45
+
46
+ def store_path() -> str:
47
+ state_dir = os.environ.get("S4L_STATE_DIR") or os.path.join(
48
+ os.path.expanduser("~"), ".social-autoposter-mcp"
49
+ )
50
+ return os.path.join(state_dir, "review-queue.json")
51
+
52
+
53
+ def _match_rows(cands: list, patch: dict) -> list:
54
+ cid = patch.get("candidate_id")
55
+ if cid is not None:
56
+ rows = [c for c in cands if c.get("candidate_id") == cid]
57
+ if rows:
58
+ return rows
59
+ n = patch.get("n")
60
+ if isinstance(n, int) and 1 <= n <= len(cands):
61
+ return [cands[n - 1]]
62
+ return []
63
+
64
+
65
+ def apply_patches(data: dict, patches: list) -> int:
66
+ cands = data.get("candidates") or []
67
+ patched = 0
68
+ for p in patches:
69
+ for c in _match_rows(cands, p):
70
+ sets = dict(p.get("set") or {})
71
+ if c.get("posted") is True:
72
+ sets.pop("terminal", None)
73
+ sets.pop("terminal_reason", None)
74
+ for k, v in sets.items():
75
+ if k == "posted" and v is not True and c.get("posted") is True:
76
+ continue # posted is sticky
77
+ c[k] = v
78
+ for k in p.get("unset") or []:
79
+ if k == "posted":
80
+ continue
81
+ c.pop(k, None)
82
+ patched += 1
83
+ return patched
84
+
85
+
86
+ def main() -> int:
87
+ src = sys.argv[1] if len(sys.argv) > 1 else "-"
88
+ raw = sys.stdin.read() if src == "-" else open(src).read()
89
+ patches = (json.loads(raw) or {}).get("patches") or []
90
+ sp = store_path()
91
+ with open(sp + ".lock", "w") as lk:
92
+ fcntl.flock(lk, fcntl.LOCK_EX)
93
+ try:
94
+ try:
95
+ with open(sp) as f:
96
+ data = json.load(f)
97
+ except Exception:
98
+ data = {"candidates": []}
99
+ patched = apply_patches(data, patches)
100
+ tmp = f"{sp}.tmp.{os.getpid()}"
101
+ with open(tmp, "w") as f:
102
+ json.dump(data, f, indent=2)
103
+ os.replace(tmp, sp)
104
+ finally:
105
+ fcntl.flock(lk, fcntl.LOCK_UN)
106
+ print(json.dumps({"ok": True, "patched": patched}))
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())