@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
@@ -455,10 +455,93 @@ def _owner_strike_count(owner, days=90):
455
455
  return (live_count, raw_count)
456
456
 
457
457
 
458
- def _format_subject(post, repo_state=None):
458
+ def _subreddit_from_url(url):
459
+ if not url:
460
+ return None
461
+ m = re.search(r"reddit\.com/r/([^/]+)", url)
462
+ return m.group(1).lower() if m else None
463
+
464
+
465
+ def _subreddit_strike_count(sub, days=90):
466
+ """How many of our reddit posts in r/<sub> were moderated (removed/deleted)
467
+ in the last `days` days. The reddit analogue of _owner_strike_count: turns a
468
+ single strike email into 'this is the Nth removal in this community', which
469
+ is the clustering signal that separates a chronic bad-fit venue from a
470
+ one-off removal. Returns 0 on any lookup failure (never blocks the email)."""
471
+ if not sub:
472
+ return 0
473
+ try:
474
+ resp = api_get("/api/v1/posts/thread-urls", query={
475
+ "platform": "reddit", "moderated_within_days": int(days),
476
+ })
477
+ except Exception:
478
+ return 0
479
+ urls = (resp.get("data") or {}).get("thread_urls") or []
480
+ return sum(1 for u in urls if _subreddit_from_url(u) == sub)
481
+
482
+
483
+ def _recorded_ban(sub, config, account):
484
+ """True if r/<sub> is already on the config.json comment_blocked denylist
485
+ with reason 'account_blocked_in_sub' for this account (the recorded output
486
+ of the deterministic ban check). Account-scoped, matching
487
+ reddit_tools._load_comment_blocked_subs semantics."""
488
+ if not sub:
489
+ return False
490
+ acct = (account or "").lower()
491
+ for entry in ((config.get("subreddit_bans") or {}).get("comment_blocked") or []):
492
+ if not isinstance(entry, dict):
493
+ continue
494
+ if (entry.get("sub") or "").strip().lower() != sub:
495
+ continue
496
+ if entry.get("reason") != "account_blocked_in_sub":
497
+ continue
498
+ ent_acct = (entry.get("account") or "").lower()
499
+ if ent_acct and acct and ent_acct != acct:
500
+ continue
501
+ return True
502
+ return False
503
+
504
+
505
+ def _reddit_ban_verdict(subs, config, account):
506
+ """Return {sub: 'banned'|'not_banned'|'unknown'} for reddit strike subs.
507
+
508
+ Prefers a single live deterministic check (reddit_ban_check.banned_state,
509
+ one browser attach for the whole sweep, read-only) and falls back to the
510
+ recorded config denylist when no session is reachable or the check is
511
+ disabled via S4L_STRIKE_BAN_CHECK=0. A recorded ban is authoritative even
512
+ when the live check says unknown (session down) so we never downgrade a
513
+ known ban to 'single-post removal'."""
514
+ verdict = {}
515
+ live = {}
516
+ if subs and os.environ.get("S4L_STRIKE_BAN_CHECK", "1") not in ("0", "false", "no"):
517
+ try:
518
+ import reddit_ban_check
519
+ live = reddit_ban_check.banned_state(list(subs)) or {}
520
+ except Exception as e:
521
+ print(f"[strike_alert] reddit_ban_check unavailable (non-fatal): {e}",
522
+ file=sys.stderr)
523
+ for sub in subs:
524
+ lv = live.get(sub)
525
+ if lv is True:
526
+ verdict[sub] = "banned"
527
+ elif _recorded_ban(sub, config, account):
528
+ verdict[sub] = "banned"
529
+ elif lv is False:
530
+ verdict[sub] = "not_banned"
531
+ else:
532
+ verdict[sub] = "unknown"
533
+ return verdict
534
+
535
+
536
+ def _format_subject(post, repo_state=None, sub_ban=None):
459
537
  platform = post["platform"] or "?"
460
538
  status = post["status"] or "?"
461
539
  tag = "STRIKE"
540
+ if platform == "reddit" and sub_ban == "banned":
541
+ # Account is banned from the whole community, not just this post
542
+ # removed. Loud subject tag so a ban is visually distinct from the
543
+ # far more common single-post removal.
544
+ tag = "STRIKE-BANNED"
462
545
  if platform == "github" and repo_state == "repo_gone":
463
546
  # Owner nuked the whole repo. Not a moderation strike against us.
464
547
  status = "repo-deleted"
@@ -488,7 +571,7 @@ def _ts(v):
488
571
  return v.isoformat() if hasattr(v, "isoformat") else str(v)
489
572
 
490
573
 
491
- def _format_body(post, repo_state=None):
574
+ def _format_body(post, repo_state=None, sub_ban=None, sub_count=None):
492
575
  platform = post["platform"] or "?"
493
576
  status = post["status"] or "?"
494
577
  project = post["project_name"] or "(no project)"
@@ -505,6 +588,35 @@ def _format_body(post, repo_state=None):
505
588
 
506
589
  owner_block = ""
507
590
  repo_block = ""
591
+ reddit_block = ""
592
+ if platform == "reddit":
593
+ sub = _subreddit_from_url(thread_url) or _subreddit_from_url(our_url)
594
+ if sub:
595
+ n = sub_count if sub_count is not None else _subreddit_strike_count(sub)
596
+ count_line = (
597
+ f"Subreddit: r/{sub} ({n} of our post(s) moderated here in the "
598
+ f"last 90 days)\n"
599
+ )
600
+ if sub_ban == "banned":
601
+ ban_line = (
602
+ f"Ban state: ACCOUNT BANNED from r/{sub} (deterministic "
603
+ f"user_is_banned check). This is not a single-post removal: "
604
+ f"every future post here will be removed. The sub is on the "
605
+ f"comment_blocked denylist so the drafter will stop targeting "
606
+ f"it, and a platform_banned learning signal feeds the digest.\n"
607
+ )
608
+ elif sub_ban == "not_banned":
609
+ ban_line = (
610
+ f"Ban state: not banned (user_is_banned=false). This is a "
611
+ f"single-post removal; the account can still post in r/{sub}.\n"
612
+ )
613
+ else:
614
+ ban_line = (
615
+ f"Ban state: unknown (no live Reddit session for the check and "
616
+ f"not on the recorded denylist). Treated as a single-post "
617
+ f"removal until confirmed.\n"
618
+ )
619
+ reddit_block = count_line + ban_line
508
620
  if platform == "github" and thread_url:
509
621
  if repo_state == "repo_gone":
510
622
  repo_block = (
@@ -559,6 +671,7 @@ def _format_body(post, repo_state=None):
559
671
  f"Detected: {checked_at}\n"
560
672
  f"{repo_block}"
561
673
  f"{owner_block}"
674
+ f"{reddit_block}"
562
675
  f"\n"
563
676
  f"Thread: {thread_url}\n"
564
677
  f"Title: {title}\n"
@@ -567,20 +680,38 @@ def _format_body(post, repo_state=None):
567
680
  f"--- Our content ---\n"
568
681
  f"{content_preview}\n"
569
682
  f"\n"
570
- f"--- Next steps ---\n"
571
- f"1. Inspect the thread to see if the comment was deleted, hidden,\n"
572
- f" or if the whole account was blocked.\n"
573
- f"2. If the owner should be hard-blocked, add it to\n"
574
- f" config.json -> exclusions.github_repos. Owner-level entries\n"
575
- f" match all repos under that owner.\n"
576
- f"3. The auto-blocklist (github_tools._dynamic_owner_blocklist)\n"
577
- f" already covers any owner with >=2 strikes in 90 days.\n"
683
+ f"{_next_steps(platform)}"
578
684
  f"\n"
579
685
  f"To re-fire this alert: python3 scripts/strike_alert.py --post-id {post['id']}\n"
580
686
  )
581
687
  return _scrub_dashes(body)
582
688
 
583
689
 
690
+ def _next_steps(platform):
691
+ if platform == "reddit":
692
+ return (
693
+ "--- Next steps ---\n"
694
+ "1. Ban state above is deterministic (logged-in user_is_banned).\n"
695
+ " BANNED subs are auto-added to config.json subreddit_bans\n"
696
+ " .comment_blocked and feed a platform_banned learning event;\n"
697
+ " no manual action needed to stop targeting them.\n"
698
+ "2. A single-post removal is one community's judgment on the content;\n"
699
+ " the platform_removed event already teaches the digest that\n"
700
+ " pattern. Watch the 'moderated here in last 90 days' count: a\n"
701
+ " rising count on a not-yet-banned sub is an early bad-fit signal.\n"
702
+ )
703
+ return (
704
+ "--- Next steps ---\n"
705
+ "1. Inspect the thread to see if the comment was deleted, hidden,\n"
706
+ " or if the whole account was blocked.\n"
707
+ "2. If the owner should be hard-blocked, add it to\n"
708
+ " config.json -> exclusions.github_repos. Owner-level entries\n"
709
+ " match all repos under that owner.\n"
710
+ "3. The auto-blocklist (github_tools._dynamic_owner_blocklist)\n"
711
+ " already covers any owner with >=2 strikes in 90 days.\n"
712
+ )
713
+
714
+
584
715
  def _send_email(subject, body):
585
716
  msg = MIMEText(body, "plain", "utf-8")
586
717
  msg["to"] = NOTIFICATION_EMAIL
@@ -651,6 +782,19 @@ def main():
651
782
  print("[strike_alert] no pending strikes")
652
783
  return
653
784
 
785
+ # Deterministic reddit ban verdict, computed ONCE for the whole sweep (a
786
+ # single read-only browser attach for all distinct subs, not one per email).
787
+ # Falls back to the recorded denylist when no session is reachable.
788
+ reddit_subs = {
789
+ s for s in (
790
+ _subreddit_from_url(r.get("thread_url")) or _subreddit_from_url(r.get("our_url"))
791
+ for r in rows if r.get("platform") == "reddit"
792
+ ) if s
793
+ }
794
+ reddit_ban = _reddit_ban_verdict(reddit_subs, _cfg, _reddit_username)
795
+ if reddit_subs:
796
+ print(f"[strike_alert] reddit_ban_verdict {reddit_ban}", flush=True)
797
+
654
798
  sent = 0
655
799
  skipped = 0
656
800
  filtered = 0
@@ -752,8 +896,13 @@ def main():
752
896
  )
753
897
  continue
754
898
 
755
- subject = _format_subject(r, repo_state=repo_state)
756
- body = _format_body(r, repo_state=repo_state)
899
+ _sub = None
900
+ _sub_ban = None
901
+ if r["platform"] == "reddit":
902
+ _sub = _subreddit_from_url(r.get("thread_url")) or _subreddit_from_url(r.get("our_url"))
903
+ _sub_ban = reddit_ban.get(_sub)
904
+ subject = _format_subject(r, repo_state=repo_state, sub_ban=_sub_ban)
905
+ body = _format_body(r, repo_state=repo_state, sub_ban=_sub_ban)
757
906
  if args.dry_run:
758
907
  print(f"[strike_alert] DRY RUN id={r['id']}")
759
908
  print(f" subject: {subject}")
@@ -228,6 +228,7 @@ def check_account_resolver_hard_fails():
228
228
  import account_resolver # noqa: E402
229
229
 
230
230
  saved_load_config = account_resolver._load_config
231
+ saved_durable_handle = account_resolver._durable_handle
231
232
  saved_env = dict(os.environ)
232
233
  try:
233
234
  # Strip every account-related env var + simulate an empty config.json
@@ -237,6 +238,15 @@ def check_account_resolver_hard_fails():
237
238
  if k.startswith("AUTOPOSTER_"):
238
239
  del os.environ[k]
239
240
  account_resolver._load_config = lambda: {}
241
+ # Also stub the durable identity store (2026-07-17, added alongside
242
+ # account_resolver's _durable_handle): resolve() falls through to it
243
+ # as a THIRD source after env/config, so on any machine with a real
244
+ # twitter_cookie_mirror (this box included) "no config" alone no
245
+ # longer means "nothing resolvable" -- the real connected account's
246
+ # own handle would still come back and fail this "fresh install"
247
+ # simulation for the wrong reason. Stub it the same way _load_config
248
+ # is stubbed so this test isolates all three sources, not just one.
249
+ account_resolver._durable_handle = lambda key: None
240
250
 
241
251
  for platform in ("twitter", "reddit", "linkedin", "github", "moltbook"):
242
252
  result = account_resolver.resolve(platform)
@@ -258,6 +268,7 @@ def check_account_resolver_hard_fails():
258
268
  result == "some_handle", f"got {result!r}")
259
269
  finally:
260
270
  account_resolver._load_config = saved_load_config
271
+ account_resolver._durable_handle = saved_durable_handle
261
272
  os.environ.clear()
262
273
  os.environ.update(saved_env)
263
274
 
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """Regression test: the autopilot stall watchdog's batch-progression check must
3
+ read the last N twitter_batches from the API envelope's data.batches, NOT the
4
+ (nonexistent) top-level resp["batches"].
5
+
6
+ Background (Nhat ae18af69, 07-15): http_api.api_get returns the RAW envelope
7
+ {"ok":..,"data":{"batches":[..],"owner_host":..}} and does not unwrap "data".
8
+ _recent_batches_not_progressing() read resp.get("batches") -> always None ->
9
+ batches=[] -> len<min -> returned False on EVERY call. batches_stuck was thus
10
+ hardwired False and the "drafting is not actually happening" stall cause could
11
+ never be selected, so the watchdog mislabeled a real 30h phase2b-prep pileup as
12
+ "orphaned routines / account change?". This locks in the correct key + the
13
+ True/False logic so it cannot silently regress again.
14
+
15
+ Run: python3 scripts/test_stall_watch_batch_progression.py
16
+ Exit 0 = all pass; non-zero with FAIL lines otherwise.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import importlib
21
+ import os
22
+ import sys
23
+ import types
24
+
25
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
26
+ SCRIPTS = os.path.join(REPO, "scripts")
27
+ # Source-tree test: pin S4L_REPO_DIR to THIS checkout so the function resolves
28
+ # http_api from here, not an inherited installed-package path
29
+ # (see gotcha_s4l_repo_dir_env_inherited_installed_package).
30
+ os.environ["S4L_REPO_DIR"] = REPO
31
+ if SCRIPTS not in sys.path:
32
+ sys.path.insert(0, SCRIPTS)
33
+
34
+ asw = importlib.import_module("autopilot_stall_watch")
35
+ MIN = asw.BATCH_PROGRESSION_MIN_BATCHES
36
+
37
+ failures: list[str] = []
38
+
39
+
40
+ def check(name: str, got, want) -> None:
41
+ ok = got == want
42
+ print(f"{'PASS' if ok else 'FAIL'} {name}: got {got!r}, want {want!r}")
43
+ if not ok:
44
+ failures.append(name)
45
+
46
+
47
+ def _fake_api(canned):
48
+ """Swap http_api for one whose api_get returns `canned` (or raises it, if a
49
+ BaseException). The function under test does `import http_api` lazily, so a
50
+ module planted in sys.modules wins."""
51
+ fake = types.ModuleType("http_api")
52
+
53
+ def api_get(path, query=None, ok_on_404=False):
54
+ if isinstance(canned, BaseException):
55
+ raise canned
56
+ return canned
57
+
58
+ fake.api_get = api_get
59
+ sys.modules["http_api"] = fake
60
+
61
+
62
+ def envelope(phases):
63
+ """The real /api/v1/twitter-batches shape: batches nested under data."""
64
+ return {"ok": True, "data": {"owner_host": "test-host",
65
+ "batches": [{"current_phase": p} for p in phases]}}
66
+
67
+
68
+ # 1. Last N batches ALL stuck at prep -> stall detected (the case that was missed).
69
+ _fake_api(envelope(["phase2b-prep"] * MIN))
70
+ check("all phase2b-prep -> batches_not_progressing True",
71
+ asw._recent_batches_not_progressing(), True)
72
+
73
+ # 2. One of the last N reached real drafting -> not a stall.
74
+ _fake_api(envelope(["phase2b-prep"] * (MIN - 1) + ["phase2b-gen"]))
75
+ check("one phase2b-gen among last N -> False",
76
+ asw._recent_batches_not_progressing(), False)
77
+
78
+ # 3. Fewer than N batches -> inconclusive, never page on thin history.
79
+ _fake_api(envelope(["phase2b-prep"] * (MIN - 1)))
80
+ check("fewer than MIN batches -> False",
81
+ asw._recent_batches_not_progressing(), False)
82
+
83
+ # 4. THE REGRESSION GUARD: batches present ONLY at the wrong top-level key must be
84
+ # ignored. Correct code reads data.batches -> sees [] -> False. If someone
85
+ # "simplifies" back to resp.get("batches"), this flips to True and fails.
86
+ _fake_api({"ok": True, "batches": [{"current_phase": "phase2b-prep"}] * MIN})
87
+ check("top-level resp['batches'] ignored (must read data.batches)",
88
+ asw._recent_batches_not_progressing(), False)
89
+
90
+ # 5. A terminal API failure (http_api raises SystemExit, a BaseException) must be
91
+ # swallowed to False, never crash this best-effort watchdog.
92
+ _fake_api(SystemExit("terminal 5xx"))
93
+ check("api SystemExit swallowed -> False",
94
+ asw._recent_batches_not_progressing(), False)
95
+
96
+ if failures:
97
+ print(f"\n{len(failures)} FAILED: {failures}")
98
+ sys.exit(1)
99
+ print("\nALL PASS")
@@ -22,6 +22,12 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
22
 
23
23
  MIN_CONTENT_LEN = 30 # skip posts with empty/placeholder content
24
24
 
25
+ # Duplicated literal (not imported): must match draft_prompt_core.py's
26
+ # STYLE_SENTINEL_TREATMENT and engagement_styles.py's STYLE_SENTINEL, both
27
+ # of which independently define "voice_first" the same way ARM_TREATMENT's
28
+ # "treatment_v4" is already duplicated across those two modules.
29
+ STYLE_SENTINEL_TREATMENT = "voice_first"
30
+
25
31
  # CTE that adds a bot-filtered `clicks` column to every row of `posts`.
26
32
  # Sources from `post_link_clicks` (per-hit log, populated by the redirector
27
33
  # after 2026-05-07) with `is_bot=false`. This is the same attribution path
@@ -522,6 +528,24 @@ def _fetch_report_via_api(*, platform, project, top, bottom):
522
528
  raw_group = data.get("top_by_group") or {}
523
529
  top_by_style = data.get("top_by_style") or []
524
530
 
531
+ # treatment_v4 Twitter posts carry no real engagement style; they're
532
+ # stamped with the sentinel "voice_first" (must match
533
+ # draft_prompt_core.STYLE_SENTINEL_TREATMENT / engagement_styles.py's
534
+ # duplicated literal) instead of NULL, to avoid SQL NULL-handling
535
+ # landmines. This SQL-side GROUP BY has no whitelist, so without this
536
+ # filter "voice_first" would show up as its own row here, at roughly
537
+ # half of all Twitter post volume, and its single highest-scoring post
538
+ # would get presented as a "style to imitate" in the Best Example
539
+ # section below -- exactly the fake-style pollution this sentinel was
540
+ # designed to avoid downstream, just relocated to this report instead.
541
+ # Filtered here (not at the API) so every caller of this module is
542
+ # covered without touching the sibling website repo's SQL.
543
+ style_perf = [r for r in style_perf if r and r[0] != STYLE_SENTINEL_TREATMENT]
544
+ top_by_style = [
545
+ r for r in top_by_style
546
+ if not (len(r) > 12 and r[12] == STYLE_SENTINEL_TREATMENT)
547
+ ]
548
+
525
549
  top_filtered = _apply_top_filter(raw_top, top) if raw_top else []
526
550
  fallback_filtered = None
527
551
  if project and not top_filtered and raw_fallback:
@@ -419,68 +419,38 @@ def _arm_self_liveness_watchdog():
419
419
  # _get_browser_and_page_raw, and find_twitter_cdp_port discovery. The
420
420
  # harness-profile discovery fallback stays as a belt for a closed tab.
421
421
  _PARK_URL = "https://x.com/robots.txt"
422
- _PARK_REGISTERED = False
423
422
 
424
423
 
425
424
  def _park_twitter_tabs():
426
- try:
427
- import urllib.request
425
+ # Thin alias kept for callers/tests; ONE implementation lives in
426
+ # scripts/browser_lifecycle.py (shared with reddit_browser.py — user
427
+ # directive 2026-07-17: share the machinery, don't fork failure points).
428
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
429
+ from browser_lifecycle import park_tabs
428
430
 
429
- base = (
430
- os.environ.get("TWITTER_CDP_URL", "").strip()
431
- or "http://127.0.0.1:9555"
432
- ).rstrip("/")
433
- resp = urllib.request.urlopen(base + "/json/list", timeout=3)
434
- targets = json.loads(resp.read())
435
- for t in targets:
436
- url = t.get("url", "")
437
- if t.get("type") != "page":
438
- continue
439
- if "x.com" not in url and "twitter.com" not in url:
440
- continue
441
- if url.split("?", 1)[0].rstrip("/").endswith("/robots.txt"):
442
- continue # already parked
443
- ws_url = t.get("webSocketDebuggerUrl")
444
- if not ws_url:
445
- continue
446
- try:
447
- import websocket
448
-
449
- # suppress_origin: Chrome 111+ rejects CDP websocket clients
450
- # that send an Origin header not in --remote-allow-origins
451
- # (same pattern as restore_twitter_session.py).
452
- ws = websocket.create_connection(
453
- ws_url, timeout=3, suppress_origin=True
454
- )
455
- try:
456
- ws.send(json.dumps({
457
- "id": 1,
458
- "method": "Page.navigate",
459
- "params": {"url": _PARK_URL},
460
- }))
461
- ws.recv()
462
- finally:
463
- ws.close()
464
- print(
465
- f"[twitter_browser] parked tab on {_PARK_URL} (was {url[:80]})",
466
- file=sys.stderr,
467
- )
468
- except Exception:
469
- continue
470
- except Exception:
471
- pass
431
+ base = (
432
+ os.environ.get("TWITTER_CDP_URL", "").strip()
433
+ or "http://127.0.0.1:9555"
434
+ ).rstrip("/")
435
+ park_tabs(base, ("x.com", "twitter.com"), _PARK_URL, "twitter_browser")
472
436
 
473
437
 
474
438
  def _register_park_on_exit():
475
439
  """Arm parking once per process, only for processes that actually used the
476
440
  browser. Registered AFTER the module-level _release_browser_lock atexit,
477
441
  so it runs BEFORE it (LIFO): park while still holding the python lock,
478
- then release. S4L_NO_TAB_PARK=1 is the escape hatch."""
479
- global _PARK_REGISTERED
480
- if _PARK_REGISTERED or os.environ.get("S4L_NO_TAB_PARK"):
481
- return
482
- _PARK_REGISTERED = True
483
- atexit.register(_park_twitter_tabs)
442
+ then release. S4L_NO_TAB_PARK=1 is the escape hatch (honored inside
443
+ browser_lifecycle)."""
444
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
445
+ from browser_lifecycle import register_park_on_exit
446
+
447
+ base = (
448
+ os.environ.get("TWITTER_CDP_URL", "").strip()
449
+ or "http://127.0.0.1:9555"
450
+ ).rstrip("/")
451
+ register_park_on_exit(
452
+ base, ("x.com", "twitter.com"), _PARK_URL, "twitter_browser"
453
+ )
484
454
 
485
455
 
486
456
  def get_browser_and_page(playwright):
@@ -33,6 +33,30 @@ log "=== archive-old-logs starting (DAYS=$DAYS) ==="
33
33
  find "$LOG_DIR" -maxdepth 1 -type f -mtime +"$DAYS" ! -name "$(basename "$RUN_LOG")" -print0 \
34
34
  | xargs -0 -I{} mv {} "$ARCHIVE_DIR/" 2>&1 | tee -a "$RUN_LOG" >/dev/null || true
35
35
 
36
+ # --- Size-based rotation for live launchd-*.log files (2026-07-17) -----------
37
+ # launchd holds these fds open and appends forever, so the mtime sweep above
38
+ # never touches them: their mtime is always fresh. Found in the wild at 1.9GB
39
+ # (launchd-linkedin-stdout.log) and 694MB (launchd-engage-twitter-stdout.log),
40
+ # which made "grep the logs" surface months-stale content as if it were
41
+ # current (the 2026-07-17 Neon-hostname false alarm).
42
+ # Copy-truncate: gzip the current content into logs-archive with a timestamp,
43
+ # then truncate IN PLACE (same inode) so the launchd fd keeps appending.
44
+ # Lines appended during the gzip window are lost on truncate; acceptable for
45
+ # logs, and the window is seconds. Threshold tunable via ROTATE_MAX_MB.
46
+ ROTATE_MAX_MB="${ROTATE_MAX_MB:-10}"
47
+ find "$LOG_DIR" -maxdepth 1 -type f -name "launchd-*.log" -size +"${ROTATE_MAX_MB}M" -print0 \
48
+ | while IFS= read -r -d '' f; do
49
+ base=$(basename "$f" .log)
50
+ dest="$ARCHIVE_DIR/${base}-$(date +%Y%m%d-%H%M%S).log.gz"
51
+ if gzip -c "$f" > "$dest" 2>>"$RUN_LOG"; then
52
+ : > "$f"
53
+ log "rotated $(basename "$f") -> $(basename "$dest") ($(du -h "$dest" | cut -f1) compressed)"
54
+ else
55
+ rm -f "$dest"
56
+ log "WARN: rotate failed for $f (left untouched)"
57
+ fi
58
+ done
59
+
36
60
  remaining=$(find "$LOG_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
37
61
  archived=$(find "$ARCHIVE_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
38
62
 
@@ -153,6 +153,7 @@ ensure_reddit_browser_for_backend() {
153
153
  HC_WINDOW_POS="${BH_REDDIT_WINDOW_POS:-2131,-1032}" \
154
154
  HC_WINDOW_SIZE="${BH_REDDIT_WINDOW_SIZE:-911,1016}" \
155
155
  HC_PRE_LAUNCH_HOOK=_reddit_defer_if_posting \
156
+ HC_EXTRA_FLAGS="${BH_REDDIT_PROXY:+--proxy-server=$BH_REDDIT_PROXY}" \
156
157
  hc_ensure_browser
157
158
  }
158
159