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.
Files changed (25) hide show
  1. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/.claude-plugin/marketplace.json +1 -1
  2. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/.claude-plugin/plugin.json +1 -1
  3. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/PKG-INFO +2 -2
  4. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/README.md +1 -1
  5. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/manifest.json +1 -1
  6. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/pyproject.toml +1 -1
  7. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/server.json +3 -3
  8. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/src/threadfox_lite/__init__.py +1 -1
  9. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/src/threadfox_lite/browser.py +67 -16
  10. threadfox_lite-0.2.5/src/threadfox_lite/process.py +288 -0
  11. threadfox_lite-0.2.5/tests/test_process.py +92 -0
  12. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/uv.lock +1 -1
  13. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/.github/workflows/publish.yml +0 -0
  14. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/.gitignore +0 -0
  15. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/.mcpbignore +0 -0
  16. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/LAUNCHGUIDE.md +0 -0
  17. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/LICENSE +0 -0
  18. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/glama.json +0 -0
  19. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/icon.png +0 -0
  20. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/skills/reddit-rules-first/SKILL.md +0 -0
  21. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/src/threadfox_lite/node.py +0 -0
  22. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/src/threadfox_lite/reddit.py +0 -0
  23. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/src/threadfox_lite/server.py +0 -0
  24. {threadfox_lite-0.2.4 → threadfox_lite-0.2.5}/tests/test_node.py +0 -0
  25. {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.4",
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.4",
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.4
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.4",
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.4"
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.4",
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.4",
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.4/threadfox-lite-0.2.4.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.4"
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
- self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": mid, "method": method, "params": params}) + "\n")
62
- 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.")
63
86
 
64
87
  def read():
65
- for line in self.proc.stdout:
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
- 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()
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 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
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": "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)
120
173
  except Exception:
121
174
  pass
122
- try:
123
- self.proc.kill()
124
- except Exception:
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()
@@ -859,7 +859,7 @@ wheels = [
859
859
 
860
860
  [[package]]
861
861
  name = "threadfox-lite"
862
- version = "0.2.4"
862
+ version = "0.2.5"
863
863
  source = { editable = "." }
864
864
  dependencies = [
865
865
  { name = "mcp" },
File without changes
File without changes