codetherapy 0.1.0__py3-none-any.whl
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.
- codetherapy/__init__.py +3 -0
- codetherapy/__main__.py +3 -0
- codetherapy/cli.py +494 -0
- codetherapy/clipboard.py +30 -0
- codetherapy/config.py +99 -0
- codetherapy/context.py +119 -0
- codetherapy/deep.py +93 -0
- codetherapy/notes.py +58 -0
- codetherapy/procutil.py +35 -0
- codetherapy/projects.py +131 -0
- codetherapy/search.py +163 -0
- codetherapy/server.py +163 -0
- codetherapy/sessions.py +101 -0
- codetherapy/textutil.py +11 -0
- codetherapy/tunnel.py +134 -0
- codetherapy-0.1.0.dist-info/METADATA +326 -0
- codetherapy-0.1.0.dist-info/RECORD +20 -0
- codetherapy-0.1.0.dist-info/WHEEL +4 -0
- codetherapy-0.1.0.dist-info/entry_points.txt +2 -0
- codetherapy-0.1.0.dist-info/licenses/LICENSE +21 -0
codetherapy/server.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Code Therapy: the MCP surface (six PM tools) and the token-guarded Streamable HTTP app."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import uvicorn
|
|
9
|
+
from mcp.server.mcpserver import MCPServer
|
|
10
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
11
|
+
from mcp.types import ToolAnnotations
|
|
12
|
+
from starlette.applications import Starlette
|
|
13
|
+
|
|
14
|
+
from codetherapy import context, deep, notes, search
|
|
15
|
+
from codetherapy.config import Config
|
|
16
|
+
from codetherapy.projects import Project, ProjectIndex
|
|
17
|
+
from codetherapy.sessions import summarize
|
|
18
|
+
|
|
19
|
+
LIST_LIMIT = 15
|
|
20
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True)
|
|
21
|
+
# Each deep lookup is a full `claude -p` run; cap how many the connector runs at once.
|
|
22
|
+
DEEP_SLOTS = threading.BoundedSemaphore(2)
|
|
23
|
+
DEEP_BUSY_MESSAGE = "Another deep lookup is already running; try again in a minute."
|
|
24
|
+
SERVER_NAME = "Code Therapy"
|
|
25
|
+
|
|
26
|
+
INSTRUCTIONS = """\
|
|
27
|
+
You are the project manager for the user's software projects, which they build with Claude Code. \
|
|
28
|
+
The user is usually talking to you by voice, so your answers are spoken aloud: lead with the status, \
|
|
29
|
+
keep it to a few sentences, and avoid code blocks, tables and long lists unless asked.
|
|
30
|
+
- If you aren't sure which project they mean, call list_projects with what they said.
|
|
31
|
+
- Call get_project_brief before answering questions about a project.
|
|
32
|
+
- Use get_recent_activity for "what happened lately" questions.
|
|
33
|
+
- For "where is X / find the file that..." questions, use search_project: it answers in about a second.
|
|
34
|
+
- Use ask_project only for questions the brief and search can't answer, such as how something works in the code. It is slow, so tell the user before you call it.
|
|
35
|
+
- When the user makes a decision or names a to-do, offer to save it with record_note.
|
|
36
|
+
Anything about the user's projects goes through these Code Therapy tools, never your own sandbox, shell, git or web search: these tools read the user's real project folders on their computer. The tools behave the same in voice and text sessions.
|
|
37
|
+
Tools by speed: fast (about 1 second): list_projects, get_project_brief, get_recent_activity, search_project, record_note. Slow (20-90 seconds): ask_project."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve(index: ProjectIndex, query: str) -> tuple[Project | None, str]:
|
|
41
|
+
result = index.resolve(query)
|
|
42
|
+
if result.project:
|
|
43
|
+
return result.project, ""
|
|
44
|
+
if result.candidates:
|
|
45
|
+
names = ", ".join(p.name for p in result.candidates)
|
|
46
|
+
return None, f"Did you mean: {names}? Ask again with the exact name."
|
|
47
|
+
return None, f'No project matches "{query}". Call list_projects to see them all.'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def limited(slots: threading.BoundedSemaphore, work: Callable[[], str]) -> str:
|
|
51
|
+
"""Run work if a slot is free right now; otherwise say so instead of queueing."""
|
|
52
|
+
if not slots.acquire(blocking=False):
|
|
53
|
+
return DEEP_BUSY_MESSAGE
|
|
54
|
+
try:
|
|
55
|
+
return work()
|
|
56
|
+
finally:
|
|
57
|
+
slots.release()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _project_line(project: Project) -> str:
|
|
61
|
+
line = f"- {project.name} (last active {project.last_active:%Y-%m-%d})"
|
|
62
|
+
if project.session_files:
|
|
63
|
+
try:
|
|
64
|
+
if title := summarize(project.session_files[0]).title:
|
|
65
|
+
line += f': latest session "{title}"'
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
if not project.exists:
|
|
69
|
+
line += " [folder missing]"
|
|
70
|
+
return line
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_server(index: ProjectIndex, claude_command: list[str] | None = None) -> MCPServer:
|
|
74
|
+
server = MCPServer(SERVER_NAME, instructions=INSTRUCTIONS)
|
|
75
|
+
|
|
76
|
+
@server.tool(annotations=READ_ONLY)
|
|
77
|
+
def list_projects(query: str = "") -> str:
|
|
78
|
+
"""List the user's Claude Code projects, most recently active first, each with its latest session title.
|
|
79
|
+
Pass query (whatever the user called the project) to narrow the list."""
|
|
80
|
+
projects = index.all()
|
|
81
|
+
if not projects:
|
|
82
|
+
return "No Claude Code projects found."
|
|
83
|
+
if query.strip():
|
|
84
|
+
result = index.resolve(query)
|
|
85
|
+
matches = [result.project] if result.project else list(result.candidates)
|
|
86
|
+
if matches:
|
|
87
|
+
return "\n".join(_project_line(p) for p in matches)
|
|
88
|
+
prefix = f'No project matches "{query}". The most recent are:\n'
|
|
89
|
+
else:
|
|
90
|
+
prefix = f"{len(projects)} projects; the {min(len(projects), LIST_LIMIT)} most recent:\n"
|
|
91
|
+
return prefix + "\n".join(_project_line(p) for p in projects[:LIST_LIMIT])
|
|
92
|
+
|
|
93
|
+
@server.tool(annotations=READ_ONLY)
|
|
94
|
+
def get_project_brief(project: str) -> str:
|
|
95
|
+
"""Current state of one project: overview (CLAUDE.md/README), git branch and recent commits,
|
|
96
|
+
recent Claude Code sessions, and open PM notes. Call this before discussing a project."""
|
|
97
|
+
found, message = _resolve(index, project)
|
|
98
|
+
return context.brief(found) if found else message
|
|
99
|
+
|
|
100
|
+
@server.tool(annotations=READ_ONLY)
|
|
101
|
+
def get_recent_activity(project: str, days: int = 7) -> str:
|
|
102
|
+
"""What was worked on in a project over the last N days: Claude Code sessions and git commits."""
|
|
103
|
+
found, message = _resolve(index, project)
|
|
104
|
+
return context.activity(found, days) if found else message
|
|
105
|
+
|
|
106
|
+
@server.tool(annotations=READ_ONLY)
|
|
107
|
+
async def ask_project(project: str, question: str) -> str:
|
|
108
|
+
"""Slow: 20-90 seconds. BEFORE calling, tell the user you're starting a lookup that can take up to a
|
|
109
|
+
minute or so; don't go quiet. For "where is X / find the file that..." questions, try search_project
|
|
110
|
+
first; it answers in about a second. Otherwise this has Claude Code read the project (read-only) to
|
|
111
|
+
answer a detailed question about its code that get_project_brief can't answer.
|
|
112
|
+
If the result says another lookup is running or the lookup failed, retry once. If it says it needs a
|
|
113
|
+
longer look (timeout), don't retry; suggest asking in Claude Code instead."""
|
|
114
|
+
found, message = _resolve(index, project)
|
|
115
|
+
if found is None:
|
|
116
|
+
return message
|
|
117
|
+
return await anyio.to_thread.run_sync(
|
|
118
|
+
lambda: limited(DEEP_SLOTS, lambda: deep.ask(found.path, question, command=claude_command)))
|
|
119
|
+
|
|
120
|
+
@server.tool(annotations=READ_ONLY)
|
|
121
|
+
def search_project(project: str, query: str) -> str:
|
|
122
|
+
"""Fast (about 1 second) literal search of a project's file names and file contents. Use it for
|
|
123
|
+
"where is X / find the file that mentions Y" questions. Case-insensitive plain text, not regex;
|
|
124
|
+
returns file paths and matching lines. Secret files such as .env are never searched."""
|
|
125
|
+
found, message = _resolve(index, project)
|
|
126
|
+
return search.search(found.path, query) if found else message
|
|
127
|
+
|
|
128
|
+
@server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False))
|
|
129
|
+
def record_note(project: str, kind: str, text: str) -> str:
|
|
130
|
+
"""Save a decision, to-do or note to PM_NOTES.md in the project for the user's next coding session.
|
|
131
|
+
kind is one of: decision, todo, note."""
|
|
132
|
+
found, message = _resolve(index, project)
|
|
133
|
+
if found is None:
|
|
134
|
+
return message
|
|
135
|
+
try:
|
|
136
|
+
line = notes.record(found.path, kind, text)
|
|
137
|
+
except (ValueError, OSError) as error:
|
|
138
|
+
return f"Couldn't save that: {error}"
|
|
139
|
+
return f"Saved to {found.name}/{notes.NOTES_FILE}: {line}"
|
|
140
|
+
|
|
141
|
+
return server
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_app(server: MCPServer, config: Config) -> Starlette:
|
|
145
|
+
hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*"]
|
|
146
|
+
origins = ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*", "https://claude.ai"]
|
|
147
|
+
if config.public_host:
|
|
148
|
+
hosts.append(config.public_host)
|
|
149
|
+
origins.append(f"https://{config.public_host}")
|
|
150
|
+
return server.streamable_http_app(
|
|
151
|
+
streamable_http_path=f"/mcp/{config.token}",
|
|
152
|
+
stateless_http=True,
|
|
153
|
+
transport_security=TransportSecuritySettings(
|
|
154
|
+
enable_dns_rebinding_protection=True, allowed_hosts=hosts, allowed_origins=origins),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run(config: Config) -> None:
|
|
159
|
+
app = build_app(build_server(ProjectIndex(config.projects_dir)), config)
|
|
160
|
+
print(f"Code Therapy listening on http://127.0.0.1:{config.port}/mcp/<token> "
|
|
161
|
+
f"(public host: {config.public_host or 'none'})", flush=True)
|
|
162
|
+
# access_log off: the secret token is part of every request path.
|
|
163
|
+
uvicorn.run(app, host="127.0.0.1", port=config.port, access_log=False, log_level="info")
|
codetherapy/sessions.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Read Claude Code session logs (~/.claude/projects/<folder>/<session-id>.jsonl)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterator
|
|
10
|
+
|
|
11
|
+
from codetherapy.textutil import clip, one_line
|
|
12
|
+
|
|
13
|
+
PROMPT_LIMIT = 200
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SessionSummary:
|
|
18
|
+
session_id: str
|
|
19
|
+
title: str
|
|
20
|
+
cwd: str
|
|
21
|
+
started: datetime | None
|
|
22
|
+
ended: datetime | None
|
|
23
|
+
git_branch: str
|
|
24
|
+
prompts: tuple[str, ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def iter_records(path: Path) -> Iterator[dict]:
|
|
28
|
+
with path.open(encoding="utf-8", errors="replace") as f:
|
|
29
|
+
for line in f:
|
|
30
|
+
try:
|
|
31
|
+
record = json.loads(line)
|
|
32
|
+
except json.JSONDecodeError:
|
|
33
|
+
continue
|
|
34
|
+
if isinstance(record, dict):
|
|
35
|
+
yield record
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_timestamp(value: object) -> datetime | None:
|
|
39
|
+
if not isinstance(value, str):
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
43
|
+
except ValueError:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _prompt_text(record: dict) -> str | None:
|
|
48
|
+
"""A prompt the user actually typed: not tool results, not subagents, not <command> wrappers."""
|
|
49
|
+
if record.get("type") != "user" or record.get("isSidechain"):
|
|
50
|
+
return None
|
|
51
|
+
message = record.get("message")
|
|
52
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
53
|
+
if not isinstance(content, str):
|
|
54
|
+
return None
|
|
55
|
+
text = content.strip()
|
|
56
|
+
if not text or text.startswith("<"):
|
|
57
|
+
return None
|
|
58
|
+
return clip(one_line(text), PROMPT_LIMIT)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_cwd(path: Path, max_records: int = 500) -> str:
|
|
62
|
+
"""The session's working directory, or "" if the log has none or can't be read."""
|
|
63
|
+
try:
|
|
64
|
+
for i, record in enumerate(iter_records(path)):
|
|
65
|
+
if i >= max_records:
|
|
66
|
+
break
|
|
67
|
+
cwd = record.get("cwd")
|
|
68
|
+
if isinstance(cwd, str) and cwd:
|
|
69
|
+
return cwd
|
|
70
|
+
except OSError: # locked, deleted mid-scan, or not a file: one bad log must not break discovery
|
|
71
|
+
pass
|
|
72
|
+
return ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def summarize(path: Path, max_prompts: int = 3) -> SessionSummary:
|
|
76
|
+
stat = path.stat()
|
|
77
|
+
return _summarize_cached(str(path), stat.st_mtime_ns, stat.st_size, max_prompts)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@lru_cache(maxsize=1024)
|
|
81
|
+
def _summarize_cached(path_str: str, _mtime_ns: int, _size: int, max_prompts: int) -> SessionSummary:
|
|
82
|
+
title = cwd = git_branch = ""
|
|
83
|
+
started: datetime | None = None
|
|
84
|
+
ended: datetime | None = None
|
|
85
|
+
prompts: list[str] = []
|
|
86
|
+
for record in iter_records(Path(path_str)):
|
|
87
|
+
if record.get("type") == "ai-title" and isinstance(record.get("aiTitle"), str):
|
|
88
|
+
title = record["aiTitle"] # later titles supersede earlier ones
|
|
89
|
+
timestamp = _parse_timestamp(record.get("timestamp"))
|
|
90
|
+
if timestamp is not None:
|
|
91
|
+
started = started or timestamp
|
|
92
|
+
ended = timestamp
|
|
93
|
+
if not cwd and isinstance(record.get("cwd"), str):
|
|
94
|
+
cwd = record["cwd"]
|
|
95
|
+
if isinstance(record.get("gitBranch"), str) and record["gitBranch"]:
|
|
96
|
+
git_branch = record["gitBranch"]
|
|
97
|
+
if len(prompts) < max_prompts:
|
|
98
|
+
text = _prompt_text(record)
|
|
99
|
+
if text:
|
|
100
|
+
prompts.append(text)
|
|
101
|
+
return SessionSummary(Path(path_str).stem, title, cwd, started, ended, git_branch, tuple(prompts))
|
codetherapy/textutil.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Small string helpers shared by the context, sessions and deep modules."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def one_line(text: str) -> str:
|
|
5
|
+
return " ".join(text.split())
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def clip(text: str, limit: int, marker: str = "…") -> str:
|
|
9
|
+
if len(text) <= limit:
|
|
10
|
+
return text
|
|
11
|
+
return text[: max(0, limit - len(marker))].rstrip() + marker
|
codetherapy/tunnel.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Find and drive ngrok, and turn its log lines into plain-language problems."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import collections
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
LEFTOVER = (" (if an old `codetherapy start` crashed, end the leftover ngrok in Task Manager / Activity Monitor, "
|
|
15
|
+
"or `pkill ngrok`)")
|
|
16
|
+
HINTS = {
|
|
17
|
+
"ERR_NGROK_4018": "ngrok has no authtoken. Run `ngrok config add-authtoken <token>` "
|
|
18
|
+
"(token: https://dashboard.ngrok.com/get-started/your-authtoken).",
|
|
19
|
+
"ERR_NGROK_105": "ngrok rejected the authtoken. Copy it again from "
|
|
20
|
+
"https://dashboard.ngrok.com/get-started/your-authtoken and run `ngrok config add-authtoken <token>`.",
|
|
21
|
+
"ERR_NGROK_108": "ngrok is already running somewhere else on your account (the free plan allows one). "
|
|
22
|
+
f"Stop the other ngrok and try again{LEFTOVER}.",
|
|
23
|
+
"ERR_NGROK_334": f"Your ngrok domain is already online in another ngrok session. Stop it and try again{LEFTOVER}.",
|
|
24
|
+
}
|
|
25
|
+
STARTED = re.compile(r'msg="started tunnel".*?\burl=(\S+)')
|
|
26
|
+
TAIL_LINES = 50
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NgrokOutput:
|
|
30
|
+
"""What wait_online reads from ngrok: lines go to `pending` until wait_online returns, then only
|
|
31
|
+
into the bounded `tail`. ngrok keeps being drained, so it never blocks on a full pipe."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self.pending: queue.Queue[str | None] = queue.Queue()
|
|
35
|
+
self.tail: collections.deque[str] = collections.deque(maxlen=TAIL_LINES)
|
|
36
|
+
self.done = threading.Event() # wait_online has returned
|
|
37
|
+
self.eof = threading.Event() # ngrok's output has ended
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def recent_output(proc: subprocess.Popen, wait: float = 0.0) -> list[str]:
|
|
41
|
+
"""The last ngrok log lines read by wait_online (at most TAIL_LINES). `wait` gives the reader
|
|
42
|
+
that long to reach the end of the output, for when ngrok has just exited."""
|
|
43
|
+
output = getattr(proc, "ngrok_output", None)
|
|
44
|
+
if not output:
|
|
45
|
+
return []
|
|
46
|
+
output.eof.wait(wait)
|
|
47
|
+
return list(output.tail)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_ngrok() -> str | None:
|
|
51
|
+
found = shutil.which("ngrok")
|
|
52
|
+
if found:
|
|
53
|
+
return found
|
|
54
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
55
|
+
if local: # winget installs without updating the current shell's PATH
|
|
56
|
+
link = Path(local, "Microsoft", "WinGet", "Links", "ngrok.exe")
|
|
57
|
+
if link.is_file():
|
|
58
|
+
return str(link)
|
|
59
|
+
matches = sorted(Path(local, "Microsoft", "WinGet", "Packages").glob("Ngrok.Ngrok_*/ngrok.exe"))
|
|
60
|
+
if matches:
|
|
61
|
+
return str(matches[-1])
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ngrok_has_authtoken(ngrok: str) -> bool:
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run([ngrok, "config", "check"], capture_output=True, text=True, encoding="utf-8",
|
|
68
|
+
errors="replace", timeout=15)
|
|
69
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
70
|
+
return False
|
|
71
|
+
match = re.search(r"configuration file at (.+)$", (result.stdout + result.stderr).strip(), re.MULTILINE)
|
|
72
|
+
if result.returncode != 0 or not match:
|
|
73
|
+
return False
|
|
74
|
+
try:
|
|
75
|
+
text = Path(match.group(1).strip()).read_text(encoding="utf-8", errors="replace")
|
|
76
|
+
except OSError:
|
|
77
|
+
return False
|
|
78
|
+
return re.search(r"^[ \t]*authtoken:[ \t]*\S+", text, re.MULTILINE) is not None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ngrok_command(ngrok: str, port: int, host: str) -> list[str]:
|
|
82
|
+
return [ngrok, "http", f"127.0.0.1:{port}", f"--url=https://{host}", "--inspect=false", "--log=stdout"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def hint_for(line: str) -> str | None:
|
|
86
|
+
for code, hint in HINTS.items():
|
|
87
|
+
if code in line:
|
|
88
|
+
return hint
|
|
89
|
+
if "lvl=eror" in line or "lvl=crit" in line:
|
|
90
|
+
return "ngrok reported a problem: " + line.strip()[:300]
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def wait_online(proc: subprocess.Popen, timeout: float) -> tuple[bool, str]:
|
|
95
|
+
"""Read ngrok's stdout until the tunnel starts, an error appears, ngrok exits, or timeout.
|
|
96
|
+
|
|
97
|
+
Reading continues in the background afterwards, into a bounded tail: see recent_output()."""
|
|
98
|
+
output = NgrokOutput()
|
|
99
|
+
proc.ngrok_output = output # type: ignore[attr-defined]
|
|
100
|
+
|
|
101
|
+
def pump() -> None:
|
|
102
|
+
try:
|
|
103
|
+
for line in proc.stdout: # type: ignore[union-attr]
|
|
104
|
+
output.tail.append(line)
|
|
105
|
+
if not output.done.is_set():
|
|
106
|
+
output.pending.put(line)
|
|
107
|
+
except (OSError, ValueError):
|
|
108
|
+
pass
|
|
109
|
+
finally:
|
|
110
|
+
output.eof.set()
|
|
111
|
+
output.pending.put(None)
|
|
112
|
+
|
|
113
|
+
def finish(ok: bool, detail: str) -> tuple[bool, str]:
|
|
114
|
+
output.done.set()
|
|
115
|
+
while True: # drop anything still queued; the tail keeps the recent lines
|
|
116
|
+
try:
|
|
117
|
+
output.pending.get_nowait()
|
|
118
|
+
except queue.Empty:
|
|
119
|
+
return ok, detail
|
|
120
|
+
|
|
121
|
+
threading.Thread(target=pump, daemon=True).start()
|
|
122
|
+
deadline = time.monotonic() + timeout
|
|
123
|
+
while (remaining := deadline - time.monotonic()) > 0:
|
|
124
|
+
try:
|
|
125
|
+
line = output.pending.get(timeout=min(remaining, 0.5))
|
|
126
|
+
except queue.Empty:
|
|
127
|
+
continue
|
|
128
|
+
if line is None:
|
|
129
|
+
return finish(False, "ngrok exited before the tunnel came online.")
|
|
130
|
+
if match := STARTED.search(line):
|
|
131
|
+
return finish(True, match.group(1))
|
|
132
|
+
if hint := hint_for(line):
|
|
133
|
+
return finish(False, hint)
|
|
134
|
+
return finish(False, f"ngrok didn't come online within {int(timeout)}s.")
|