threadfox-lite 0.2.4__tar.gz → 0.2.5__tar.gz
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.
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/.claude-plugin/marketplace.json +1 -1
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/.claude-plugin/plugin.json +1 -1
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/PKG-INFO +2 -2
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/README.md +1 -1
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/manifest.json +1 -1
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/pyproject.toml +1 -1
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/server.json +3 -3
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/src/threadfox_lite/__init__.py +1 -1
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/src/threadfox_lite/browser.py +67 -16
- threadfox_lite-0.2.5/src/threadfox_lite/process.py +288 -0
- threadfox_lite-0.2.5/tests/test_process.py +92 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/uv.lock +1 -1
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/.github/workflows/publish.yml +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/.gitignore +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/.mcpbignore +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/LAUNCHGUIDE.md +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/LICENSE +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/glama.json +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/icon.png +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/skills/reddit-rules-first/SKILL.md +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/src/threadfox_lite/node.py +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/src/threadfox_lite/reddit.py +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/src/threadfox_lite/server.py +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/tests/test_node.py +0 -0
- {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/tests/test_reddit.py +0 -0
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"name": "threadfox-lite",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Free, read-only Reddit tools: subreddit rules before you post, post status after, and the reddit-rules-first skill.",
|
|
15
|
-
"version": "0.2.
|
|
15
|
+
"version": "0.2.5",
|
|
16
16
|
"category": "marketing",
|
|
17
17
|
"homepage": "https://threadfox.vip"
|
|
18
18
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "threadfox-lite",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Free, read-only Reddit tools: find communities where you may post, read their rules, check your account and whether a post stayed up, plus the reddit-rules-first skill.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "ThreadFox (AMF Indiana)",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: threadfox-lite
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
4
4
|
Summary: Read a subreddit's rules and check whether a Reddit post is still up, from Claude Code, Codex or any MCP client. Free tools from ThreadFox.
|
|
5
5
|
Project-URL: Homepage, https://threadfox.vip
|
|
6
6
|
Project-URL: Source, https://github.com/amflimited/threadfox-lite
|
|
@@ -21,7 +21,7 @@ Free, read-only Reddit tools for Claude Desktop, Claude Code, Codex and any MCP
|
|
|
21
21
|
- `post_status`: whether a post is still live, removed by moderators or deleted, with score and comments. Check at 1, 6 and 24 hours.
|
|
22
22
|
- `threadfox_full_kit`: what the full ThreadFox kit adds (finding conversations where people ask for what you sell, posting from your own Chrome, replies, an outcome ledger, a 12-module course).
|
|
23
23
|
|
|
24
|
-
**Reads only, through your own signed-in Chrome.** Reddit now sends every signed-out request, including its public JSON, to a login page, so ThreadFox Lite reads the way you do: in your Chrome, signed in as you, one small read at a time. It never posts, votes or changes anything, and needs no Reddit API keys. One-time setup: Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. Node.js is fetched for you if you don't have it (the official build from nodejs.org into `~/.threadfox/runtime`, checked against its published SHA-256; nothing installed system-wide). The first read asks you to pick a tab in Chrome.
|
|
24
|
+
**Reads only, through your own signed-in Chrome.** Reddit now sends every signed-out request, including its public JSON, to a login page, so ThreadFox Lite reads the way you do: in your Chrome, signed in as you, one small read at a time. It never posts, votes or changes anything, and needs no Reddit API keys. One-time setup: Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. Node.js is fetched for you if you don't have it (the official build from nodejs.org into `~/.threadfox/runtime`, checked against its published SHA-256; nothing installed system-wide). It stays light on your computer: one Reddit read at a time across every ThreadFox app on the machine, and its Chrome connection closes after 10 idle minutes and never leaves background processes behind. The first read asks you to pick a tab in Chrome.
|
|
25
25
|
|
|
26
26
|
## Install
|
|
27
27
|
|
|
@@ -8,7 +8,7 @@ Free, read-only Reddit tools for Claude Desktop, Claude Code, Codex and any MCP
|
|
|
8
8
|
- `post_status`: whether a post is still live, removed by moderators or deleted, with score and comments. Check at 1, 6 and 24 hours.
|
|
9
9
|
- `threadfox_full_kit`: what the full ThreadFox kit adds (finding conversations where people ask for what you sell, posting from your own Chrome, replies, an outcome ledger, a 12-module course).
|
|
10
10
|
|
|
11
|
-
**Reads only, through your own signed-in Chrome.** Reddit now sends every signed-out request, including its public JSON, to a login page, so ThreadFox Lite reads the way you do: in your Chrome, signed in as you, one small read at a time. It never posts, votes or changes anything, and needs no Reddit API keys. One-time setup: Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. Node.js is fetched for you if you don't have it (the official build from nodejs.org into `~/.threadfox/runtime`, checked against its published SHA-256; nothing installed system-wide). The first read asks you to pick a tab in Chrome.
|
|
11
|
+
**Reads only, through your own signed-in Chrome.** Reddit now sends every signed-out request, including its public JSON, to a login page, so ThreadFox Lite reads the way you do: in your Chrome, signed in as you, one small read at a time. It never posts, votes or changes anything, and needs no Reddit API keys. One-time setup: Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. Node.js is fetched for you if you don't have it (the official build from nodejs.org into `~/.threadfox/runtime`, checked against its published SHA-256; nothing installed system-wide). It stays light on your computer: one Reddit read at a time across every ThreadFox app on the machine, and its Chrome connection closes after 10 idle minutes and never leaves background processes behind. The first read asks you to pick a tab in Chrome.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": "0.4",
|
|
3
3
|
"name": "threadfox-lite",
|
|
4
4
|
"display_name": "ThreadFox Lite",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.5",
|
|
6
6
|
"description": "Find subreddits where you may post, read their rules, check your account and whether a post stayed up. Free, read-only.",
|
|
7
7
|
"long_description": "Free, read-only Reddit tools for Claude and any MCP client. find_communities lists subreddits about a topic with their self-promotion rules flagged; subreddit_rules reads one community's rules, size and description; account_check reports an account's age, karma, standing and whether its recent posts are removed or hidden from search; post_status says whether a post is live, removed or deleted. Reddit now sends signed-out requests to a login page, so every read runs in your own Chrome, signed in as you, through Microsoft's Playwright Extension (needs only the extension: Node.js is fetched automatically when missing; the first read asks you to pick a tab). Nothing is posted or changed. The full ThreadFox kit (finding conversations where people ask for what you sell, posting from your own Chrome, an outcome ledger, a 12-module course) is sold separately at https://threadfox.vip.",
|
|
8
8
|
"author": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "threadfox-lite"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.5"
|
|
4
4
|
description = "Read a subreddit's rules and check whether a Reddit post is still up, from Claude Code, Codex or any MCP client. Free tools from ThreadFox."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.amflimited/threadfox-lite",
|
|
4
4
|
"title": "ThreadFox Lite",
|
|
5
5
|
"description": "Find subreddits you may post in, read their rules, check your account and posts. Free, read-only.",
|
|
6
|
-
"version": "0.2.
|
|
6
|
+
"version": "0.2.5",
|
|
7
7
|
"websiteUrl": "https://threadfox.vip",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/amflimited/threadfox-lite",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "pypi",
|
|
15
15
|
"identifier": "threadfox-lite",
|
|
16
|
-
"version": "0.2.
|
|
16
|
+
"version": "0.2.5",
|
|
17
17
|
"runtimeHint": "uvx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"registryType": "mcpb",
|
|
24
|
-
"identifier": "https://github.com/amflimited/threadfox-lite/releases/download/v0.2.
|
|
24
|
+
"identifier": "https://github.com/amflimited/threadfox-lite/releases/download/v0.2.5/threadfox-lite-0.2.5.mcpb",
|
|
25
25
|
"fileSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
26
26
|
"transport": {
|
|
27
27
|
"type": "stdio"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""ThreadFox Lite: free, read-only Reddit tools for AI agents."""
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.5"
|
|
@@ -11,9 +11,11 @@ import json
|
|
|
11
11
|
import os
|
|
12
12
|
import re
|
|
13
13
|
import subprocess
|
|
14
|
+
import sys
|
|
14
15
|
import threading
|
|
15
16
|
import time
|
|
16
17
|
|
|
18
|
+
from . import process
|
|
17
19
|
from .node import SetupError, ensure_npx, find_npx # noqa: F401 (find_npx and SetupError are part of this module's API)
|
|
18
20
|
|
|
19
21
|
EXTENSION_URL = "https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm"
|
|
@@ -21,6 +23,8 @@ SETUP = ("ThreadFox Lite reads Reddit through your own signed-in Chrome, because
|
|
|
21
23
|
"before it shows anything. One-time setup: add Microsoft's Playwright Extension to Chrome (" + EXTENSION_URL + "), "
|
|
22
24
|
"sign in to Reddit in that Chrome, then try again.")
|
|
23
25
|
SPACING = 1.5 # seconds between reads, so the account is never rate-limited
|
|
26
|
+
IDLE_SECONDS = 600 # the Chrome connection is let go after this long without a read; the next read reconnects
|
|
27
|
+
COOLDOWN_AFTER_429 = 600
|
|
24
28
|
|
|
25
29
|
|
|
26
30
|
class Chrome:
|
|
@@ -34,6 +38,8 @@ class Chrome:
|
|
|
34
38
|
self.tool = None
|
|
35
39
|
self.last = 0.0
|
|
36
40
|
self.lock = threading.Lock()
|
|
41
|
+
self.idle_timer = None
|
|
42
|
+
self.can_close_tab = False
|
|
37
43
|
|
|
38
44
|
def start(self):
|
|
39
45
|
if self.proc and self.proc.poll() is None:
|
|
@@ -44,25 +50,42 @@ class Chrome:
|
|
|
44
50
|
npx = ensure_npx() # downloads the official Node.js once when none is installed; raises SetupError if it cannot
|
|
45
51
|
cmd = [npx, "-y", "@playwright/mcp@latest", "--extension"]
|
|
46
52
|
env["PATH"] = os.path.dirname(npx) + os.pathsep + env.get("PATH", "")
|
|
53
|
+
# Its own process group, ended as a tree; a Windows job or Linux's parent-death signal covers a crash (process.py).
|
|
54
|
+
if os.name == "nt":
|
|
55
|
+
process._windows_job()
|
|
56
|
+
group = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
57
|
+
else:
|
|
58
|
+
process.reap_orphans()
|
|
59
|
+
group = {"preexec_fn": process._die_with_parent} if sys.platform.startswith("linux") else {"start_new_session": True}
|
|
47
60
|
self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
48
|
-
text=True, encoding="utf-8", bufsize=1, env=env)
|
|
61
|
+
text=True, encoding="utf-8", bufsize=1, env=env, **group)
|
|
62
|
+
process._remember_child(self.proc)
|
|
49
63
|
self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
50
64
|
"clientInfo": {"name": "threadfox-lite", "version": "1"}}, 120)
|
|
51
65
|
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
|
|
52
66
|
self.proc.stdin.flush()
|
|
53
67
|
names = [t.get("name") for t in self.request("tools/list", {}).get("tools", [])]
|
|
54
68
|
self.tool = next((n for n in ("browser_run_code_unsafe", "browser_run_code") if n in names), None)
|
|
69
|
+
self.can_close_tab = "browser_tabs" in names
|
|
55
70
|
if not self.tool:
|
|
56
71
|
raise SetupError("This Playwright MCP version cannot run a read; update it (npx -y @playwright/mcp@latest).")
|
|
72
|
+
try:
|
|
73
|
+
self.request("tools/call", {"name": self.tool, "arguments": {"code": process.NO_UNLOAD}}, 30)
|
|
74
|
+
except SetupError:
|
|
75
|
+
pass
|
|
57
76
|
|
|
58
77
|
def request(self, method, params, timeout=None):
|
|
59
78
|
self.n += 1
|
|
60
|
-
mid, box = self.n, {}
|
|
61
|
-
|
|
62
|
-
|
|
79
|
+
mid, box, proc = self.n, {}, self.proc
|
|
80
|
+
try:
|
|
81
|
+
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": mid, "method": method, "params": params}) + "\n")
|
|
82
|
+
proc.stdin.flush()
|
|
83
|
+
except (OSError, ValueError, AttributeError):
|
|
84
|
+
self.close(tidy=False)
|
|
85
|
+
raise SetupError("The Chrome connection had closed. Try again: ThreadFox Lite reconnects on the next read.")
|
|
63
86
|
|
|
64
87
|
def read():
|
|
65
|
-
for line in
|
|
88
|
+
for line in proc.stdout:
|
|
66
89
|
try:
|
|
67
90
|
msg = json.loads(line)
|
|
68
91
|
except ValueError:
|
|
@@ -70,11 +93,15 @@ class Chrome:
|
|
|
70
93
|
if msg.get("id") == mid:
|
|
71
94
|
box["msg"] = msg
|
|
72
95
|
return
|
|
96
|
+
box["eof"] = True # the child exited: say so now instead of waiting out the timeout
|
|
73
97
|
reader = threading.Thread(target=read, daemon=True)
|
|
74
98
|
reader.start()
|
|
75
99
|
reader.join(timeout or self.timeout)
|
|
76
100
|
if "msg" not in box:
|
|
77
101
|
self.close(tidy=False)
|
|
102
|
+
if box.get("eof"):
|
|
103
|
+
raise SetupError("The Chrome connection closed during the read (Chrome or the Playwright Extension disconnected). "
|
|
104
|
+
"Try again: ThreadFox Lite reconnects on the next read.")
|
|
78
105
|
raise SetupError("Chrome did not answer. Open Chrome, click the Playwright Extension and pick a tab when it "
|
|
79
106
|
"asks to connect, then try again. " + SETUP)
|
|
80
107
|
if "error" in box["msg"]:
|
|
@@ -91,15 +118,21 @@ class Chrome:
|
|
|
91
118
|
}, %s);
|
|
92
119
|
return JSON.stringify(out);
|
|
93
120
|
}""" % json.dumps(path)
|
|
94
|
-
with self.lock:
|
|
95
|
-
|
|
121
|
+
with self.lock, process.MachineLock() as machine:
|
|
122
|
+
shared = machine.state()
|
|
123
|
+
if time.time() < (shared.get("cooldown_until") or 0):
|
|
124
|
+
raise SetupError("Reddit asked to slow down; no Reddit reads until "
|
|
125
|
+
+ time.strftime("%H:%M UTC", time.gmtime(shared["cooldown_until"])) + ".")
|
|
126
|
+
wait = max(self.last, shared.get("last_call") or 0) + SPACING - time.time()
|
|
96
127
|
if wait > 0:
|
|
97
|
-
time.sleep(wait)
|
|
128
|
+
time.sleep(min(wait, SPACING))
|
|
98
129
|
self.start()
|
|
99
130
|
try:
|
|
100
131
|
result = self.request("tools/call", {"name": self.tool, "arguments": {"code": code}})
|
|
101
132
|
finally:
|
|
102
133
|
self.last = time.time()
|
|
134
|
+
machine.update(last_call=self.last)
|
|
135
|
+
self.arm_idle()
|
|
103
136
|
text = "\n".join(c.get("text", "") for c in result.get("content", []) if c.get("type") == "text")
|
|
104
137
|
if result.get("isError") or "### Error" in text:
|
|
105
138
|
message = re.sub(r"^#+\s*Error\s*", "", text).strip()
|
|
@@ -108,22 +141,40 @@ class Chrome:
|
|
|
108
141
|
if not m:
|
|
109
142
|
raise SetupError("No answer came back from Chrome: " + text[:200])
|
|
110
143
|
out = json.loads(json.loads(m.group(1)))
|
|
144
|
+
if out.get("status") == 429:
|
|
145
|
+
process._share_cooldown(time.time() + COOLDOWN_AFTER_429) # every ThreadFox app on this computer waits
|
|
111
146
|
return out.get("status"), out.get("body")
|
|
112
147
|
|
|
148
|
+
def arm_idle(self):
|
|
149
|
+
if self.idle_timer:
|
|
150
|
+
self.idle_timer.cancel()
|
|
151
|
+
self.idle_timer = threading.Timer(IDLE_SECONDS, self.idle_close)
|
|
152
|
+
self.idle_timer.daemon = True
|
|
153
|
+
self.idle_timer.start()
|
|
154
|
+
|
|
155
|
+
def idle_close(self):
|
|
156
|
+
if self.lock.acquire(blocking=False): # never in the middle of a read
|
|
157
|
+
try:
|
|
158
|
+
self.close()
|
|
159
|
+
finally:
|
|
160
|
+
self.lock.release()
|
|
161
|
+
|
|
113
162
|
def close(self, tidy=True):
|
|
114
|
-
"""Close this session's tab in Chrome, then end the Playwright
|
|
163
|
+
"""Close this session's tab in Chrome (after a blank page, so nothing can hold it), then end the Playwright tree."""
|
|
164
|
+
if self.idle_timer:
|
|
165
|
+
self.idle_timer.cancel(); self.idle_timer = None
|
|
115
166
|
if not self.proc:
|
|
116
167
|
return
|
|
117
|
-
if tidy and self.proc.poll() is None:
|
|
168
|
+
if tidy and self.proc.poll() is None and self.tool:
|
|
118
169
|
try:
|
|
119
|
-
self.request("tools/call", {"name":
|
|
170
|
+
self.request("tools/call", {"name": self.tool, "arguments": {"code": process.LEAVE}}, timeout=10)
|
|
171
|
+
if self.can_close_tab:
|
|
172
|
+
self.request("tools/call", {"name": "browser_tabs", "arguments": {"action": "close"}}, timeout=10)
|
|
120
173
|
except Exception:
|
|
121
174
|
pass
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
pass
|
|
126
|
-
self.proc = None
|
|
175
|
+
proc, self.proc = self.proc, None
|
|
176
|
+
process.kill_tree(proc)
|
|
177
|
+
process._forget_child(proc.pid)
|
|
127
178
|
|
|
128
179
|
|
|
129
180
|
_chrome = None
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Staying light on the computer, shared with the full ThreadFox kit (same files in ~/.threadfox, so both coordinate).
|
|
2
|
+
|
|
3
|
+
- One Reddit call at a time on this computer across every app running ThreadFox or ThreadFox Lite, with shared pacing
|
|
4
|
+
and a shared 429 cooldown (browser.lock and browser-state.json in THREADFOX_HOME or ~/.threadfox).
|
|
5
|
+
- The Playwright child runs in its own process group and is ended as a tree. If ThreadFox Lite is killed first, a
|
|
6
|
+
Windows job ends the tree, Linux sends the child SIGTERM, and the next start reaps what is left.
|
|
7
|
+
- The tab never registers "Leave site?", and goes to a blank page before it closes.
|
|
8
|
+
Copied from the kit's threadfox_browser.py; keep the two in step.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from .node import SetupError
|
|
17
|
+
|
|
18
|
+
MACHINE_LOCK_WAIT = 120
|
|
19
|
+
CLIENT = 'ThreadFox Lite'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def threadfox_home():
|
|
23
|
+
return os.environ.get('THREADFOX_HOME') or os.path.join(os.path.expanduser('~'), '.threadfox')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MachineLock:
|
|
27
|
+
"""One Reddit call at a time on this computer, across every app running ThreadFox. The state beside the lock carries
|
|
28
|
+
the pacing and any rate-limit cooldown, so two apps cannot together read faster than one, or ignore a 429 the other got."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, wait=None):
|
|
31
|
+
self.wait = MACHINE_LOCK_WAIT if wait is None else wait
|
|
32
|
+
self.home = threadfox_home()
|
|
33
|
+
self.file = None
|
|
34
|
+
|
|
35
|
+
def _try(self):
|
|
36
|
+
if os.name == 'nt':
|
|
37
|
+
import msvcrt
|
|
38
|
+
self.file.seek(0)
|
|
39
|
+
msvcrt.locking(self.file.fileno(), msvcrt.LK_NBLCK, 1)
|
|
40
|
+
else:
|
|
41
|
+
import fcntl
|
|
42
|
+
fcntl.flock(self.file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
43
|
+
|
|
44
|
+
def __enter__(self):
|
|
45
|
+
os.makedirs(self.home, exist_ok=True)
|
|
46
|
+
self.file = open(os.path.join(self.home, 'browser.lock'), 'a+')
|
|
47
|
+
if os.name == 'nt' and os.path.getsize(self.file.name) == 0:
|
|
48
|
+
self.file.write('0'); self.file.flush()
|
|
49
|
+
deadline = time.time() + self.wait
|
|
50
|
+
while True:
|
|
51
|
+
try:
|
|
52
|
+
self._try()
|
|
53
|
+
break
|
|
54
|
+
except OSError:
|
|
55
|
+
if time.time() >= deadline:
|
|
56
|
+
holder = self.state().get('holder') or {}
|
|
57
|
+
self.file.close(); self.file = None
|
|
58
|
+
raise SetupError('Another app on this computer is using ThreadFox\'s Chrome connection'
|
|
59
|
+
+ (f" ({holder.get('client')}, since {holder.get('since')})" if holder.get('client') else '')
|
|
60
|
+
+ f'. ThreadFox waited {self.wait} seconds for its turn; try again in a moment.')
|
|
61
|
+
time.sleep(0.25)
|
|
62
|
+
self.update(holder={'client': CLIENT, 'pid': os.getpid(), 'since': time.strftime('%H:%M:%S')})
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(self, *exc):
|
|
66
|
+
try:
|
|
67
|
+
self.update(holder=None)
|
|
68
|
+
if os.name == 'nt':
|
|
69
|
+
import msvcrt
|
|
70
|
+
self.file.seek(0)
|
|
71
|
+
msvcrt.locking(self.file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
72
|
+
else:
|
|
73
|
+
import fcntl
|
|
74
|
+
fcntl.flock(self.file, fcntl.LOCK_UN)
|
|
75
|
+
finally:
|
|
76
|
+
self.file.close(); self.file = None
|
|
77
|
+
|
|
78
|
+
def state(self):
|
|
79
|
+
try:
|
|
80
|
+
with open(os.path.join(self.home, 'browser-state.json'), encoding='utf-8') as f:
|
|
81
|
+
return json.load(f)
|
|
82
|
+
except (OSError, ValueError):
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
def update(self, **changes):
|
|
86
|
+
data = dict(self.state(), **changes)
|
|
87
|
+
os.makedirs(self.home, exist_ok=True) # a fresh computer has no ThreadFox folder until the first write
|
|
88
|
+
path = os.path.join(self.home, 'browser-state.json')
|
|
89
|
+
with open(path + '.tmp', 'w', encoding='utf-8') as f:
|
|
90
|
+
json.dump(data, f)
|
|
91
|
+
os.replace(path + '.tmp', path)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _share_cooldown(until):
|
|
95
|
+
"""Tell every app on this computer about a 429, so none of them calls Reddit before it ends."""
|
|
96
|
+
try:
|
|
97
|
+
MachineLock().update(cooldown_until=until)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_JOB = None
|
|
103
|
+
JOB_STATUS = 'not tried' # threadfox_doctor and the tests read why the job did or did not attach
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _windows_job():
|
|
107
|
+
"""Put this ThreadFox process in a Windows job that kills everything in it when its last handle closes. Children
|
|
108
|
+
inherit the job, so when ThreadFox exits in any way (the AI app closing it, a crash, Task Manager) Windows ends the
|
|
109
|
+
Playwright child, Node.js and anything they started. Best effort: without it, close() still ends the tree."""
|
|
110
|
+
global _JOB, JOB_STATUS
|
|
111
|
+
if os.name != 'nt' or _JOB is not None:
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
import ctypes
|
|
115
|
+
from ctypes import wintypes
|
|
116
|
+
k32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
117
|
+
|
|
118
|
+
class IO(ctypes.Structure):
|
|
119
|
+
_fields_ = [(n, ctypes.c_ulonglong) for n in ('r', 'w', 'o', 'rb', 'wb', 'ob')]
|
|
120
|
+
|
|
121
|
+
class BASIC(ctypes.Structure):
|
|
122
|
+
_fields_ = [('PerProcessUserTimeLimit', ctypes.c_longlong), ('PerJobUserTimeLimit', ctypes.c_longlong),
|
|
123
|
+
('LimitFlags', wintypes.DWORD), ('MinimumWorkingSetSize', ctypes.c_size_t),
|
|
124
|
+
('MaximumWorkingSetSize', ctypes.c_size_t), ('ActiveProcessLimit', wintypes.DWORD),
|
|
125
|
+
('Affinity', ctypes.c_size_t), ('PriorityClass', wintypes.DWORD), ('SchedulingClass', wintypes.DWORD)]
|
|
126
|
+
|
|
127
|
+
class EXTENDED(ctypes.Structure):
|
|
128
|
+
_fields_ = [('BasicLimitInformation', BASIC), ('IoInfo', IO), ('ProcessMemoryLimit', ctypes.c_size_t),
|
|
129
|
+
('JobMemoryLimit', ctypes.c_size_t), ('PeakProcessMemoryUsed', ctypes.c_size_t),
|
|
130
|
+
('PeakJobMemoryUsed', ctypes.c_size_t)]
|
|
131
|
+
|
|
132
|
+
# Explicit signatures: without them ctypes cannot pass the current-process pseudo-handle (-1) and the call fails.
|
|
133
|
+
k32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
|
134
|
+
k32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
135
|
+
k32.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD]
|
|
136
|
+
k32.SetInformationJobObject.restype = wintypes.BOOL
|
|
137
|
+
k32.GetCurrentProcess.argtypes = []
|
|
138
|
+
k32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
139
|
+
k32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
|
140
|
+
k32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
141
|
+
job = k32.CreateJobObjectW(None, None)
|
|
142
|
+
if not job:
|
|
143
|
+
JOB_STATUS = f'CreateJobObject failed ({ctypes.get_last_error()})'
|
|
144
|
+
return
|
|
145
|
+
info = EXTENDED()
|
|
146
|
+
info.BasicLimitInformation.LimitFlags = 0x2000 | 0x800 # KILL_ON_JOB_CLOSE | BREAKAWAY_OK
|
|
147
|
+
if not k32.SetInformationJobObject(job, 9, ctypes.byref(info), ctypes.sizeof(info)):
|
|
148
|
+
JOB_STATUS = f'SetInformationJobObject failed ({ctypes.get_last_error()})'
|
|
149
|
+
return
|
|
150
|
+
if not k32.AssignProcessToJobObject(job, k32.GetCurrentProcess()):
|
|
151
|
+
JOB_STATUS = f'AssignProcessToJobObject failed ({ctypes.get_last_error()})'
|
|
152
|
+
return
|
|
153
|
+
_JOB = job # kept open for this process's lifetime; it closes, and the job ends, when ThreadFox does
|
|
154
|
+
JOB_STATUS = 'attached'
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
JOB_STATUS = f'unavailable ({exc})'
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _die_with_parent():
|
|
160
|
+
"""Linux: the Playwright child gets SIGTERM if ThreadFox is killed without a chance to close it."""
|
|
161
|
+
os.setsid()
|
|
162
|
+
try:
|
|
163
|
+
import ctypes, signal
|
|
164
|
+
ctypes.CDLL('libc.so.6', use_errno=True).prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _group_members(pgid):
|
|
170
|
+
try:
|
|
171
|
+
out = subprocess.run(['ps', '-A', '-o', 'pid=,pgid='], capture_output=True, text=True, timeout=5).stdout
|
|
172
|
+
except (OSError, subprocess.SubprocessError):
|
|
173
|
+
return []
|
|
174
|
+
rows = [line.split() for line in out.splitlines() if len(line.split()) == 2]
|
|
175
|
+
return [int(pid) for pid, group in rows if group.isdigit() and int(group) == pgid]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def reap_orphans():
|
|
179
|
+
"""End Playwright processes left by a ThreadFox that died without closing them (POSIX; Windows' job does this).
|
|
180
|
+
Each child ThreadFox starts leads its own process group. When that ThreadFox is gone: a leader still running with
|
|
181
|
+
ThreadFox's command is ended with its group, and a group whose leader already exited (Linux ends the leader with
|
|
182
|
+
its parent) but still has members is those members' group, since only that leader could have created it.
|
|
183
|
+
Records older than a day are dropped unused."""
|
|
184
|
+
if os.name == 'nt':
|
|
185
|
+
return
|
|
186
|
+
import signal
|
|
187
|
+
state = MachineLock().state()
|
|
188
|
+
kept = []
|
|
189
|
+
for entry in state.get('children') or []:
|
|
190
|
+
try:
|
|
191
|
+
os.kill(entry['parent'], 0)
|
|
192
|
+
kept.append(entry) # its ThreadFox is still running
|
|
193
|
+
continue
|
|
194
|
+
except OSError:
|
|
195
|
+
pass
|
|
196
|
+
if time.time() - (entry.get('at') or 0) > 86400:
|
|
197
|
+
continue
|
|
198
|
+
pid = entry['pid']
|
|
199
|
+
try:
|
|
200
|
+
os.kill(pid, 0)
|
|
201
|
+
leader_alive = True
|
|
202
|
+
except OSError:
|
|
203
|
+
leader_alive = False
|
|
204
|
+
if leader_alive:
|
|
205
|
+
try:
|
|
206
|
+
command = subprocess.run(['ps', '-o', 'command=', '-p', str(pid)], capture_output=True, text=True, timeout=5).stdout
|
|
207
|
+
except (OSError, subprocess.SubprocessError):
|
|
208
|
+
command = ''
|
|
209
|
+
ours = [t for t in entry.get('cmd') or [] if t] or ['playwright', 'npx']
|
|
210
|
+
if not any(t.lower() in command.lower() for t in ours):
|
|
211
|
+
continue # the pid now belongs to something else: leave it
|
|
212
|
+
elif not _group_members(pid):
|
|
213
|
+
continue # nothing left of that group
|
|
214
|
+
try:
|
|
215
|
+
os.killpg(pid, signal.SIGTERM)
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
try:
|
|
219
|
+
MachineLock().update(children=kept)
|
|
220
|
+
except OSError:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _remember_child(proc):
|
|
225
|
+
try:
|
|
226
|
+
state = MachineLock().state()
|
|
227
|
+
children = [c for c in state.get('children') or [] if c.get('pid') != proc.pid][-20:]
|
|
228
|
+
tokens = [os.path.basename(str(x)) for x in (proc.args[:2] if isinstance(proc.args, list) else [proc.args])]
|
|
229
|
+
MachineLock().update(children=children + [{'pid': proc.pid, 'parent': os.getpid(), 'at': int(time.time()), 'cmd': tokens}])
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _forget_child(pid):
|
|
235
|
+
try:
|
|
236
|
+
state = MachineLock().state()
|
|
237
|
+
MachineLock().update(children=[c for c in state.get('children') or [] if c.get('pid') != pid])
|
|
238
|
+
except OSError:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def busy_holder():
|
|
243
|
+
"""The app holding the Chrome connection right now, or None (threadfox_doctor reports it)."""
|
|
244
|
+
try:
|
|
245
|
+
with MachineLock(wait=0):
|
|
246
|
+
return None
|
|
247
|
+
except SetupError:
|
|
248
|
+
return MachineLock().state().get('holder') or {'client': 'another app'}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def kill_tree(proc):
|
|
252
|
+
"""End the Playwright child and everything it started. On Windows npx.cmd starts node, which starts Playwright MCP:
|
|
253
|
+
killing only the first left Node.js processes running in the background after every session."""
|
|
254
|
+
if proc is None or proc.poll() is not None:
|
|
255
|
+
return
|
|
256
|
+
try:
|
|
257
|
+
if os.name == 'nt':
|
|
258
|
+
subprocess.run(['taskkill', '/PID', str(proc.pid), '/T', '/F'], capture_output=True, timeout=15)
|
|
259
|
+
else:
|
|
260
|
+
import signal
|
|
261
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
262
|
+
try:
|
|
263
|
+
proc.wait(timeout=3)
|
|
264
|
+
except subprocess.TimeoutExpired:
|
|
265
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
266
|
+
except (OSError, subprocess.SubprocessError):
|
|
267
|
+
pass
|
|
268
|
+
try:
|
|
269
|
+
proc.kill()
|
|
270
|
+
proc.wait(timeout=5)
|
|
271
|
+
except (OSError, subprocess.SubprocessError):
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ThreadFox's own tab never registers "Leave site?": an unsent draft would otherwise hold the tab with a modal dialog
|
|
276
|
+
# that blocks navigation and closing. Reddit's own checks are untouched.
|
|
277
|
+
NO_UNLOAD = """async (page) => {
|
|
278
|
+
await page.addInitScript(() => {
|
|
279
|
+
const add = EventTarget.prototype.addEventListener;
|
|
280
|
+
EventTarget.prototype.addEventListener = function (type, fn, opts) { if (type === 'beforeunload') return; return add.call(this, type, fn, opts); };
|
|
281
|
+
Object.defineProperty(window, 'onbeforeunload', {configurable: true, get() { return null; }, set() {}});
|
|
282
|
+
});
|
|
283
|
+
return JSON.stringify({ok: true});
|
|
284
|
+
}"""
|
|
285
|
+
LEAVE = """async (page) => {
|
|
286
|
+
await page.goto('about:blank').catch(() => {});
|
|
287
|
+
return JSON.stringify({left: page.url()});
|
|
288
|
+
}"""
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""ThreadFox Lite stays light on the computer and coordinates with the full kit through the same files."""
|
|
2
|
+
import json, os, subprocess, sys, tempfile, textwrap, time, unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
6
|
+
from threadfox_lite import browser, process
|
|
7
|
+
|
|
8
|
+
FAKE = textwrap.dedent('''
|
|
9
|
+
import json, subprocess, sys
|
|
10
|
+
mode = sys.argv[1]
|
|
11
|
+
if mode == 'grandchild':
|
|
12
|
+
kid = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])
|
|
13
|
+
open(sys.argv[2], 'w').write(str(kid.pid))
|
|
14
|
+
for line in sys.stdin:
|
|
15
|
+
msg = json.loads(line)
|
|
16
|
+
if 'id' not in msg: continue
|
|
17
|
+
if msg['method'] == 'initialize': out = {'protocolVersion': '2025-06-18', 'capabilities': {}}
|
|
18
|
+
elif msg['method'] == 'tools/list': out = {'tools': [{'name': 'browser_run_code_unsafe'}, {'name': 'browser_tabs'}]}
|
|
19
|
+
elif mode == 'dies' and 'threadfox-die' in json.dumps(msg): sys.exit(0)
|
|
20
|
+
elif mode == 'limited' and 'fetch(path' in json.dumps(msg):
|
|
21
|
+
out = {'content': [{'type': 'text', 'text': '### Result\\n' + json.dumps(json.dumps({'status': 429, 'body': None})) + '\\n'}]}
|
|
22
|
+
else: out = {'content': [{'type': 'text', 'text': '### Result\\n' + json.dumps(json.dumps({'status': 200, 'body': {'ok': 1}})) + '\\n'}]}
|
|
23
|
+
sys.stdout.write(json.dumps({'jsonrpc': '2.0', 'id': msg['id'], 'result': out}) + '\\n'); sys.stdout.flush()
|
|
24
|
+
''')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Light(unittest.TestCase):
|
|
28
|
+
def setUp(self):
|
|
29
|
+
self.tmp = tempfile.TemporaryDirectory(); self.root = Path(self.tmp.name)
|
|
30
|
+
for p in (patch.dict(os.environ, {"THREADFOX_HOME": str(self.root / "home")}), patch.object(browser, "SPACING", 0)):
|
|
31
|
+
p.start(); self.addCleanup(p.stop)
|
|
32
|
+
(self.root / "fake.py").write_text(FAKE)
|
|
33
|
+
|
|
34
|
+
def tearDown(self):
|
|
35
|
+
self.tmp.cleanup()
|
|
36
|
+
|
|
37
|
+
def chrome(self, *args):
|
|
38
|
+
return browser.Chrome([sys.executable, str(self.root / "fake.py"), *args], timeout=20)
|
|
39
|
+
|
|
40
|
+
def test_it_uses_the_same_lock_and_state_files_as_the_full_kit(self):
|
|
41
|
+
lock = process.MachineLock()
|
|
42
|
+
self.assertEqual(Path(lock.home), self.root / "home")
|
|
43
|
+
with lock:
|
|
44
|
+
pass
|
|
45
|
+
self.assertTrue((self.root / "home" / "browser.lock").exists())
|
|
46
|
+
self.assertTrue((self.root / "home" / "browser-state.json").exists())
|
|
47
|
+
|
|
48
|
+
def test_a_429_is_shared_with_every_threadfox_app_on_the_computer(self):
|
|
49
|
+
c = self.chrome("limited")
|
|
50
|
+
self.assertEqual(c.get("/r/x/about.json")[0], 429)
|
|
51
|
+
self.assertGreater(process.MachineLock().state()["cooldown_until"], time.time() + 500)
|
|
52
|
+
with self.assertRaisesRegex(browser.SetupError, "slow down"):
|
|
53
|
+
self.chrome("ok").get("/r/x/about.json")
|
|
54
|
+
c.close()
|
|
55
|
+
|
|
56
|
+
def test_a_dead_browser_is_reported_at_once_and_the_next_read_reconnects(self):
|
|
57
|
+
c = self.chrome("dies")
|
|
58
|
+
started = time.time()
|
|
59
|
+
c.start()
|
|
60
|
+
with self.assertRaisesRegex(browser.SetupError, "closed during the read"):
|
|
61
|
+
c.request("tools/call", {"name": "x", "arguments": {"code": "threadfox-die"}})
|
|
62
|
+
self.assertLess(time.time() - started, 5)
|
|
63
|
+
c.command = [sys.executable, str(self.root / "fake.py"), "ok"]
|
|
64
|
+
self.assertEqual(c.get("/r/x/about.json"), (200, {"ok": 1}))
|
|
65
|
+
c.close()
|
|
66
|
+
|
|
67
|
+
@unittest.skipIf(os.name == "nt", "POSIX process groups; Windows uses a job object")
|
|
68
|
+
def test_closing_ends_the_whole_process_tree(self):
|
|
69
|
+
pidfile = self.root / "gc.pid"
|
|
70
|
+
c = self.chrome("grandchild", str(pidfile))
|
|
71
|
+
c.get("/r/x/about.json")
|
|
72
|
+
grandchild = int(pidfile.read_text())
|
|
73
|
+
c.close()
|
|
74
|
+
for _ in range(50):
|
|
75
|
+
try:
|
|
76
|
+
os.kill(grandchild, 0); time.sleep(0.1)
|
|
77
|
+
except OSError:
|
|
78
|
+
break
|
|
79
|
+
else:
|
|
80
|
+
self.fail("the grandchild outlived close()")
|
|
81
|
+
|
|
82
|
+
def test_an_idle_connection_is_let_go(self):
|
|
83
|
+
with patch.object(browser, "IDLE_SECONDS", 0.3):
|
|
84
|
+
c = self.chrome("ok")
|
|
85
|
+
c.get("/r/x/about.json")
|
|
86
|
+
time.sleep(1.0)
|
|
87
|
+
self.assertIsNone(c.proc)
|
|
88
|
+
c.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|