@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 +12 -0
- package/mcp/dist/version.js +49 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +6 -1
- package/mcp/menubar/s4l_card.py +10 -19
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/cleanup_harness_tabs.py +74 -11
- package/scripts/s4l_box_update.sh +35 -5
- package/scripts/snapshot.py +53 -2
- package/scripts/twitter_browser.py +200 -25
- package/skill/engage-dm-replies.sh +13 -3
- package/skill/lib/harness-common.sh +32 -3
- package/skill/lib/linkedin-backend.sh +0 -1
- package/skill/lib/reddit-backend.sh +0 -1
- package/skill/lib/twitter-backend.sh +0 -1
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,
|
package/mcp/dist/version.js
CHANGED
|
@@ -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, {
|
|
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 = [];
|
package/mcp/dist/version.json
CHANGED
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.
|
|
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).
|
|
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])
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -464,12 +464,12 @@ def _compact_arm(variant):
|
|
|
464
464
|
|
|
465
465
|
|
|
466
466
|
def _reply_heading_suffix(d):
|
|
467
|
-
"""Concise 'project
|
|
468
|
-
'Reply (editable):' heading (2026-07-07), so the
|
|
469
|
-
|
|
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
|
|
472
|
-
|
|
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
|
-
#
|
|
484
|
-
# "
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
|
|
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
|
+
"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,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/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
37
|
-
print(
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
127
|
+
rels = json.loads(_raw or "[]")
|
|
98
128
|
except Exception:
|
|
99
129
|
rels = []
|
|
100
130
|
best = None
|
package/scripts/snapshot.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
for
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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" \
|