@m13v/s4l 1.7.7-rc.3 → 1.7.7-rc.5

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.
package/mcp/dist/repo.js CHANGED
@@ -71,6 +71,18 @@ export function run(cmd, args, opts = {}) {
71
71
  catch {
72
72
  /* a spawn observer must never break the run */
73
73
  }
74
+ if (opts.stdin != null) {
75
+ // Swallow async pipe errors (e.g. EPIPE when the child exits before
76
+ // reading) — an unhandled stream error would crash the whole process.
77
+ child.stdin.on("error", () => { });
78
+ try {
79
+ child.stdin.write(opts.stdin);
80
+ child.stdin.end();
81
+ }
82
+ catch {
83
+ /* a closed stdin must never break the run */
84
+ }
85
+ }
74
86
  let stdout = "";
75
87
  let stderr = "";
76
88
  // Per-stream partial-line buffers so onLine fires on whole lines only,
@@ -195,16 +195,63 @@ async function latestFromGithubRedirect() {
195
195
  return null;
196
196
  }
197
197
  }
198
+ // Optional GitHub token (2026-07-30): authenticated probes get 5000/h instead
199
+ // of the anonymous 60/h-per-IP quota that silenced the staging update banner
200
+ // on 2026-07-13 and again on 2026-07-30. Sources, in order: GITHUB_TOKEN /
201
+ // GH_TOKEN env, then `gh auth token` when the gh CLI exists (dev/operator
202
+ // machines; .mcpb boxes have neither and resolve to null instantly). The token
203
+ // is only ever sent to api.github.com, always via stdin (curl -H @-) so it
204
+ // never appears in argv/`ps`, and the `gh auth token` shell-out is noTee so it
205
+ // never reaches the telemetry relay. Keep in lockstep with
206
+ // scripts/snapshot.py::_github_token.
207
+ let ghTokCache = { at: 0, tok: null };
208
+ const GH_TOK_TTL_MS = 900_000;
209
+ async function githubToken() {
210
+ const now = Date.now();
211
+ if (ghTokCache.at && now - ghTokCache.at < GH_TOK_TTL_MS)
212
+ return ghTokCache.tok;
213
+ let tok = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
214
+ if (!tok) {
215
+ for (const gh of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "gh"]) {
216
+ const res = await run(gh, ["auth", "token"], { timeoutMs: 5000, noTee: true });
217
+ if (res.code === -1)
218
+ continue; // not spawnable at this path; try the next
219
+ const cand = (res.stdout || "").trim();
220
+ if (res.code === 0 && cand)
221
+ tok = cand;
222
+ break;
223
+ }
224
+ }
225
+ ghTokCache = { at: now, tok };
226
+ return tok;
227
+ }
198
228
  // Conditional-request state lives in the SHARED cache file (latest-release.json)
199
229
  // so the ETag survives process boundaries: short-lived MCP respawns used to pay
200
230
  // a full 200 per process; now every probe sends If-None-Match and gets a free
201
- // 304 between releases.
231
+ // 304 between releases. Probes authenticate when a GitHub token is available;
232
+ // a 401 (revoked/expired token) retries anonymously so a bad token is never
233
+ // worse than no token.
202
234
  async function curlConditional(url, etag) {
235
+ const tok = await githubToken();
236
+ const first = await curlOnce(url, etag, tok);
237
+ if (first.status === 401 && tok) {
238
+ ghTokCache = { at: Date.now(), tok: null }; // drop the dead token
239
+ return curlOnce(url, etag, null);
240
+ }
241
+ return first;
242
+ }
243
+ async function curlOnce(url, etag, token) {
203
244
  const args = ["-sS", "-m", "10", "-H", "Accept: application/vnd.github+json"];
245
+ if (token)
246
+ args.push("-H", "@-"); // Authorization arrives via stdin, never argv
204
247
  if (etag)
205
248
  args.push("-H", `If-None-Match: ${etag}`);
206
249
  args.push("-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url);
207
- const res = await run("curl", args, { timeoutMs: 12000, noTee: true });
250
+ const res = await run("curl", args, {
251
+ timeoutMs: 12000,
252
+ noTee: true,
253
+ stdin: token ? `Authorization: Bearer ${token}` : undefined,
254
+ });
208
255
  let status = 0;
209
256
  let newEtag = null;
210
257
  const body = [];
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.7-rc.3",
3
- "installedAt": "2026-07-30T22:30:41.307Z"
2
+ "version": "1.7.7-rc.5",
3
+ "installedAt": "2026-07-31T22:56:07.561Z"
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.7-rc.3",
5
+ "version": "1.7.7-rc.5",
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": {
@@ -156,9 +156,14 @@ class _Worker(threading.Thread):
156
156
  # the foreground, hide that specific Chrome process by pid, then force
157
157
  # focus back to the app the user was in. Screenshots/clicks are
158
158
  # unaffected (CDP is offscreen-raster + synthetic input; the occlusion
159
- # flags keep hidden tabs painting). Escape hatch: S4L_NO_HARNESS_HIDE.
159
+ # flags keep hidden tabs painting).
160
+ # DEFAULT OFF (user decision 2026-07-30): only the telemetry above runs
161
+ # unless S4L_HARNESS_HIDE=1 opts in. S4L_NO_HARNESS_HIDE force-disables
162
+ # even when the opt-in is set.
160
163
  if os.environ.get("S4L_NO_HARNESS_HIDE"):
161
164
  return
165
+ if os.environ.get("S4L_HARNESS_HIDE") != "1":
166
+ return
162
167
  pos = details.get("window_position") or ""
163
168
  try:
164
169
  offscreen = any(int(float(v)) < 0 for v in pos.split(",")[:2])
@@ -464,12 +464,12 @@ def _compact_arm(variant):
464
464
 
465
465
 
466
466
  def _reply_heading_suffix(d):
467
- """Concise 'project/lane · viral N · t v4' readout appended straight onto the
468
- 'Reply (editable):' heading (2026-07-07), so the two facts a reviewer
469
- checks first don't require opening the details eye. Pulled from the same
467
+ """Concise 'project · viral N · t v4' readout appended straight onto the
468
+ 'Reply (editable):' heading (2026-07-07), so the facts a reviewer checks
469
+ first don't require opening the details eye. Pulled from the same
470
470
  fields _details_lines used to render as 'Project:'/'Lane:'/'Virality
471
- score:' rows; those three are omitted from the popover now to avoid
472
- showing the same numbers twice. Empty string when neither is known."""
471
+ score:' rows; those are omitted from the popover now to avoid showing
472
+ the same numbers twice. Empty string when neither is known."""
473
473
  d = d or {}
474
474
  project = (d.get("project") or "").strip()
475
475
  lane = ((d.get("experiments") or {}).get("lane") or "").strip()
@@ -480,20 +480,11 @@ def _reply_heading_suffix(d):
480
480
  # approve_() blocks posting on these and the reviewer should know why
481
481
  # before clicking rather than after.
482
482
  bits.append("⚠ SANDBOX — not postable")
483
- # Project and lane are independent concepts most of the time (project
484
- # "fazm" drafted under lane "personal_brand"), but the operator's own
485
- # PersonalBrand project IS the personal_brand lane, so project=="PersonalBrand"
486
- # + lane=="personal_brand" is the SAME fact twice, not two facts — compare
487
- # with punctuation/case stripped so "PersonalBrand" vs "personal_brand"
488
- # collapses to one token instead of "PersonalBrand/personal_brand".
489
- same = project and lane and re.sub(r"[^a-z0-9]", "", project.lower()) == re.sub(
490
- r"[^a-z0-9]", "", lane.lower()
491
- )
492
- if same:
493
- bits.append(project)
494
- elif project and lane:
495
- bits.append(f"{project}/{lane}")
496
- elif project:
483
+ # Heading shows the project/lane name only, never a combined
484
+ # "project/lane" string (2026-07-31, user call: the "/promotion" suffix
485
+ # on cards was confusing). Lane is still stamped on the candidate and
486
+ # readable in the details popover; it's just not concatenated here.
487
+ if project:
497
488
  bits.append(project)
498
489
  elif lane:
499
490
  bits.append(lane)
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.7-rc.3",
3
+ "version": "1.7.7-rc.5",
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.7-rc.3",
3
+ "version": "1.7.7-rc.5",
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",
@@ -1,11 +1,21 @@
1
1
  #!/usr/bin/env python3
2
- """Close every CDP "page" tab in harness Chrome except one.
2
+ """Close every CDP "page" tab in harness Chrome except one, and keep that one
3
+ tab REAL (http/https).
3
4
 
4
- Called from skill/lib/twitter-backend.sh::ensure_twitter_browser_for_backend
5
- and skill/engage-twitter.sh's inline harness branch as part of pre-flight.
6
- Safe to call any time: exits 0 silently when harness Chrome is down. Workers
7
- and iframe targets are left alone; they auto-clean when their parent page
8
- closes.
5
+ Called from skill/lib/harness-common.sh::hc_cleanup_tabs (all three platform
6
+ backends) as part of pre-flight. Safe to call any time: exits 0 silently when
7
+ harness Chrome is down. Workers and iframe targets are left alone; they
8
+ auto-clean when their parent page closes.
9
+
10
+ Revive (2026-07-31): when BH_REVIVE_URL is set to an http(s) url, a Chrome
11
+ whose only tab is about:blank gets that tab navigated to the revive url, and
12
+ a completely tabless Chrome (--no-startup-window launch) gets ONE background
13
+ tab created there. This matters because the harness daemon's is_real_page()
14
+ refuses about:/chrome: tabs — a blank-only browser used to trap it in a
15
+ "stale session -> foreground createTarget(about:blank) -> still not real"
16
+ loop, one macOS focus steal per iteration (30/day on reddit-harness).
17
+ Both revive paths need websocket-client (present in the owned runtime venv,
18
+ S4L_PYTHON); without it the script degrades to close-only, as before.
9
19
 
10
20
  The standalone-script form (vs an inline heredoc) is required because bash
11
21
  3.2 on macOS cannot parse a nested heredoc inside a function body inside a
@@ -24,6 +34,56 @@ import urllib.request
24
34
  # its own harness Chrome on 9556. Default 9555 keeps Twitter callers unchanged.
25
35
  CDP_PORT = int(os.environ.get("BH_CLEANUP_PORT", "9555"))
26
36
  CDP_URL = f"http://127.0.0.1:{CDP_PORT}"
37
+ REVIVE_URL = os.environ.get("BH_REVIVE_URL", "")
38
+
39
+
40
+ def _is_real(t) -> bool:
41
+ return (t.get("url") or "").startswith(("http://", "https://"))
42
+
43
+
44
+ def _ws_call(ws_url: str, method: str, timeout: float = 8.0, **params):
45
+ # suppress_origin: Chrome 111+ rejects ws clients whose Origin header is
46
+ # not in --remote-allow-origins (same pattern as cdp_ready_check.py).
47
+ import websocket # websocket-client; ImportError handled by callers
48
+
49
+ ws = websocket.create_connection(ws_url, timeout=timeout, suppress_origin=True)
50
+ try:
51
+ ws.send(json.dumps({"id": 1, "method": method, "params": params}))
52
+ import time
53
+
54
+ deadline = time.time() + timeout
55
+ while time.time() < deadline:
56
+ msg = json.loads(ws.recv())
57
+ if msg.get("id") == 1:
58
+ return msg
59
+ raise TimeoutError(f"no reply to {method}")
60
+ finally:
61
+ ws.close()
62
+
63
+
64
+ def _revive(keep) -> None:
65
+ """Ensure one REAL tab exists: navigate a lone non-real tab (keep) to
66
+ REVIVE_URL, or create a background tab there when keep is None (tabless
67
+ Chrome). Best-effort; cleanup's exit code never depends on it."""
68
+ if not REVIVE_URL.startswith(("http://", "https://")):
69
+ return
70
+ try:
71
+ if keep is None:
72
+ # background=True: a foreground Target.createTarget raises the
73
+ # Chrome window and steals macOS app focus; revive must be silent.
74
+ info = json.loads(
75
+ urllib.request.urlopen(f"{CDP_URL}/json/version", timeout=2).read()
76
+ )
77
+ _ws_call(
78
+ info["webSocketDebuggerUrl"], "Target.createTarget",
79
+ url=REVIVE_URL, background=True,
80
+ )
81
+ print(f"[cleanup_harness_tabs] created background tab on {REVIVE_URL}")
82
+ elif not _is_real(keep) and keep.get("webSocketDebuggerUrl"):
83
+ _ws_call(keep["webSocketDebuggerUrl"], "Page.navigate", url=REVIVE_URL)
84
+ print(f"[cleanup_harness_tabs] revived blank tab -> {REVIVE_URL}")
85
+ except Exception as e:
86
+ print(f"[cleanup_harness_tabs] revive skipped ({type(e).__name__}: {e})")
27
87
 
28
88
 
29
89
  def main() -> int:
@@ -33,8 +93,13 @@ def main() -> int:
33
93
  except Exception:
34
94
  return 0
35
95
  pages = [t for t in tabs if t.get("type") == "page"]
36
- if len(pages) <= 1:
37
- print(f"[cleanup_harness_tabs] {len(pages)} page tab(s), no cleanup needed")
96
+ if not pages:
97
+ print("[cleanup_harness_tabs] 0 page tab(s)")
98
+ _revive(None)
99
+ return 0
100
+ if len(pages) == 1:
101
+ print("[cleanup_harness_tabs] 1 page tab(s), no cleanup needed")
102
+ _revive(pages[0])
38
103
  return 0
39
104
  # Keep a REAL (http/https) tab when one exists, not blindly pages[0]. The
40
105
  # /json order is roughly most-recently-active first, so a freshly-spawned
@@ -43,9 +108,6 @@ def main() -> int:
43
108
  # daemon's tab forces it to re-attach and re-spawn another about:blank, which
44
109
  # is exactly the orphan-tab churn this script is meant to clean up. Falling
45
110
  # back to pages[0] preserves the prior behavior when every tab is blank.
46
- def _is_real(t):
47
- return (t.get("url") or "").startswith(("http://", "https://"))
48
-
49
111
  keep = next((t for t in pages if _is_real(t)), pages[0])
50
112
  closed = 0
51
113
  for t in pages:
@@ -61,6 +123,7 @@ def main() -> int:
61
123
  pass
62
124
  kept_kind = "1 real" if _is_real(keep) else "1"
63
125
  print(f"[cleanup_harness_tabs] closed {closed}/{len(pages) - 1} extra page tabs (kept {kept_kind})")
126
+ _revive(keep)
64
127
  return 0
65
128
 
66
129
 
@@ -73,11 +73,36 @@ channel = ch if ch in ("stable", "staging") else "stable"
73
73
  REPO = "m13v/s4l"
74
74
  TAG_DL = "https://github.com/%s/releases/download/%s/social-autoposter.mcpb"
75
75
 
76
- def curl(url):
76
+ # Optional GitHub token (2026-07-30): authenticated requests get 5000/h vs the
77
+ # anonymous 60/h-per-IP quota (which, exhausted, makes this resolver fail
78
+ # closed). Sources: GITHUB_TOKEN / GH_TOKEN env, then `gh auth token` when the
79
+ # gh CLI exists (boxes have neither; resolves to None instantly). Sent via
80
+ # stdin (-H @-), never argv. Keep in lockstep with snapshot.py::_github_token.
81
+ def gh_token():
82
+ tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or None
83
+ if not tok:
84
+ for gh in ("/opt/homebrew/bin/gh", "/usr/local/bin/gh", "gh"):
85
+ try:
86
+ r = subprocess.run([gh, "auth", "token"],
87
+ capture_output=True, text=True, timeout=5)
88
+ cand = (r.stdout or "").strip()
89
+ if r.returncode == 0 and cand:
90
+ tok = cand
91
+ break
92
+ except FileNotFoundError:
93
+ continue
94
+ except Exception:
95
+ break
96
+ return tok
97
+
98
+ def curl(url, token=None):
77
99
  try:
78
- r = subprocess.run(["/usr/bin/curl", "-fsSL", "-m", "15",
79
- "-H", "Accept: application/vnd.github+json", url],
80
- capture_output=True, text=True, timeout=20)
100
+ args = ["/usr/bin/curl", "-fsSL", "-m", "15",
101
+ "-H", "Accept: application/vnd.github+json"]
102
+ if token:
103
+ args += ["-H", "@-"]
104
+ r = subprocess.run(args + [url], capture_output=True, text=True, timeout=20,
105
+ input=("Authorization: Bearer %s" % token) if token else None)
81
106
  return r.stdout if r.returncode == 0 else ""
82
107
  except Exception:
83
108
  return ""
@@ -93,8 +118,13 @@ def ver_key(v):
93
118
  m = re.findall(r"\d+", pre)
94
119
  return (nums[0], nums[1], nums[2], 0, int(m[-1]) if m else 0)
95
120
 
121
+ _tok = gh_token()
122
+ _url = "https://api.github.com/repos/%s/releases?per_page=30" % REPO
123
+ _raw = curl(_url, _tok)
124
+ if not _raw and _tok:
125
+ _raw = curl(_url) # bad token must never be worse than anonymous
96
126
  try:
97
- rels = json.loads(curl("https://api.github.com/repos/%s/releases?per_page=30" % REPO) or "[]")
127
+ rels = json.loads(_raw or "[]")
98
128
  except Exception:
99
129
  rels = []
100
130
  best = None
@@ -482,18 +482,69 @@ def _latest_from_github_redirect():
482
482
  return None
483
483
 
484
484
 
485
+ # ---- optional GitHub token (2026-07-30) -----------------------------------
486
+ # Authenticated probes get a 5000/h quota instead of the anonymous 60/h-per-IP
487
+ # quota that silenced the staging update banner on 2026-07-13 and again on
488
+ # 2026-07-30 (rate-limited staging probe degraded to the prerelease-blind
489
+ # releases/latest redirect, so a staging box resolved stable and never saw the
490
+ # rc). Sources, in order: GITHUB_TOKEN / GH_TOKEN env, then `gh auth token`
491
+ # when the gh CLI exists (dev/operator machines; .mcpb boxes have neither and
492
+ # resolve to None instantly). The token is only ever sent to api.github.com,
493
+ # always via stdin (-H @-) so it never appears in `ps` or logs. Keep in
494
+ # lockstep with mcp/src/version.ts::githubToken.
495
+ _gh_tok_cache = {"at": 0.0, "tok": None}
496
+ _GH_TOK_TTL = 900.0
497
+
498
+
499
+ def _github_token():
500
+ now = time.time()
501
+ if _gh_tok_cache["at"] and now - _gh_tok_cache["at"] < _GH_TOK_TTL:
502
+ return _gh_tok_cache["tok"]
503
+ tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or None
504
+ if not tok:
505
+ for gh in ("/opt/homebrew/bin/gh", "/usr/local/bin/gh", "gh"):
506
+ try:
507
+ res = subprocess.run([gh, "auth", "token"],
508
+ capture_output=True, text=True, timeout=5)
509
+ cand = (res.stdout or "").strip()
510
+ if res.returncode == 0 and cand:
511
+ tok = cand
512
+ break
513
+ except FileNotFoundError:
514
+ continue
515
+ except Exception:
516
+ break
517
+ _gh_tok_cache.update(at=now, tok=tok)
518
+ return tok
519
+
520
+
485
521
  # Conditional-request state lives in the SHARED cache file (latest-release.json)
486
522
  # so the ETag survives process boundaries: short-lived shell-outs used to pay a
487
523
  # full 200 per process; now every probe sends If-None-Match and gets a free 304
488
524
  # between releases.
489
525
  def _curl_conditional(url, etag):
490
- """GET url with optional If-None-Match. Returns (status, new_etag, body)."""
526
+ """GET url with optional If-None-Match, authenticated when a GitHub token
527
+ is available. Returns (status, new_etag, body). On 401 with a token
528
+ (revoked/expired) retries anonymously so a bad token is never worse than
529
+ no token."""
530
+ tok = _github_token()
531
+ status, new_etag, body = _curl_once(url, etag, tok)
532
+ if status == 401 and tok:
533
+ _gh_tok_cache.update(tok=None) # drop the dead token for this process
534
+ status, new_etag, body = _curl_once(url, etag, None)
535
+ return status, new_etag, body
536
+
537
+
538
+ def _curl_once(url, etag, token):
491
539
  args = ["/usr/bin/curl", "-sS", "-m", "10",
492
540
  "-H", "Accept: application/vnd.github+json"]
541
+ if token:
542
+ args += ["-H", "@-"] # Authorization arrives via stdin, never argv
493
543
  if etag:
494
544
  args += ["-H", "If-None-Match: %s" % etag]
495
545
  args += ["-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url]
496
- res = subprocess.run(args, capture_output=True, text=True, timeout=12)
546
+ res = subprocess.run(args, capture_output=True, text=True, timeout=12,
547
+ input=("Authorization: Bearer %s" % token) if token else None)
497
548
  status, new_etag, body = 0, None, []
498
549
  for line in (res.stdout or "").splitlines():
499
550
  if line.startswith("__CURL_STATUS__:"):
@@ -14,6 +14,9 @@ Usage:
14
14
  # Scan DM inbox for unread conversations
15
15
  python3 twitter_browser.py unread-dms
16
16
 
17
+ # Same scan, plus the Message Requests tab (rows tagged is_request=true)
18
+ python3 twitter_browser.py unread-dms --include-requests
19
+
17
20
  # Read messages from a DM conversation
18
21
  python3 twitter_browser.py read-conversation "https://x.com/i/chat/123-456"
19
22
 
@@ -1495,15 +1498,24 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
1495
1498
  browser.close()
1496
1499
 
1497
1500
 
1498
- def unread_dms():
1501
+ def unread_dms(include_requests=False):
1499
1502
  """Scan Twitter/X DM inbox for conversations.
1500
1503
 
1501
1504
  Navigates to /i/chat, handles the encryption passcode if needed,
1502
1505
  and extracts all visible conversations with their author, preview text,
1503
1506
  timestamp, and conversation URL.
1504
1507
 
1508
+ include_requests=True additionally scans the Message Requests tab
1509
+ (/i/chat/requests, inbound DMs from people we don't follow) with the
1510
+ exact same scrape/scroll code and appends those rows to the result.
1511
+ Every row carries "is_request" (true only for requests-tab rows) so the
1512
+ pipeline can apply a relevance gate before replying. A requests-tab
1513
+ failure degrades to primary-only results (stderr note) rather than
1514
+ failing the whole scan.
1515
+
1505
1516
  Returns: [{"author": "...", "handle": "...", "preview": "...", "time": "...",
1506
- "thread_url": "...", "is_from_us": bool, "has_unread": bool}, ...]
1517
+ "thread_url": "...", "is_from_us": bool, "has_unread": bool,
1518
+ "is_request": bool}, ...]
1507
1519
 
1508
1520
  `has_unread` is the signal callers should filter on. It is derived from the
1509
1521
  sidebar's visual unread state (aria-label "unread", bold font weight on the
@@ -1563,7 +1575,11 @@ def unread_dms():
1563
1575
  if (!link) continue;
1564
1576
 
1565
1577
  const threadUrl = link.href;
1566
- if (!threadUrl.match(/\\/i\\/chat\\/[\\d-g]/)) continue;
1578
+ // Accept both /i/chat/<ids> (primary inbox) and
1579
+ // /i/chat/requests/<ids> (Message Requests tab). The bare
1580
+ // "Message requests" row link (/i/chat/requests, no ids)
1581
+ // still fails the match and stays excluded.
1582
+ if (!threadUrl.match(/\\/i\\/chat\\/(requests\\/)?[\\d-g]/)) continue;
1567
1583
 
1568
1584
  let handle = '';
1569
1585
  const avatarLink = item.querySelector('a[href^="https://x.com/"]');
@@ -1678,27 +1694,86 @@ def unread_dms():
1678
1694
  return 0;
1679
1695
  }"""
1680
1696
 
1681
- seen = {}
1682
- stuck_iters = 0
1683
- max_iters = int(os.environ.get("TWITTER_UNREAD_SCROLL_MAX_ITERS", "60"))
1684
- max_no_growth = int(os.environ.get("TWITTER_UNREAD_SCROLL_NO_GROWTH", "5"))
1685
- for _ in range(max_iters):
1686
- batch = page.evaluate(scrape_js)
1687
- grew = False
1688
- for c in batch:
1689
- if c["thread_url"] not in seen:
1690
- seen[c["thread_url"]] = c
1691
- grew = True
1692
- if not grew:
1693
- stuck_iters += 1
1694
- else:
1695
- stuck_iters = 0
1696
- if stuck_iters >= max_no_growth:
1697
- break
1698
- page.evaluate(scroll_js)
1699
- page.wait_for_timeout(600)
1700
-
1701
- unique = list(seen.values())
1697
+ def _scan_sidebar(is_request):
1698
+ # Shared scrape+scroll loop for both the primary inbox and
1699
+ # the Message Requests tab (same virtualized sidebar DOM).
1700
+ seen = {}
1701
+ stuck_iters = 0
1702
+ max_iters = int(os.environ.get("TWITTER_UNREAD_SCROLL_MAX_ITERS", "60"))
1703
+ max_no_growth = int(os.environ.get("TWITTER_UNREAD_SCROLL_NO_GROWTH", "5"))
1704
+ for _ in range(max_iters):
1705
+ batch = page.evaluate(scrape_js)
1706
+ grew = False
1707
+ for c in batch:
1708
+ if c["thread_url"] not in seen:
1709
+ c["is_request"] = is_request
1710
+ seen[c["thread_url"]] = c
1711
+ grew = True
1712
+ if not grew:
1713
+ stuck_iters += 1
1714
+ else:
1715
+ stuck_iters = 0
1716
+ if stuck_iters >= max_no_growth:
1717
+ break
1718
+ page.evaluate(scroll_js)
1719
+ page.wait_for_timeout(600)
1720
+ return list(seen.values())
1721
+
1722
+ unique = _scan_sidebar(False)
1723
+
1724
+ # Message Requests pass: same DOM, same scrape/scroll code, just
1725
+ # a different page. These are inbound DMs from people we don't
1726
+ # follow; they never appear in the /i/chat sidebar (the
1727
+ # "Message requests" row itself is dropped by the thread-URL
1728
+ # regex above), so without this pass they are invisible to the
1729
+ # whole DM pipeline.
1730
+ if include_requests:
1731
+ try:
1732
+ # A concurrent pipeline sharing this harness tab can yank
1733
+ # navigation between our goto and the scrape (landing on
1734
+ # e.g. a search page), which would silently scrape 0
1735
+ # request rows. Verify the URL stuck; re-goto up to 2x.
1736
+ landed = False
1737
+ for _nav_try in range(3):
1738
+ page.goto(
1739
+ "https://x.com/i/chat/requests",
1740
+ wait_until="domcontentloaded",
1741
+ )
1742
+ page.wait_for_timeout(5000)
1743
+ if "chat/requests" in page.url:
1744
+ landed = True
1745
+ break
1746
+ unreachable, reason = _is_x_unreachable(page)
1747
+ if unreachable:
1748
+ print(
1749
+ f"[unread_dms] requests tab unreachable ({reason}); "
1750
+ "returning primary-only results",
1751
+ file=sys.stderr,
1752
+ )
1753
+ elif not landed:
1754
+ print(
1755
+ f"[unread_dms] requests tab navigation hijacked "
1756
+ f"(landed on {page.url}); returning primary-only "
1757
+ "results",
1758
+ file=sys.stderr,
1759
+ )
1760
+ else:
1761
+ primary_urls = {c["thread_url"] for c in unique}
1762
+ n_req = 0
1763
+ for c in _scan_sidebar(True):
1764
+ if c["thread_url"] not in primary_urls:
1765
+ unique.append(c)
1766
+ n_req += 1
1767
+ print(
1768
+ f"[unread_dms] requests tab scanned: {n_req} rows",
1769
+ file=sys.stderr,
1770
+ )
1771
+ except Exception as e:
1772
+ print(
1773
+ f"[unread_dms] requests scan failed ({e}); "
1774
+ "returning primary-only results",
1775
+ file=sys.stderr,
1776
+ )
1702
1777
 
1703
1778
  # If the inbox API was throttled hard AND we got nothing back,
1704
1779
  # treat this as rate-limited so the caller can back off instead
@@ -1754,6 +1829,60 @@ def read_conversation(thread_url, max_messages=20):
1754
1829
  const main = document.querySelector('main');
1755
1830
  if (!main) return {partner_name: '', partner_handle: '', messages: [], total_found: 0};
1756
1831
 
1832
+ // 2026-07-30: X shipped a redesigned chat thread DOM. The
1833
+ // message list is now [data-testid="dm-message-list"] with one
1834
+ // [data-testid="message-<uuid>"] node per bubble and the text
1835
+ // inside [data-testid="message-text-<uuid>"]; the old
1836
+ // li/[role=listitem] structure is gone (this silently zeroed
1837
+ // every read from ~07-28). Direction comes from the bubble
1838
+ // row's computed justify-content: flex-end = ours,
1839
+ // flex-start = theirs (verified against a mixed
1840
+ // conversation). The legacy extraction below stays as the
1841
+ // fallback for any account still on the old DOM.
1842
+ const v2list = main.querySelector('[data-testid="dm-message-list"]');
1843
+ if (v2list) {
1844
+ const uEl = main.querySelector('[data-testid="dm-conversation-username"]');
1845
+ if (uEl) partnerName = uEl.textContent.trim();
1846
+ const handleEls2 = main.querySelectorAll('div, span');
1847
+ for (const el of handleEls2) {
1848
+ const t = el.textContent.trim();
1849
+ if (t.startsWith('@') && t.length > 2 && t.length < 50 &&
1850
+ !t.includes(' ') && t.substring(1) !== ourHandle) {
1851
+ partnerHandle = t.substring(1);
1852
+ break;
1853
+ }
1854
+ }
1855
+ const messages = [];
1856
+ for (const item of v2list.querySelectorAll('[data-testid^="message-"]')) {
1857
+ const tid = item.getAttribute('data-testid') || '';
1858
+ if (tid.startsWith('message-text-')) continue;
1859
+ const textEl = item.querySelector('[data-testid^="message-text-"]');
1860
+ let content = ((textEl ? textEl.textContent : item.textContent) || '');
1861
+ const fullText = item.textContent || '';
1862
+ const timeMatch = fullText.match(/(\\d{1,2}:\\d{2}\\s*[AP]M)/);
1863
+ const time = timeMatch ? timeMatch[1] : '';
1864
+ content = content
1865
+ .replace(/(\\d{1,2}:\\d{2}\\s*[AP]M)/g, '')
1866
+ .replace(/(Seen|Delivered|Sent)\\s*$/, '')
1867
+ .trim();
1868
+ if (!content) continue;
1869
+ const isFromUs =
1870
+ window.getComputedStyle(item).justifyContent === 'flex-end';
1871
+ messages.push({
1872
+ sender: isFromUs ? ourHandle : (partnerHandle || partnerName),
1873
+ content: content,
1874
+ time: time,
1875
+ is_from_us: isFromUs,
1876
+ });
1877
+ }
1878
+ return {
1879
+ partner_name: partnerName,
1880
+ partner_handle: partnerHandle,
1881
+ messages: messages.slice(-maxMessages),
1882
+ total_found: messages.length,
1883
+ };
1884
+ }
1885
+
1757
1886
  // Find the conversation panel (the section containing the
1758
1887
  // message textbox), NOT the sidebar conversation list.
1759
1888
  // The textbox has aria-label like "Unencrypted message".
@@ -2069,6 +2198,22 @@ def send_dm(thread_url, message, dm_id=None, apply_campaigns=True):
2069
2198
  "landed_url": page.url,
2070
2199
  }
2071
2200
 
2201
+ # Message-request threads (from x.com/i/chat/requests) hide the
2202
+ # compose box behind an Accept banner until the request is
2203
+ # accepted. Accepting moves the thread to the primary inbox and
2204
+ # does not notify the sender; the reply we're about to send is
2205
+ # the visible action. No-op on normal threads (button absent).
2206
+ try:
2207
+ _accept = page.get_by_role(
2208
+ "button", name=re.compile(r"^Accept\b", re.I)
2209
+ )
2210
+ if _accept.count() > 0 and _accept.first.is_visible():
2211
+ _accept.first.click()
2212
+ page.wait_for_timeout(2000)
2213
+ print("[send_dm] accepted message request", file=sys.stderr)
2214
+ except Exception:
2215
+ pass
2216
+
2072
2217
  # Find the message input box
2073
2218
  msg_box = None
2074
2219
  for label in ["Unencrypted message", "Start a new message"]:
@@ -2085,6 +2230,17 @@ def send_dm(thread_url, message, dm_id=None, apply_campaigns=True):
2085
2230
  'div[role="textbox"][contenteditable="true"]'
2086
2231
  ).last
2087
2232
  msg_box.wait_for(state="visible", timeout=3000)
2233
+ except Exception:
2234
+ msg_box = None
2235
+
2236
+ # 2026-07-30 chat redesign: the composer is now a plain
2237
+ # <textarea data-testid="dm-composer-textarea"> (placeholder
2238
+ # "Unencrypted message"), no contenteditable div and no
2239
+ # role/aria-label attributes.
2240
+ if not msg_box:
2241
+ try:
2242
+ msg_box = page.locator('[data-testid="dm-composer-textarea"]')
2243
+ msg_box.wait_for(state="visible", timeout=3000)
2088
2244
  except Exception:
2089
2245
  return {"ok": False, "error": "message_box_not_found"}
2090
2246
 
@@ -2110,6 +2266,21 @@ def send_dm(thread_url, message, dm_id=None, apply_campaigns=True):
2110
2266
  if verified and dm_id is not None:
2111
2267
  _log_twitter_dm_outbound(dm_id, message, minted_codes=minted_link_codes)
2112
2268
 
2269
+ # A verified send on a message-request thread accepts the
2270
+ # request, which moves the conversation to the primary inbox
2271
+ # where the /i/chat/requests/<ids> URL no longer resolves
2272
+ # (X bounces it to the requests list). Canonicalize the stored
2273
+ # chat_url so follow-up sends navigate correctly.
2274
+ if verified and dm_id is not None and "/i/chat/requests/" in thread_url:
2275
+ try:
2276
+ import http_api
2277
+ canonical = thread_url.replace("/i/chat/requests/", "/i/chat/")
2278
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", {"chat_url": canonical})
2279
+ print(f"[send_dm] request accepted; canonicalized chat_url -> {canonical}",
2280
+ file=sys.stderr)
2281
+ except Exception as e:
2282
+ print(f"[send_dm] chat_url canonicalize failed: {e}", file=sys.stderr)
2283
+
2113
2284
  return {
2114
2285
  "ok": verified,
2115
2286
  "thread_url": page.url,
@@ -2744,7 +2915,11 @@ def main():
2744
2915
  print(json.dumps(result, indent=2))
2745
2916
 
2746
2917
  elif cmd == "unread-dms":
2747
- result = unread_dms()
2918
+ # --include-requests: also scan x.com/i/chat/requests (message
2919
+ # requests from people we don't follow); those rows carry
2920
+ # is_request=true.
2921
+ _inc_req = "--include-requests" in sys.argv[2:]
2922
+ result = unread_dms(include_requests=_inc_req)
2748
2923
  print(json.dumps(result, indent=2))
2749
2924
 
2750
2925
  elif cmd == "read-conversation":
@@ -551,12 +551,15 @@ if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "twitter" ] || [ "$PLATFORM" = "x" ]; t
551
551
  IFS= read -r -d '' PHASE_C_BLOCK <<'PHASE_C_EOF' || true
552
552
  ## PHASE C: Scan X/Twitter DMs for new messages
553
553
 
554
- 1. Get ALL Twitter DM conversations visible in the sidebar (the script returns the full list, not only unread) and write them to /tmp/twitter_threads.json:
554
+ 1. Get ALL Twitter DM conversations visible in the sidebar (the script returns the full list, not only unread), INCLUDING the Message Requests tab (x.com/i/chat/requests), and write them to /tmp/twitter_threads.json:
555
555
  ```bash
556
- python3 scripts/twitter_browser.py unread-dms > /tmp/twitter_threads.json
556
+ python3 scripts/twitter_browser.py unread-dms --include-requests > /tmp/twitter_threads.json
557
557
  ```
558
558
  This handles the encrypted DM passcode automatically (loaded from .env TWITTER_DM_PASSCODE).
559
- Returns JSON array with: author, handle, preview, time, thread_url, is_from_us, has_unread.
559
+ Returns JSON array with: author, handle, preview, time, thread_url, is_from_us, has_unread, is_request.
560
+ Rows with `is_request: true` came from the Message Requests tab: cold inbound DMs from people we don't follow who messaged us first. They flow through the exact same steps below (backfill, filter, read, ensure-dm, log-inbound, mark-inspected); the only difference is a relevance gate at reply time (step 3f). If the script's stderr shows "requests tab unreachable" or "navigation hijacked", proceed with the primary-only rows it returned; do not re-run the scan.
561
+ **URL form for request threads: keep `/i/chat/requests/<ids>` EXACTLY as scanned.** Pass it verbatim to read-conversation, ensure-dm, and send-dm. Do NOT strip the `/requests/` segment yourself: the canonical `/i/chat/<ids>` form does not resolve until the request is accepted (X bounces it to the requests list), so a hand-normalized chat_url breaks every later send. send-dm canonicalizes the stored chat_url automatically after the accepting reply goes through.
562
+ **Request backlog cap: inspect at most the 10 NEWEST `is_request` threads per run** (they are already newest-first in the scan). A burst of accepts/replies across a weeks-old backlog in one cycle is a behavioral-fingerprint risk; the rest of the backlog drains 10 per cycle on subsequent runs. The cap applies only to `is_request` rows; normal threads are unaffected.
560
563
 
561
564
  1a. Backfill chat URLs for any existing X DM row still missing one. Cheap, idempotent, fills buttons for historical rows whose chat is still in the sidebar:
562
565
  ```bash
@@ -593,6 +596,13 @@ if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "twitter" ] || [ "$PLATFORM" = "x" ]; t
593
596
  python3 scripts/dm_conversation.py mark-inspected --dm-id DM_ID
594
597
  ```
595
598
  Skipping this on a "nothing new" thread is the bug that makes us re-open the same cold conversation every cycle. Always run it. The next filter-inbox will skip the thread for 24h unless a fresh inbound or outbound is logged in the meantime.
599
+ f. **Message requests only (`is_request: true` on the sidebar row): relevance gate before any reply.** The requests tab mixes real leads with spam, so decide per thread after reading it:
600
+ - RELEVANT (mentions our projects/products or their problem space, responding to our public posts, genuine question, plausible customer or partner): treat it as a normal conversation. It will surface in Phase D via the pending rail and gets a reply there. Sending the reply is what accepts the request; `send-dm` clicks the Accept button itself, and accepting does not notify them, so the reply is the first visible action.
601
+ - SPAM or IRRELEVANT (crypto/engagement-farm blasts, template "collab?" mass marketing, bots, adult content, pure solicitation with zero connection to us): do NOT reply and do NOT accept. Still complete steps c-d so the message is on record, THEN close the conversation so it never re-enters the queue:
602
+ ```bash
603
+ python3 scripts/dm_conversation.py set-status --dm-id DM_ID --status closed
604
+ ```
605
+ Run set-status closed AFTER log-inbound (log-inbound flips the row to needs_reply; closed must win). Never delete or block request threads from this pipeline.
596
606
  PHASE_C_EOF
597
607
  fi
598
608
 
@@ -33,7 +33,12 @@
33
33
  # HC_PROFILE_DIR - Chrome profile dir; also the exact-dir pgrep reap match
34
34
  # HC_DEFAULT_URL - http://127.0.0.1:<port>; ownership test vs external/BYO
35
35
  # HC_CDP_URL - actual CDP url (backend passes its <PLATFORM>_CDP_URL)
36
- # HC_LAUNCH_URL - first-tab url (default about:blank)
36
+ # HC_LAUNCH_URL - first-tab url (default derived per HC_PLATFORM via
37
+ # _hc_default_launch_url; BH_LAUNCH_URL env overrides).
38
+ # MUST be a real http(s) page: the harness daemon's
39
+ # is_real_page() refuses about:/chrome: tabs, and a
40
+ # blank-only Chrome used to trap it in a foreground
41
+ # createTarget loop = focus steals (2026-07-31)
37
42
  # HC_WINDOW_POS - macOS offscreen window position (x,y)
38
43
  # HC_WINDOW_SIZE - macOS window size (w,h)
39
44
  # HC_EXTRA_FLAGS - extra platform Chrome flags, ONE STRING, split on
@@ -203,6 +208,22 @@ hc_record_health() {
203
208
  > "$_f" 2>/dev/null || true
204
209
  }
205
210
 
211
+ _hc_default_launch_url() {
212
+ # Per-platform first-tab / revive url ($1 = platform, default HC_PLATFORM).
213
+ # ONE map for every backend — the per-backend HC_LAUNCH_URL copies drifted
214
+ # (twitter had x.com, reddit/linkedin had about:blank, which armed the
215
+ # daemon's blank-tab focus-steal loop; see cleanup_harness_tabs.py).
216
+ # robots.txt = static real page, no SPA to crash while parked, same-domain
217
+ # so tab-reuse preferences still match. Twitter keeps x.com: its post-
218
+ # launch session-restore hook has always run against the full page.
219
+ case "${1:-${HC_PLATFORM:-}}" in
220
+ twitter) echo "https://x.com" ;;
221
+ reddit) echo "https://www.reddit.com/robots.txt" ;;
222
+ linkedin) echo "https://www.linkedin.com/robots.txt" ;;
223
+ *) echo "about:blank" ;;
224
+ esac
225
+ }
226
+
206
227
  hc_cleanup_tabs() {
207
228
  # Close every CDP "page" tab except one. $1 = port, $2 = log label.
208
229
  # Delegated to a standalone Python script because bash 3.2 (what launchd
@@ -221,7 +242,15 @@ hc_cleanup_tabs() {
221
242
  return 0
222
243
  fi
223
244
  fi
224
- BH_CLEANUP_PORT="$_port" python3 "$_BH_REPO_DIR/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
245
+ # BH_REVIVE_URL lets the script heal a blank-only / tabless Chrome by
246
+ # (re)establishing one real tab in the BACKGROUND — without it the harness
247
+ # daemon can never re-attach cleanly (is_real_page rejects about:blank).
248
+ # Platform derived from the label ("reddit-harness" -> reddit). S4L_PYTHON
249
+ # (owned runtime venv) has websocket-client; bare python3 degrades to
250
+ # close-only, same as before.
251
+ BH_CLEANUP_PORT="$_port" \
252
+ BH_REVIVE_URL="$(_hc_default_launch_url "${_label%-harness}")" \
253
+ "${S4L_PYTHON:-python3}" "$_BH_REPO_DIR/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
225
254
  }
226
255
 
227
256
  # Shared interpreter for the reserved skip code 78 (2026-07-15). Every
@@ -418,7 +447,7 @@ hc_ensure_browser() {
418
447
  # pipeline op mints its tab via the background Target.createTarget
419
448
  # path) a positional URL argument would force a window and defeat the
420
449
  # flag — drop the launch URL in that mode.
421
- local _launch_url="${HC_LAUNCH_URL:-about:blank}"
450
+ local _launch_url="${HC_LAUNCH_URL:-${BH_LAUNCH_URL:-$(_hc_default_launch_url)}}"
422
451
  case " ${HC_EXTRA_FLAGS:-} " in
423
452
  *" --no-startup-window "*) _launch_url="" ;;
424
453
  esac
@@ -297,7 +297,6 @@ ensure_linkedin_browser_for_backend() {
297
297
  HC_PROFILE_DIR="$HOME/.claude/browser-profiles/browser-harness-linkedin" \
298
298
  HC_DEFAULT_URL="$_BH_LINKEDIN_DEFAULT_URL" \
299
299
  HC_CDP_URL="${LINKEDIN_CDP_URL:-$_BH_LINKEDIN_DEFAULT_URL}" \
300
- HC_LAUNCH_URL="about:blank" \
301
300
  HC_WINDOW_POS="${BH_LINKEDIN_WINDOW_POS:-3814,-1050}" \
302
301
  HC_WINDOW_SIZE="${BH_LINKEDIN_WINDOW_SIZE:-1024,1013}" \
303
302
  HC_EXTRA_FLAGS="--disable-renderer-backgrounding --disable-background-timer-throttling" \
@@ -149,7 +149,6 @@ ensure_reddit_browser_for_backend() {
149
149
  HC_PROFILE_DIR="$HOME/.claude/browser-profiles/reddit-harness" \
150
150
  HC_DEFAULT_URL="$_BH_REDDIT_DEFAULT_URL" \
151
151
  HC_CDP_URL="${REDDIT_CDP_URL:-$_BH_REDDIT_DEFAULT_URL}" \
152
- HC_LAUNCH_URL="about:blank" \
153
152
  HC_WINDOW_POS="${BH_REDDIT_WINDOW_POS:-2131,-1032}" \
154
153
  HC_WINDOW_SIZE="${BH_REDDIT_WINDOW_SIZE:-911,1016}" \
155
154
  HC_PRE_LAUNCH_HOOK=_reddit_defer_if_posting \
@@ -160,7 +160,6 @@ ensure_twitter_browser_for_backend() {
160
160
  HC_PROFILE_DIR="$HOME/.claude/browser-profiles/browser-harness" \
161
161
  HC_DEFAULT_URL="$_BH_DEFAULT_URL" \
162
162
  HC_CDP_URL="${TWITTER_CDP_URL:-$_BH_DEFAULT_URL}" \
163
- HC_LAUNCH_URL="${BH_LAUNCH_URL:-https://x.com}" \
164
163
  HC_WINDOW_POS="${BH_WINDOW_POS:-3042,-1032}" \
165
164
  HC_WINDOW_SIZE="${BH_WINDOW_SIZE:-1024,1013}" \
166
165
  HC_EXTRA_FLAGS="--password-store=basic --use-mock-keychain" \