visualagents 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.
visualagents/detect.py ADDED
@@ -0,0 +1,337 @@
1
+ """Works out how to start a project, so `visualagents run <folder>` needs no command.
2
+
3
+ Looks at the README's shell snippets, start scripts, Makefile targets, Python entry points (FastAPI,
4
+ Gradio, Streamlit, Chainlit, `__main__` scripts), pyproject console scripts and package.json, then
5
+ points `python` / `uvicorn` / ... at the project's own virtualenv. The chosen command is saved to
6
+ `.visualagents/config.json`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import shlex
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from . import blueprint as bpmod
20
+
21
+ _SKIP = {".git", "node_modules", "__pycache__", ".visualagents", "dist", "build", ".tox", ".mypy_cache",
22
+ ".pytest_cache", "site-packages", ".next", ".cache"}
23
+ _RUN_LINE = re.compile(
24
+ r"^(?:\./[\w.\-/]+\.sh|bash\s+[\w.\-/]+\.sh|sh\s+[\w.\-/]+\.sh|python3?\s+(?!-m\s+(?:venv|pip)\b)\S+"
25
+ r"|uv\s+run\s+\S+|poetry\s+run\s+\S+|streamlit\s+run|chainlit\s+run|uvicorn\s+\S+|fastapi\s+(?:dev|run)|"
26
+ r"gradio\s+\S+|npm\s+run\s+(?:dev|start)|npm\s+start|pnpm\s+(?:run\s+)?(?:dev|start)|"
27
+ r"yarn\s+(?:dev|start)|make\s+(?:run|start|dev|serve)|docker\s+compose\s+up|docker-compose\s+up)\b"
28
+ )
29
+ _NOT_RUN = re.compile(r"\b(pip|install|venv|virtualenv|setup\.py|pytest|test|lint|build|format|mypy|ruff|black|"
30
+ r"stop|kill|clean|uninstall|teardown|down|reset)\b")
31
+ _VENV_TOOLS = {"python", "python3", "uvicorn", "streamlit", "chainlit", "gradio", "fastapi", "flask", "jupyter",
32
+ "hypercorn", "gunicorn", "celery", "panel"}
33
+ _ENTRY_NAMES = ("main.py", "app.py", "run.py", "server.py", "cli.py", "agent.py", "agents.py", "chat.py",
34
+ "webui.py", "demo.py", "start.py", "launch.py")
35
+ _ENTRY_DIRS = (".", "src", "backend", "app", "server", "api")
36
+
37
+
38
+ @dataclass
39
+ class Candidate:
40
+ command: list[str]
41
+ cwd: str = "."
42
+ source: str = ""
43
+ note: str = ""
44
+ score: int = 0
45
+ env: dict[str, str] = field(default_factory=dict)
46
+
47
+ def label(self) -> str:
48
+ where = f" (in {self.cwd}/)" if self.cwd not in (".", "") else ""
49
+ return f"{shlex.join(self.command)}{where}"
50
+
51
+ def to_json(self) -> dict[str, Any]:
52
+ return {"command": self.command, "cwd": self.cwd}
53
+
54
+
55
+ # -- saved choice ------------------------------------------------------------------
56
+
57
+ def config_path(project: Path) -> Path:
58
+ return bpmod.state_dir(project) / "config.json"
59
+
60
+
61
+ def load_saved(project: Path) -> Candidate | None:
62
+ try:
63
+ data = json.loads(config_path(project).read_text(encoding="utf-8"))
64
+ except (OSError, ValueError):
65
+ return None
66
+ cmd = data.get("command")
67
+ if isinstance(cmd, str):
68
+ cmd = shlex.split(cmd)
69
+ if not cmd:
70
+ return None
71
+ return Candidate(list(cmd), data.get("cwd") or ".", "saved")
72
+
73
+
74
+ def save_choice(project: Path, cand: Candidate) -> Path:
75
+ path = config_path(project)
76
+ path.parent.mkdir(parents=True, exist_ok=True)
77
+ try:
78
+ data = json.loads(path.read_text(encoding="utf-8"))
79
+ except (OSError, ValueError):
80
+ data = {}
81
+ data.update(cand.to_json())
82
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
83
+ ignore = path.parent / ".gitignore"
84
+ if not ignore.exists():
85
+ ignore.write_text("runs/\n", encoding="utf-8")
86
+ return path
87
+
88
+
89
+ # -- virtualenvs --------------------------------------------------------------------
90
+
91
+ def find_venvs(project: Path, max_depth: int = 3) -> list[Path]:
92
+ """Virtualenvs inside the project (anything with a pyvenv.cfg), shallowest first."""
93
+ project = Path(project).resolve()
94
+ found: list[Path] = []
95
+ for dirpath, dirnames, _ in os.walk(project):
96
+ here = Path(dirpath)
97
+ depth = len(here.relative_to(project).parts)
98
+ if (here / "pyvenv.cfg").is_file():
99
+ found.append(here)
100
+ dirnames[:] = []
101
+ continue
102
+ if depth >= max_depth:
103
+ dirnames[:] = []
104
+ continue
105
+ dirnames[:] = sorted(d for d in dirnames if d not in _SKIP)
106
+ found.sort(key=lambda p: (len(p.relative_to(project).parts), p.name not in (".venv", "venv")))
107
+ return found
108
+
109
+
110
+ def venv_bin(venv: Path, name: str) -> Path:
111
+ return venv / ("Scripts" if os.name == "nt" else "bin") / (name + (".exe" if os.name == "nt" else ""))
112
+
113
+
114
+ def best_venv(project: Path, cwd: str = ".") -> Path | None:
115
+ """The virtualenv closest to where the command runs (backend/venv for a command run in backend/)."""
116
+ project = Path(project).resolve()
117
+ venvs = find_venvs(project)
118
+ if not venvs:
119
+ return None
120
+ here = (project / cwd).resolve()
121
+
122
+ def distance(v: Path) -> tuple[int, int]:
123
+ parent = v.parent
124
+ if here == parent or parent in here.parents:
125
+ return (0, len(here.relative_to(parent).parts))
126
+ if here in parent.parents:
127
+ return (1, len(parent.relative_to(here).parts))
128
+ return (2, len(v.relative_to(project).parts))
129
+
130
+ return sorted(venvs, key=distance)[0]
131
+
132
+
133
+ def resolve(project: Path, cand: Candidate) -> tuple[list[str], list[str]]:
134
+ """Point the command at the project's environment. Returns (argv, notes for the user)."""
135
+ project = Path(project).resolve()
136
+ argv = list(cand.command)
137
+ notes: list[str] = []
138
+ if not argv:
139
+ return argv, notes
140
+ head = Path(argv[0]).name
141
+ venv = best_venv(project, cand.cwd)
142
+ if head in _VENV_TOOLS and "/" not in argv[0]:
143
+ if venv is not None and venv_bin(venv, head if head != "python3" else "python").exists():
144
+ argv[0] = str(venv_bin(venv, head if head != "python3" else "python"))
145
+ notes.append(f"using the project's virtualenv: {venv.relative_to(project)}")
146
+ elif (project / "uv.lock").exists():
147
+ argv = ["uv", "run", *argv]
148
+ notes.append("using uv (uv.lock found)")
149
+ elif (project / "poetry.lock").exists():
150
+ argv = ["poetry", "run", *argv]
151
+ notes.append("using poetry (poetry.lock found)")
152
+ else:
153
+ if head in ("python", "python3"):
154
+ argv[0] = working_python(head)
155
+ if (project / "requirements.txt").exists() or (project / "pyproject.toml").exists():
156
+ notes.append("no virtualenv found - create one if imports fail: "
157
+ "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt")
158
+ elif argv[0].endswith(".py") and (project / cand.cwd / argv[0]).exists():
159
+ py = venv_bin(venv, "python") if venv else None
160
+ argv = [str(py) if py and py.exists() else working_python("python3"), *argv]
161
+ return argv, notes
162
+
163
+
164
+ def working_python(preferred: str = "python3") -> str:
165
+ """First of python3 / python on PATH that actually starts (pyenv shims can point at missing versions)."""
166
+ import shutil
167
+ import subprocess
168
+ import sys
169
+
170
+ for name in dict.fromkeys([preferred, "python3", "python"]):
171
+ exe = shutil.which(name)
172
+ if not exe:
173
+ continue
174
+ try:
175
+ if subprocess.run([exe, "-c", "import sys"], capture_output=True, timeout=10).returncode == 0:
176
+ return name if exe == shutil.which(name) else exe
177
+ except (OSError, subprocess.SubprocessError):
178
+ continue
179
+ return sys.executable
180
+
181
+
182
+ # -- detection ------------------------------------------------------------------------
183
+
184
+ def detect(project: Path) -> list[Candidate]:
185
+ project = Path(project).resolve()
186
+ cands: list[Candidate] = []
187
+ cands += _from_readme(project)
188
+ cands += _from_scripts(project)
189
+ cands += _from_python_entries(project)
190
+ cands += _from_pyproject(project)
191
+ cands += _from_makefile(project)
192
+ cands += _from_package_json(project)
193
+ seen: set[tuple[str, str]] = set()
194
+ out = []
195
+ for c in sorted(cands, key=lambda c: -c.score):
196
+ key = (shlex.join(c.command), os.path.normpath(c.cwd))
197
+ if key in seen:
198
+ continue
199
+ seen.add(key)
200
+ out.append(c)
201
+ return out
202
+
203
+
204
+ def _from_readme(project: Path) -> list[Candidate]:
205
+ out: list[Candidate] = []
206
+ for name in ("README.md", "readme.md", "README.MD", "README.rst", "README"):
207
+ readme = project / name
208
+ if readme.is_file():
209
+ break
210
+ else:
211
+ return out
212
+ try:
213
+ text = readme.read_text(encoding="utf-8", errors="replace")
214
+ except OSError:
215
+ return out
216
+ shells = {"", "bash", "sh", "shell", "console", "zsh", "text", "cmd", "terminal", "powershell"}
217
+ blocks = [body for lang, body in re.findall(r"^[ \t]*```[ \t]*([\w+-]*)[^\n]*\n(.*?)^[ \t]*```", text, re.S | re.M)
218
+ if lang.lower() in shells]
219
+ order = 0
220
+ for block in blocks:
221
+ cwd = "."
222
+ for raw in block.splitlines():
223
+ line = raw.strip()
224
+ line = re.sub(r"^(\$|>|%)\s+", "", line)
225
+ if not line or line.startswith("#"):
226
+ continue
227
+ for part in [p.strip() for p in re.split(r"\s*&&\s*", line)]:
228
+ m_cd = re.match(r"^cd\s+(\S+)$", part)
229
+ if m_cd:
230
+ target = m_cd.group(1).strip("'\"")
231
+ if not target.startswith(("/", "~", "$")) and "<" not in target:
232
+ cwd = os.path.normpath(os.path.join(cwd, target))
233
+ continue
234
+ if not _RUN_LINE.match(part) or _NOT_RUN.search(part.split(" #")[0]):
235
+ continue
236
+ cmd_text = part.split(" #")[0].strip()
237
+ try:
238
+ argv = shlex.split(cmd_text)
239
+ except ValueError:
240
+ continue
241
+ if not (project / cwd).is_dir() or not _files_exist(project / cwd, argv):
242
+ continue
243
+ order += 1
244
+ out.append(Candidate(argv, cwd, "README", score=100 - order))
245
+ return out
246
+
247
+
248
+ def _files_exist(base: Path, argv: list[str]) -> bool:
249
+ for tok in argv[:3]:
250
+ if tok.endswith((".py", ".sh")) and not (base / tok).exists():
251
+ return False
252
+ return True
253
+
254
+
255
+ def _from_scripts(project: Path) -> list[Candidate]:
256
+ out = []
257
+ for name in ("start.sh", "run.sh", "dev.sh", "launch.sh", "serve.sh", "main.sh", "start", "run"): # never stop.sh etc.
258
+ p = project / name
259
+ if p.is_file() and (name.endswith(".sh") or os.access(p, os.X_OK)):
260
+ cmd = [f"./{name}"] if os.access(p, os.X_OK) else ["bash", name]
261
+ out.append(Candidate(cmd, ".", "start script", score=90 if name.startswith("start") else 80))
262
+ return out
263
+
264
+
265
+ def _from_python_entries(project: Path) -> list[Candidate]:
266
+ out = []
267
+ for d in _ENTRY_DIRS:
268
+ base = project / d
269
+ if not base.is_dir():
270
+ continue
271
+ files = [base / n for n in _ENTRY_NAMES] + sorted(base.glob("*_app.py")) + sorted(base.glob("app_*.py"))
272
+ for f in files:
273
+ if not f.is_file():
274
+ continue
275
+ try:
276
+ src = f.read_text(encoding="utf-8", errors="replace")
277
+ except OSError:
278
+ continue
279
+ rel_dir = os.path.relpath(f.parent, project)
280
+ if re.search(r"^\s*(import streamlit|from streamlit)", src, re.M):
281
+ out.append(Candidate(["streamlit", "run", f.name], rel_dir, "Streamlit app", score=70))
282
+ elif re.search(r"^\s*(import chainlit|from chainlit)", src, re.M):
283
+ out.append(Candidate(["chainlit", "run", f.name], rel_dir, "Chainlit app", score=70))
284
+ elif "__main__" in src:
285
+ kind = "Gradio app" if "gradio" in src else "script"
286
+ out.append(Candidate(["python", f.name], rel_dir, kind, score=65 if kind != "script" else 60))
287
+ else:
288
+ m = re.search(r"^(\w+)\s*=\s*FastAPI\(", src, re.M)
289
+ if m:
290
+ out.append(Candidate(["python", "-m", "uvicorn", f"{f.stem}:{m.group(1)}", "--port", "8000"],
291
+ rel_dir, "FastAPI app", score=68))
292
+ return out
293
+
294
+
295
+ def _from_pyproject(project: Path) -> list[Candidate]:
296
+ p = project / "pyproject.toml"
297
+ if not p.is_file():
298
+ return []
299
+ try:
300
+ import tomllib # type: ignore
301
+
302
+ data = tomllib.loads(p.read_text(encoding="utf-8"))
303
+ except Exception:
304
+ return []
305
+ scripts = (data.get("project") or {}).get("scripts") or {}
306
+ scripts.update(((data.get("tool") or {}).get("poetry") or {}).get("scripts") or {})
307
+ return [Candidate([name], ".", "pyproject script", score=55) for name in list(scripts)[:4]]
308
+
309
+
310
+ def _from_makefile(project: Path) -> list[Candidate]:
311
+ p = project / "Makefile"
312
+ if not p.is_file():
313
+ return []
314
+ try:
315
+ text = p.read_text(encoding="utf-8", errors="replace")
316
+ except OSError:
317
+ return []
318
+ return [Candidate(["make", t], ".", "Makefile", score=50)
319
+ for t in ("run", "start", "dev", "serve") if re.search(rf"^{t}\s*:", text, re.M)]
320
+
321
+
322
+ def _from_package_json(project: Path) -> list[Candidate]:
323
+ out = []
324
+ for d in (".", "frontend", "web", "app", "ui"):
325
+ p = project / d / "package.json"
326
+ if not p.is_file():
327
+ continue
328
+ try:
329
+ scripts = json.loads(p.read_text(encoding="utf-8")).get("scripts") or {}
330
+ except (OSError, ValueError):
331
+ continue
332
+ for name in ("dev", "start"):
333
+ if name in scripts:
334
+ out.append(Candidate(["npm", "run", name], os.path.normpath(d), "package.json",
335
+ score=45 if d == "." else 30))
336
+ break
337
+ return out
visualagents/home.py ADDED
@@ -0,0 +1,139 @@
1
+ """User-level VisualAgents state: ~/.visualagents (override with VISUALAGENTS_HOME).
2
+
3
+ daemon.json where the background dashboard listens (url, token, pid)
4
+ registry.json hooked projects: id -> {path, envs}
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def home() -> Path:
17
+ return Path(os.environ.get("VISUALAGENTS_HOME") or Path.home() / ".visualagents")
18
+
19
+
20
+ def _read(path: Path) -> dict[str, Any]:
21
+ try:
22
+ return json.loads(path.read_text(encoding="utf-8"))
23
+ except (OSError, ValueError):
24
+ return {}
25
+
26
+
27
+ def _write(path: Path, data: dict[str, Any]) -> None:
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ tmp = path.with_suffix(".tmp")
30
+ tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
31
+ os.chmod(tmp, 0o600)
32
+ tmp.replace(path)
33
+
34
+
35
+ # -- daemon ----------------------------------------------------------------------
36
+
37
+ def daemon_file() -> Path:
38
+ return home() / "daemon.json"
39
+
40
+
41
+ def read_daemon() -> dict[str, Any]:
42
+ """The running daemon's {url, token, pid}, or {} when none is running."""
43
+ info = _read(daemon_file())
44
+ pid = info.get("pid")
45
+ if not pid or not _alive(int(pid)):
46
+ return {}
47
+ return info
48
+
49
+
50
+ def write_daemon(url: str, token: str, pid: int) -> None:
51
+ _write(daemon_file(), {"url": url, "token": token, "pid": pid})
52
+
53
+
54
+ def clear_daemon(pid: int | None = None) -> None:
55
+ info = _read(daemon_file())
56
+ if pid is None or info.get("pid") == pid:
57
+ try:
58
+ daemon_file().unlink()
59
+ except OSError:
60
+ pass
61
+
62
+
63
+ def _alive(pid: int) -> bool:
64
+ try:
65
+ os.kill(pid, 0)
66
+ except ProcessLookupError:
67
+ return False
68
+ except PermissionError:
69
+ return True
70
+ except OSError:
71
+ return False
72
+ return True
73
+
74
+
75
+ # -- registry of hooked projects ----------------------------------------------
76
+
77
+ def registry() -> dict[str, dict[str, Any]]:
78
+ return _read(home() / "registry.json").get("projects", {})
79
+
80
+
81
+ def save_registry(projects: dict[str, dict[str, Any]]) -> None:
82
+ _write(home() / "registry.json", {"projects": projects})
83
+
84
+
85
+ def project_id(path: Path, projects: dict[str, dict[str, Any]] | None = None) -> str:
86
+ """Short, URL-friendly id for a project folder (stable across calls)."""
87
+ path = Path(path).resolve()
88
+ projects = registry() if projects is None else projects
89
+ for pid, info in projects.items():
90
+ if info.get("path") == str(path):
91
+ return pid
92
+ base = re.sub(r"[^a-z0-9]+", "-", path.name.lower()).strip("-") or "project"
93
+ pid, n = base, 2
94
+ while pid in projects and projects[pid].get("path") != str(path):
95
+ pid, n = f"{base}-{n}", n + 1
96
+ return pid
97
+
98
+
99
+ # -- saved settings and keys (set by the setup wizard) ------------------------------
100
+
101
+ def settings() -> dict[str, Any]:
102
+ return _read(home() / "settings.json")
103
+
104
+
105
+ def save_settings(data: dict[str, Any]) -> None:
106
+ current = settings()
107
+ current.update(data)
108
+ _write(home() / "settings.json", current)
109
+
110
+
111
+ def remember_project(path: Path) -> None:
112
+ recent = [p for p in settings().get("recent", []) if p != str(path)]
113
+ save_settings({"recent": [str(path), *recent][:8]})
114
+
115
+
116
+ def saved_keys() -> dict[str, str]:
117
+ return {k: v for k, v in _read(home() / "keys.json").items() if isinstance(v, str) and v}
118
+
119
+
120
+ def save_key(var: str, value: str) -> Path:
121
+ keys = saved_keys()
122
+ keys[var] = value
123
+ path = home() / "keys.json"
124
+ _write(path, keys)
125
+ return path
126
+
127
+
128
+ def apply_saved() -> None:
129
+ """Fill in saved keys / provider / models for anything the environment and project .env left unset."""
130
+ for var, value in saved_keys().items():
131
+ if not os.environ.get(var):
132
+ os.environ[var] = value
133
+ s = settings()
134
+ provider = s.get("provider")
135
+ if provider and not os.environ.get("VISUALAGENTS_PROVIDER"):
136
+ os.environ["VISUALAGENTS_PROVIDER"] = provider
137
+ models = s.get("models", {})
138
+ if provider and provider in models and not os.environ.get("VISUALAGENTS_MODEL"):
139
+ os.environ["VISUALAGENTS_MODEL"] = models[provider]
visualagents/hooks.py ADDED
@@ -0,0 +1,63 @@
1
+ """Install / remove the startup hook that lets a project's Python processes report to the dashboard.
2
+
3
+ `visualagents hook <folder>` writes `visualagents_hook.pth` into the site-packages of each virtualenv in
4
+ the folder (or an interpreter you name). Python runs .pth "import" lines at startup, so every process from
5
+ that environment - started by a script, an IDE, a notebook kernel - loads VisualAgents' runtime, which stays
6
+ silent until the process makes an LLM / agent call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from importlib import resources
13
+ from pathlib import Path
14
+
15
+ PTH_NAME = "visualagents_hook.pth"
16
+
17
+
18
+ def boot_path() -> Path:
19
+ return Path(str(resources.files("visualagents") / "_hook" / "visualagents_boot.py")).resolve()
20
+
21
+
22
+ def site_packages(python: Path) -> Path:
23
+ out = subprocess.run(
24
+ [str(python), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"],
25
+ capture_output=True, text=True, timeout=30, check=True,
26
+ )
27
+ return Path(out.stdout.strip())
28
+
29
+
30
+ def pth_line(project: Path) -> str:
31
+ boot = str(boot_path())
32
+ return (
33
+ f"import os; _va = {boot!r}; os.path.exists(_va) and exec(compile(open(_va, encoding='utf-8').read(), _va, "
34
+ f"'exec'), {{'__name__': 'visualagents_boot', '__file__': _va, 'VISUALAGENTS_PROJECT': {str(project)!r}}})"
35
+ )
36
+
37
+
38
+ def install(python: Path, project: Path) -> Path:
39
+ site = site_packages(python)
40
+ site.mkdir(parents=True, exist_ok=True)
41
+ target = site / PTH_NAME
42
+ target.write_text(
43
+ f"# VisualAgents hook for {project} - remove with: visualagents unhook {project}\n{pth_line(project)}\n",
44
+ encoding="utf-8",
45
+ )
46
+ return target
47
+
48
+
49
+ def uninstall(site: Path) -> bool:
50
+ target = Path(site) / PTH_NAME
51
+ if target.exists():
52
+ target.unlink()
53
+ return True
54
+ return False
55
+
56
+
57
+ def hooked_project(site: Path) -> str | None:
58
+ """Which project an installed hook points at (None when not hooked)."""
59
+ try:
60
+ first = (Path(site) / PTH_NAME).read_text(encoding="utf-8").splitlines()[0]
61
+ except (OSError, IndexError):
62
+ return None
63
+ return first.split("hook for ", 1)[-1].split(" - remove", 1)[0] if "hook for " in first else ""
visualagents/hub.py ADDED
@@ -0,0 +1,143 @@
1
+ """Thread-safe fan-out between inputs (process output, SDK events, LLM narration) and UI clients.
2
+
3
+ Raw inputs are also recorded to `.visualagents/runs/<timestamp>.jsonl` so any run can be replayed
4
+ later - with a newer blueprint or smarter patterns - via `visualagents replay`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import secrets
11
+ import threading
12
+ import time
13
+ from collections import deque
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .tracker import Tracker
18
+
19
+
20
+ class Hub:
21
+ def __init__(self, blueprint: dict[str, Any], *, record_dir: Path | None = None, meta: dict[str, Any] | None = None):
22
+ self.blueprint = blueprint
23
+ self.tracker = Tracker(blueprint)
24
+ self.meta = meta or {}
25
+ self.token = secrets.token_urlsafe(16)
26
+ self.cond = threading.Condition()
27
+ self.events: deque[dict[str, Any]] = deque(maxlen=20000)
28
+ self.state_version = 0
29
+ self.recent_lines: deque[str] = deque(maxlen=60)
30
+ self.procs: set[int] = set() # hooked processes currently reporting (daemon mode)
31
+ self.launcher = None # set when the dashboard may start the project (launcher.Launcher)
32
+ self.lines_seen = 0
33
+ self.started_at = time.time()
34
+ self._record = None
35
+ self.record_path: Path | None = None
36
+ if record_dir is not None:
37
+ record_dir.mkdir(parents=True, exist_ok=True)
38
+ self.record_path = record_dir / time.strftime("%Y%m%d-%H%M%S.jsonl")
39
+ self._record = open(self.record_path, "a", encoding="utf-8")
40
+ self._write_record({"type": "meta", "blueprint": blueprint, "meta": self.meta})
41
+
42
+ # -- inputs --------------------------------------------------------------
43
+ def feed_line(self, line: str, stream: str = "stdout") -> None:
44
+ with self.cond:
45
+ self._write_record({"type": "line", "stream": stream, "text": line})
46
+ self.lines_seen += 1
47
+ self.recent_lines.append(line)
48
+ self._publish(self.tracker.ingest_line(line, stream))
49
+
50
+ def feed_structured(self, payload: dict[str, Any]) -> None:
51
+ typ = payload.get("type")
52
+ with self.cond:
53
+ if typ == "process_start":
54
+ pid = int(payload.get("pid") or 0)
55
+ if not self.procs and self.tracker.run["status"] != "running":
56
+ self._write_record({"type": "run_start", "command": payload.get("text", "")})
57
+ self._publish(self.tracker.run_started(str(payload.get("text") or "")))
58
+ self.procs.add(pid)
59
+ self._publish([self.tracker._event("status", None, f"Process {pid} attached: {payload.get('text', '')}",
60
+ source="system")])
61
+ return
62
+ if typ == "process_end":
63
+ pid = int(payload.get("pid") or 0)
64
+ if pid not in self.procs:
65
+ return
66
+ self.procs.discard(pid)
67
+ if not self.procs:
68
+ self._write_record({"type": "run_end", "code": payload.get("code", 0)})
69
+ self._publish(self.tracker.run_finished(payload.get("code", 0)))
70
+ return
71
+ self._write_record({"type": "structured", "payload": payload})
72
+ self._publish(self.tracker.ingest_structured(payload))
73
+
74
+ def narrate(self, agent: str | None, activity: str) -> None:
75
+ with self.cond:
76
+ self._write_record({"type": "narration", "agent": agent, "activity": activity})
77
+ self._publish(self.tracker.apply_narration(agent, activity))
78
+
79
+ def run_started(self, command: str = "") -> None:
80
+ with self.cond:
81
+ self._write_record({"type": "run_start", "command": command})
82
+ self._publish(self.tracker.run_started(command))
83
+
84
+ def run_finished(self, code: int | None) -> None:
85
+ with self.cond:
86
+ self._write_record({"type": "run_end", "code": code})
87
+ self._publish(self.tracker.run_finished(code))
88
+
89
+ def system(self, text: str) -> None:
90
+ with self.cond:
91
+ self._publish([self.tracker._event("status", None, text, source="system")])
92
+
93
+ def reset_view(self) -> None:
94
+ with self.cond:
95
+ run = dict(self.tracker.run)
96
+ self.tracker.reset()
97
+ self.tracker.run.update(run)
98
+ self._publish([self.tracker._event("status", None, "View reset", source="system")])
99
+
100
+ # -- outputs -------------------------------------------------------------
101
+ def _publish(self, events: list[dict[str, Any]]) -> None:
102
+ for ev in events:
103
+ self.events.append(ev)
104
+ self.state_version += 1
105
+ self.cond.notify_all()
106
+
107
+ def snapshot(self, max_events: int = 6000) -> dict[str, Any]:
108
+ with self.cond:
109
+ return {
110
+ "blueprint": self.blueprint,
111
+ "state": self.tracker.snapshot(),
112
+ "events": list(self.events)[-max_events:],
113
+ "meta": {**self.meta, "lines": self.lines_seen, "record": str(self.record_path or ""),
114
+ "launch": self.launcher.info() if self.launcher else None},
115
+ "version": self.state_version,
116
+ }
117
+
118
+ def wait(self, last_seq: int, last_version: int, timeout: float = 15.0):
119
+ """Block until something newer than (last_seq, last_version) exists. Returns (events, state, version)."""
120
+ with self.cond:
121
+ if self.state_version == last_version:
122
+ self.cond.wait(timeout)
123
+ events = [e for e in self.events if e["seq"] > last_seq] if self.events and self.events[-1]["seq"] > last_seq else []
124
+ state = self.tracker.snapshot() if self.state_version != last_version else None
125
+ return events, state, self.state_version
126
+
127
+ def _write_record(self, obj: dict[str, Any]) -> None:
128
+ if self._record is None:
129
+ return
130
+ obj["t"] = round(time.time() - self.started_at, 3)
131
+ try:
132
+ self._record.write(json.dumps(obj, ensure_ascii=False) + "\n")
133
+ self._record.flush()
134
+ except (OSError, ValueError):
135
+ self._record = None
136
+
137
+ def close(self) -> None:
138
+ if self._record is not None:
139
+ try:
140
+ self._record.close()
141
+ except OSError:
142
+ pass
143
+ self._record = None