threadfox-lite 0.2.3__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.
Files changed (25) hide show
  1. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/.claude-plugin/marketplace.json +1 -1
  2. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/.claude-plugin/plugin.json +1 -1
  3. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/LAUNCHGUIDE.md +1 -1
  4. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/PKG-INFO +2 -2
  5. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/README.md +1 -1
  6. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/manifest.json +2 -2
  7. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/pyproject.toml +1 -1
  8. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/server.json +3 -3
  9. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/src/threadfox_lite/__init__.py +1 -1
  10. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/src/threadfox_lite/browser.py +75 -42
  11. threadfox_lite-0.2.5/src/threadfox_lite/node.py +187 -0
  12. threadfox_lite-0.2.5/src/threadfox_lite/process.py +288 -0
  13. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/src/threadfox_lite/server.py +14 -2
  14. threadfox_lite-0.2.5/tests/test_node.py +57 -0
  15. threadfox_lite-0.2.5/tests/test_process.py +92 -0
  16. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/tests/test_reddit.py +1 -1
  17. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/uv.lock +1 -1
  18. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/.github/workflows/publish.yml +0 -0
  19. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/.gitignore +0 -0
  20. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/.mcpbignore +0 -0
  21. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/LICENSE +0 -0
  22. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/glama.json +0 -0
  23. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/icon.png +0 -0
  24. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/skills/reddit-rules-first/SKILL.md +0 -0
  25. {threadfox_lite-0.2.3 → threadfox_lite-0.2.5}/src/threadfox_lite/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.3",
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",
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)",
@@ -11,7 +11,7 @@ This server (ThreadFox Lite) runs locally and gives every user four free, read-o
11
11
  Built from our own run: 978,227 recorded views from two days of posts (Sept 20-21 UTC), with no ad spend. That is our result, not a promise about yours. Made by AMF Indiana; not affiliated with Reddit, OpenAI or Anthropic.
12
12
 
13
13
  ## Setup Requirements
14
- MCP_LICENSE_KEY (optional): your MCP Marketplace license key. The read-only tools work without it. Requires uv (uvx), Python 3.10+, Node.js and Microsoft's Playwright Extension in the Chrome where you are signed in to Reddit.
14
+ MCP_LICENSE_KEY (optional): your MCP Marketplace license key. The read-only tools work without it. Requires uv (uvx), Python 3.10+ and Microsoft's Playwright Extension in the Chrome where you are signed in to Reddit.
15
15
 
16
16
  ## Category
17
17
  Marketing
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: threadfox-lite
3
- Version: 0.2.3
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: [Node.js](https://nodejs.org) and Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. 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: [Node.js](https://nodejs.org) and Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. 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,9 +2,9 @@
2
2
  "manifest_version": "0.4",
3
3
  "name": "threadfox-lite",
4
4
  "display_name": "ThreadFox Lite",
5
- "version": "0.2.3",
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
- "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 Node.js and the extension; 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.",
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": {
9
9
  "name": "AMF Indiana (ThreadFox)",
10
10
  "email": "support@threadfox.vip",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "threadfox-lite"
3
- version = "0.2.3"
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.3",
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.3",
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.3/threadfox-lite-0.2.3.mcpb",
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.3"
2
+ __version__ = "0.2.5"
@@ -2,41 +2,29 @@
2
2
 
3
3
  Reddit now sends every signed-out request, including its public JSON, to a login page. So ThreadFox Lite reads
4
4
  the way you do: in your Chrome, signed in as you. It starts Microsoft's Playwright MCP server in extension mode
5
- (`npx @playwright/mcp --extension`, needs Node.js and the Playwright Extension in Chrome) and runs one small
6
- fetch of Reddit's JSON inside an old.reddit.com tab per read. Nothing is posted, voted or changed.
5
+ (`npx @playwright/mcp --extension`; node.py fetches Node.js when none is installed, and you add the Playwright
6
+ Extension to Chrome) and runs one small fetch of Reddit's JSON inside an old.reddit.com tab per read. Nothing is
7
+ posted, voted or changed.
7
8
  """
8
9
  import atexit
9
- import glob
10
10
  import json
11
11
  import os
12
12
  import re
13
- import shutil
14
13
  import subprocess
14
+ import sys
15
15
  import threading
16
16
  import time
17
17
 
18
+ from . import process
19
+ from .node import SetupError, ensure_npx, find_npx # noqa: F401 (find_npx and SetupError are part of this module's API)
20
+
18
21
  EXTENSION_URL = "https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm"
19
22
  SETUP = ("ThreadFox Lite reads Reddit through your own signed-in Chrome, because Reddit now asks everyone to sign in "
20
- "before it shows anything. One-time setup: install Node.js (https://nodejs.org), add Microsoft's Playwright "
21
- "Extension to Chrome (" + EXTENSION_URL + "), sign in to Reddit in that Chrome, then try again.")
23
+ "before it shows anything. One-time setup: add Microsoft's Playwright Extension to Chrome (" + EXTENSION_URL + "), "
24
+ "sign in to Reddit in that Chrome, then try again.")
22
25
  SPACING = 1.5 # seconds between reads, so the account is never rate-limited
23
-
24
-
25
- class SetupError(RuntimeError):
26
- """The browser route is not ready; the message says what to do."""
27
-
28
-
29
- def find_npx():
30
- """npx on PATH, or in the usual Node.js folders (apps started from the Dock or Start menu get a short PATH)."""
31
- found = shutil.which("npx")
32
- if found:
33
- return found
34
- home = os.path.expanduser("~")
35
- candidates = ["/usr/local/bin/npx", "/opt/homebrew/bin/npx", os.path.join(home, ".volta", "bin", "npx")]
36
- candidates += sorted(glob.glob(os.path.join(home, ".nvm", "versions", "node", "*", "bin", "npx")), reverse=True)
37
- for base in filter(None, [os.environ.get("ProgramFiles"), os.environ.get("APPDATA")]):
38
- candidates += [os.path.join(base, "nodejs", "npx.cmd"), os.path.join(base, "npm", "npx.cmd")]
39
- return next((c for c in candidates if os.path.isfile(c)), None)
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
40
28
 
41
29
 
42
30
  class Chrome:
@@ -50,6 +38,8 @@ class Chrome:
50
38
  self.tool = None
51
39
  self.last = 0.0
52
40
  self.lock = threading.Lock()
41
+ self.idle_timer = None
42
+ self.can_close_tab = False
53
43
 
54
44
  def start(self):
55
45
  if self.proc and self.proc.poll() is None:
@@ -57,30 +47,45 @@ class Chrome:
57
47
  cmd = list(self.command or [])
58
48
  env = dict(os.environ)
59
49
  if not cmd:
60
- npx = find_npx()
61
- if not npx:
62
- raise SetupError(SETUP)
50
+ npx = ensure_npx() # downloads the official Node.js once when none is installed; raises SetupError if it cannot
63
51
  cmd = [npx, "-y", "@playwright/mcp@latest", "--extension"]
64
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}
65
60
  self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
66
- 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)
67
63
  self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
68
64
  "clientInfo": {"name": "threadfox-lite", "version": "1"}}, 120)
69
65
  self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
70
66
  self.proc.stdin.flush()
71
67
  names = [t.get("name") for t in self.request("tools/list", {}).get("tools", [])]
72
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
73
70
  if not self.tool:
74
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
75
76
 
76
77
  def request(self, method, params, timeout=None):
77
78
  self.n += 1
78
- mid, box = self.n, {}
79
- self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": mid, "method": method, "params": params}) + "\n")
80
- self.proc.stdin.flush()
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.")
81
86
 
82
87
  def read():
83
- for line in self.proc.stdout:
88
+ for line in proc.stdout:
84
89
  try:
85
90
  msg = json.loads(line)
86
91
  except ValueError:
@@ -88,11 +93,15 @@ class Chrome:
88
93
  if msg.get("id") == mid:
89
94
  box["msg"] = msg
90
95
  return
96
+ box["eof"] = True # the child exited: say so now instead of waiting out the timeout
91
97
  reader = threading.Thread(target=read, daemon=True)
92
98
  reader.start()
93
99
  reader.join(timeout or self.timeout)
94
100
  if "msg" not in box:
95
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.")
96
105
  raise SetupError("Chrome did not answer. Open Chrome, click the Playwright Extension and pick a tab when it "
97
106
  "asks to connect, then try again. " + SETUP)
98
107
  if "error" in box["msg"]:
@@ -109,15 +118,21 @@ class Chrome:
109
118
  }, %s);
110
119
  return JSON.stringify(out);
111
120
  }""" % json.dumps(path)
112
- with self.lock:
113
- wait = self.last + SPACING - time.time()
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()
114
127
  if wait > 0:
115
- time.sleep(wait)
128
+ time.sleep(min(wait, SPACING))
116
129
  self.start()
117
130
  try:
118
131
  result = self.request("tools/call", {"name": self.tool, "arguments": {"code": code}})
119
132
  finally:
120
133
  self.last = time.time()
134
+ machine.update(last_call=self.last)
135
+ self.arm_idle()
121
136
  text = "\n".join(c.get("text", "") for c in result.get("content", []) if c.get("type") == "text")
122
137
  if result.get("isError") or "### Error" in text:
123
138
  message = re.sub(r"^#+\s*Error\s*", "", text).strip()
@@ -126,22 +141,40 @@ class Chrome:
126
141
  if not m:
127
142
  raise SetupError("No answer came back from Chrome: " + text[:200])
128
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
129
146
  return out.get("status"), out.get("body")
130
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
+
131
162
  def close(self, tidy=True):
132
- """Close this session's tab in Chrome, then end the Playwright process."""
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
133
166
  if not self.proc:
134
167
  return
135
- if tidy and self.proc.poll() is None:
168
+ if tidy and self.proc.poll() is None and self.tool:
136
169
  try:
137
- self.request("tools/call", {"name": "browser_tabs", "arguments": {"action": "close"}}, timeout=10)
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)
138
173
  except Exception:
139
174
  pass
140
- try:
141
- self.proc.kill()
142
- except Exception:
143
- pass
144
- self.proc = None
175
+ proc, self.proc = self.proc, None
176
+ process.kill_tree(proc)
177
+ process._forget_child(proc.pid)
145
178
 
146
179
 
147
180
  _chrome = None
@@ -0,0 +1,187 @@
1
+ """Node.js for Playwright MCP, without asking the user to install it.
2
+
3
+ ThreadFox Lite's reads run Microsoft's Playwright MCP (`npx @playwright/mcp`), which needs Node.js 18+. When no usable
4
+ Node.js is installed, the official Node.js LTS is downloaded into ~/.threadfox/runtime, checked against the SHA-256
5
+ nodejs.org publishes (pinned below) and used from there. Nothing is installed system-wide. The full ThreadFox kit uses
6
+ the same folder, so the two share one copy.
7
+ """
8
+ import glob
9
+ import hashlib
10
+ import os
11
+ import platform
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import tarfile
16
+ import tempfile
17
+ import threading
18
+ import urllib.request
19
+ import zipfile
20
+
21
+
22
+ class SetupError(RuntimeError):
23
+ """The browser route is not ready; the message says what to do."""
24
+
25
+
26
+ # The official Node.js LTS, fetched only when no Node.js 18+ is installed. SHA-256 values are copied from
27
+ # https://nodejs.org/dist/v24.21.0/SHASUMS256.txt; a download that does not match is deleted unrun.
28
+ NODE_VERSION = '24.21.0'
29
+ NODE_SHA256 = {
30
+ 'darwin-arm64.tar.xz': '6239d4cf92d864487ec8cd3615038f7b67e7f58b77b21cd2f09ea9fbd68065fe',
31
+ 'darwin-x64.tar.xz': '0ae5a24c24bb7d015cd816c5036b3f90f2945aa872fcf54e58da054753b3a299',
32
+ 'linux-arm64.tar.xz': '6ad1325edbdb5649c379b75a237147a666c95d4f9ae8d340fef2d1575d289ad2',
33
+ 'linux-x64.tar.xz': 'fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6',
34
+ 'darwin-arm64.tar.gz': 'bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057',
35
+ 'darwin-x64.tar.gz': '1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097',
36
+ 'linux-arm64.tar.gz': '724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5',
37
+ 'linux-x64.tar.gz': '6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff',
38
+ 'win-arm64.zip': '8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921',
39
+ 'win-x64.zip': '158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541',
40
+ }
41
+ NODE_BASE_URL = 'https://nodejs.org/dist'
42
+ _node_lock = threading.Lock()
43
+
44
+
45
+ def node_platform():
46
+ """('darwin'|'linux'|'win', 'arm64'|'x64'), with None for a platform nodejs.org has no build for."""
47
+ system = {'Darwin': 'darwin', 'Linux': 'linux', 'Windows': 'win'}.get(platform.system())
48
+ machine = platform.machine().lower()
49
+ arch = 'arm64' if machine in ('arm64', 'aarch64') else 'x64' if machine in ('x86_64', 'amd64', 'x64') else None
50
+ return system, arch
51
+
52
+
53
+ def runtime_dirs():
54
+ """Where ThreadFox keeps its portable Node.js: THREADFOX_HOME or ~/.threadfox, shared with the full kit."""
55
+ home = os.environ.get('THREADFOX_HOME') or os.path.join(os.path.expanduser('~'), '.threadfox')
56
+ return [os.path.join(home, 'runtime')]
57
+
58
+
59
+ def portable_npx():
60
+ system, arch = node_platform()
61
+ name = f'node-v{NODE_VERSION}-{system}-{arch}'
62
+ for base in runtime_dirs():
63
+ exe = os.path.join(base, name, 'npx.cmd') if system == 'win' else os.path.join(base, name, 'bin', 'npx')
64
+ if os.path.isfile(exe):
65
+ return exe
66
+ return None
67
+
68
+
69
+ def find_npx():
70
+ """ThreadFox's own portable Node.js, then npx on PATH, then the usual Node.js install folders. Apps started from the
71
+ Dock or Start menu (Claude Desktop) often get a shorter PATH than a terminal, so a working install can be invisible to them."""
72
+ found = portable_npx() or shutil.which('npx')
73
+ if found:
74
+ return found
75
+ home = os.path.expanduser('~')
76
+ candidates = ['/usr/local/bin/npx', '/opt/homebrew/bin/npx', os.path.join(home, '.volta', 'bin', 'npx')]
77
+ candidates += sorted(glob.glob(os.path.join(home, '.nvm', 'versions', 'node', '*', 'bin', 'npx')), reverse=True)
78
+ candidates += sorted(glob.glob(os.path.join(home, '.fnm', 'node-versions', '*', 'installation', 'bin', 'npx')), reverse=True)
79
+ for base in filter(None, [os.environ.get('ProgramFiles'), os.environ.get('ProgramFiles(x86)'), os.environ.get('APPDATA')]):
80
+ candidates += [os.path.join(base, 'nodejs', 'npx.cmd'), os.path.join(base, 'npm', 'npx.cmd')]
81
+ return next((c for c in candidates if os.path.isfile(c)), None)
82
+
83
+
84
+ def node_major(npx):
85
+ """The major version of the node that sits next to this npx, or None when it cannot be asked."""
86
+ folder = os.path.dirname(npx)
87
+ node = next((c for c in (os.path.join(folder, 'node.exe'), os.path.join(folder, 'node')) if os.path.isfile(c)), None) or shutil.which('node')
88
+ try:
89
+ out = subprocess.run([node, '--version'], capture_output=True, text=True, timeout=15).stdout if node else ''
90
+ except (OSError, subprocess.SubprocessError):
91
+ return None
92
+ m = re.match(r'v(\d+)\.', out.strip())
93
+ return int(m.group(1)) if m else None
94
+
95
+
96
+ def _download(url, dest):
97
+ """curl where it exists (every current macOS, Windows 10+ and most Linux): it uses the system's certificates, which some
98
+ Python builds on macOS cannot find. Python's own client otherwise."""
99
+ curl = shutil.which('curl')
100
+ if curl:
101
+ done = subprocess.run([curl, '-fsSL', '--retry', '2', '--connect-timeout', '20', '-o', dest, url],
102
+ stdin=subprocess.DEVNULL, capture_output=True, text=True)
103
+ if done.returncode == 0:
104
+ return
105
+ with urllib.request.urlopen(url, timeout=60) as r, open(dest, 'wb') as f:
106
+ shutil.copyfileobj(r, f, 1 << 20)
107
+
108
+
109
+ def _extract(archive, dest):
110
+ if archive.endswith('.zip'):
111
+ with zipfile.ZipFile(archive) as z:
112
+ z.extractall(dest) # zipfile drops absolute paths and '..'
113
+ return
114
+ with tarfile.open(archive) as t:
115
+ if hasattr(tarfile, 'data_filter'):
116
+ t.extractall(dest, filter='data')
117
+ return
118
+ root = os.path.realpath(dest)
119
+ for m in t.getmembers():
120
+ target = os.path.realpath(os.path.join(dest, m.name))
121
+ link = os.path.realpath(os.path.join(os.path.dirname(target), m.linkname)) if m.issym() or m.islnk() else target
122
+ if not (target + os.sep).startswith(root + os.sep) or not (link + os.sep).startswith(root + os.sep) or m.isdev():
123
+ raise SetupError('The Node.js archive contains an unexpected path, so it was not unpacked.')
124
+ t.extractall(dest)
125
+
126
+
127
+ def install_node():
128
+ """Download the official Node.js LTS into ThreadFox's runtime folder, check its SHA-256 before unpacking, and return its npx.
129
+ Nothing is installed system-wide and no installer runs; deleting the folder removes it."""
130
+ with _node_lock:
131
+ if portable_npx():
132
+ return portable_npx()
133
+ system, arch = node_platform()
134
+ ext = 'zip' if system == 'win' else 'tar.xz'
135
+ if ext == 'tar.xz':
136
+ try:
137
+ import lzma # noqa: F401 (some Python builds leave it out; .tar.gz is twice the size but always readable)
138
+ except ImportError:
139
+ ext = 'tar.gz'
140
+ key = f'{system}-{arch}.{ext}'
141
+ if key not in NODE_SHA256:
142
+ raise SetupError(f'ThreadFox has no portable Node.js for {platform.system()} {platform.machine()}. '
143
+ 'Install Node.js 18+ from https://nodejs.org, then quit and reopen your AI app.')
144
+ name = f'node-v{NODE_VERSION}-{system}-{arch}'
145
+ runtime = runtime_dirs()[0]
146
+ os.makedirs(runtime, exist_ok=True)
147
+ work = tempfile.mkdtemp(prefix='.node-', dir=runtime)
148
+ try:
149
+ archive = os.path.join(work, name + '.' + ext)
150
+ try:
151
+ _download(f'{NODE_BASE_URL}/v{NODE_VERSION}/{name}.{ext}', archive)
152
+ except OSError as exc:
153
+ raise SetupError(f'Node.js could not be downloaded from nodejs.org ({exc}). Check the internet connection and try again, '
154
+ 'or install Node.js 18+ from https://nodejs.org.')
155
+ digest = hashlib.sha256()
156
+ with open(archive, 'rb') as f:
157
+ for chunk in iter(lambda: f.read(1 << 20), b''):
158
+ digest.update(chunk)
159
+ if digest.hexdigest() != NODE_SHA256[key]:
160
+ raise SetupError('The Node.js download did not match the checksum nodejs.org publishes, so it was deleted without '
161
+ 'running. Try again, or install Node.js 18+ from https://nodejs.org.')
162
+ try:
163
+ _extract(archive, work)
164
+ except (tarfile.TarError, zipfile.BadZipFile, EOFError) as exc:
165
+ raise SetupError(f'The Node.js archive could not be unpacked safely ({exc}). Try again, or install Node.js 18+ from https://nodejs.org.')
166
+ final = os.path.join(runtime, name)
167
+ if os.path.isdir(final):
168
+ shutil.rmtree(final)
169
+ os.replace(os.path.join(work, name), final)
170
+ finally:
171
+ shutil.rmtree(work, ignore_errors=True)
172
+ exe = portable_npx()
173
+ if not exe:
174
+ raise SetupError('Node.js was unpacked but npx is missing. Install Node.js 18+ from https://nodejs.org.')
175
+ return exe
176
+
177
+
178
+ def ensure_npx(install=True):
179
+ """An npx whose Node.js is 18 or newer: an installed one when it qualifies, otherwise ThreadFox's portable copy."""
180
+ exe = find_npx()
181
+ if exe and exe != portable_npx():
182
+ major = node_major(exe)
183
+ if major is None or major < 18:
184
+ exe = None # Playwright MCP needs a Node.js 18+ that runs; an old or broken one is left alone and not used
185
+ if exe or not install:
186
+ return exe
187
+ return install_node()
@@ -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
+ }"""
@@ -5,17 +5,19 @@ which tells a paying customer how to open the full ThreadFox kit (posting from y
5
5
  the outcome ledger and the course). The full kit is sold at https://threadfox.vip and on MCP Marketplace.
6
6
  """
7
7
  import os
8
+ import threading
8
9
 
9
10
  from mcp.server.fastmcp import FastMCP
10
11
 
11
- from . import reddit
12
+ from . import node, reddit
12
13
 
13
14
  from . import __version__
14
15
 
15
16
  mcp = FastMCP("threadfox-lite", instructions=(
16
17
  "Free, read-only Reddit tools. find_communities and subreddit_rules before writing a post, account_check before posting "
17
18
  "from an account, post_status at 1, 6 and 24 hours. Every read runs in the user's own signed-in Chrome; if a tool returns "
18
- "setup steps (Node.js and the Playwright Extension), relay them plainly and stop. Nothing here posts or changes anything."))
19
+ "setup steps (the Playwright Extension in Chrome), relay them plainly and stop. Node.js is fetched automatically when missing. "
20
+ "Nothing here posts or changes anything."))
19
21
  mcp._mcp_server.version = __version__ # report ThreadFox Lite's version, not the MCP library's
20
22
 
21
23
 
@@ -65,7 +67,17 @@ def threadfox_full_kit() -> dict:
65
67
  "get_it": "https://threadfox.vip ($49 once, 30-day refund) or ThreadFox on MCP Marketplace"}
66
68
 
67
69
 
70
+ def _prefetch_node():
71
+ """Fetch Node.js in the background at start when none is usable, so the first read does not wait for it."""
72
+ try:
73
+ if not os.environ.get("THREADFOX_LITE_BROWSER_CMD"):
74
+ node.ensure_npx()
75
+ except Exception:
76
+ pass # the first read reports the reason
77
+
78
+
68
79
  def main():
80
+ threading.Thread(target=_prefetch_node, daemon=True).start()
69
81
  mcp.run()
70
82
 
71
83
 
@@ -0,0 +1,57 @@
1
+ """No Node.js installed: the official Node.js LTS is downloaded once, checked against its pinned SHA-256, and used."""
2
+ import hashlib, io, os, sys, tarfile, tempfile, 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 node
7
+
8
+ NAME = f"node-v{node.NODE_VERSION}-linux-x64"
9
+
10
+
11
+ class PortableNode(unittest.TestCase):
12
+ def setUp(self):
13
+ self.tmp = tempfile.TemporaryDirectory(); root = Path(self.tmp.name)
14
+ self.home = root / "home"; self.dist = root / "dist" / f"v{node.NODE_VERSION}"; self.dist.mkdir(parents=True)
15
+ for p in (patch.dict(os.environ, {"THREADFOX_HOME": str(self.home)}), patch.object(node, "NODE_BASE_URL", (root / "dist").as_uri()),
16
+ patch.object(node, "node_platform", return_value=("linux", "x64"))):
17
+ p.start(); self.addCleanup(p.stop)
18
+
19
+ def tearDown(self):
20
+ self.tmp.cleanup()
21
+
22
+ def publish(self, members):
23
+ archive = self.dist / f"{NAME}.tar.xz"
24
+ with tarfile.open(archive, "w:xz") as tar:
25
+ for name, data in members:
26
+ info = tarfile.TarInfo(name); info.size = len(data); info.mode = 0o755
27
+ tar.addfile(info, io.BytesIO(data))
28
+ return hashlib.sha256(archive.read_bytes()).hexdigest()
29
+
30
+ def test_downloads_checks_and_unpacks_once(self):
31
+ digest = self.publish([(f"{NAME}/bin/node", b"#!/bin/sh\n"), (f"{NAME}/bin/npx", b"#!/bin/sh\n")])
32
+ with patch.dict(node.NODE_SHA256, {"linux-x64.tar.xz": digest}):
33
+ npx = node.install_node()
34
+ self.assertEqual(npx, str(self.home / "runtime" / NAME / "bin" / "npx"))
35
+ (self.dist / f"{NAME}.tar.xz").unlink()
36
+ self.assertEqual(node.install_node(), npx)
37
+ self.assertEqual(os.listdir(self.home / "runtime"), [NAME])
38
+
39
+ def test_a_mismatched_download_is_deleted_unrun(self):
40
+ self.publish([(f"{NAME}/bin/npx", b"tampered")])
41
+ with patch.dict(node.NODE_SHA256, {"linux-x64.tar.xz": "0" * 64}):
42
+ with self.assertRaisesRegex(node.SetupError, "checksum"):
43
+ node.install_node()
44
+ self.assertEqual(os.listdir(self.home / "runtime"), [])
45
+
46
+ def test_an_old_or_broken_installed_node_is_not_used(self):
47
+ with patch.object(node, "find_npx", return_value="/usr/bin/npx"), patch.object(node, "install_node", return_value="PORTABLE"):
48
+ with patch.object(node, "node_major", return_value=16):
49
+ self.assertEqual(node.ensure_npx(), "PORTABLE")
50
+ with patch.object(node, "node_major", return_value=None):
51
+ self.assertEqual(node.ensure_npx(), "PORTABLE")
52
+ with patch.object(node, "node_major", return_value=22):
53
+ self.assertEqual(node.ensure_npx(), "/usr/bin/npx")
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()
@@ -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()
@@ -103,7 +103,7 @@ class Setup(unittest.TestCase):
103
103
  raise browser.SetupError(browser.SETUP)
104
104
  for r in (reddit.subreddit_rules("SaaS", get=get), reddit.account_check("someone", get=get),
105
105
  reddit.post_status("https://www.reddit.com/r/SaaS/comments/abc123/t/", get=get), reddit.find_communities("bakery", get=get)):
106
- self.assertIn("Playwright Extension", r["error"]); self.assertIn("nodejs.org", r["error"])
106
+ self.assertIn("Playwright Extension", r["error"]); self.assertNotIn("install Node.js", r["error"])
107
107
 
108
108
  def test_chrome_client_reads_through_a_playwright_mcp(self):
109
109
  """The real client against a stand-in Playwright MCP that answers the way the real one formats results."""
@@ -859,7 +859,7 @@ wheels = [
859
859
 
860
860
  [[package]]
861
861
  name = "threadfox-lite"
862
- version = "0.2.3"
862
+ version = "0.2.5"
863
863
  source = { editable = "." }
864
864
  dependencies = [
865
865
  { name = "mcp" },
File without changes
File without changes