loopcaptain 0.3.1__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.
Files changed (50) hide show
  1. loopcaptain/__init__.py +0 -0
  2. loopcaptain/agents.py +162 -0
  3. loopcaptain/board/AGENTS.md +39 -0
  4. loopcaptain/board/__init__.py +0 -0
  5. loopcaptain/board/app.py +430 -0
  6. loopcaptain/board/discover.py +249 -0
  7. loopcaptain/board/files.py +94 -0
  8. loopcaptain/board/filters.py +48 -0
  9. loopcaptain/board/graph.py +139 -0
  10. loopcaptain/board/overview.py +59 -0
  11. loopcaptain/board/static/board.js +41 -0
  12. loopcaptain/board/static/fonts/OFL.txt +94 -0
  13. loopcaptain/board/static/fonts/inter-latin-wght-normal.woff2 +0 -0
  14. loopcaptain/board/static/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
  15. loopcaptain/board/static/mark.svg +5 -0
  16. loopcaptain/board/static/style.css +425 -0
  17. loopcaptain/board/steer.py +207 -0
  18. loopcaptain/board/templates/base.html +27 -0
  19. loopcaptain/board/templates/connect.html +35 -0
  20. loopcaptain/board/templates/file.html +9 -0
  21. loopcaptain/board/templates/loop.html +357 -0
  22. loopcaptain/board/templates/overview.html +108 -0
  23. loopcaptain/board/templates/unreadable.html +17 -0
  24. loopcaptain/board/trend.py +185 -0
  25. loopcaptain/cli.py +204 -0
  26. loopcaptain/demo/__init__.py +59 -0
  27. loopcaptain/demo/loop.py +311 -0
  28. loopcaptain/demo/loops.py +76 -0
  29. loopcaptain/mcp_server/AGENTS.md +23 -0
  30. loopcaptain/mcp_server/__init__.py +0 -0
  31. loopcaptain/mcp_server/server.py +181 -0
  32. loopcaptain/prompt_texts/build-a-loop.md +6 -0
  33. loopcaptain/prompt_texts/watch-my-loops.md +6 -0
  34. loopcaptain/prompts.py +27 -0
  35. loopcaptain/protocol/AGENTS.md +24 -0
  36. loopcaptain/protocol/LOOPS.md +402 -0
  37. loopcaptain/protocol/__init__.py +17 -0
  38. loopcaptain/protocol/doctor.py +79 -0
  39. loopcaptain/protocol/models.py +496 -0
  40. loopcaptain/protocol/signing.py +105 -0
  41. loopcaptain/protocol/state.py +595 -0
  42. loopcaptain/protocol/store.py +275 -0
  43. loopcaptain/protocol/validate.py +199 -0
  44. loopcaptain-0.3.1.dist-info/METADATA +220 -0
  45. loopcaptain-0.3.1.dist-info/RECORD +50 -0
  46. loopcaptain-0.3.1.dist-info/WHEEL +4 -0
  47. loopcaptain-0.3.1.dist-info/entry_points.txt +2 -0
  48. loopcaptain-0.3.1.dist-info/licenses/LICENSE +202 -0
  49. loopcaptain-0.3.1.dist-info/licenses/NOTICE +11 -0
  50. loopcaptain-0.3.1.dist-info/licenses/src/loopcaptain/board/static/fonts/OFL.txt +94 -0
File without changes
loopcaptain/agents.py ADDED
@@ -0,0 +1,162 @@
1
+ """How each coding agent adds the Loopcaptain MCP server and runs a loop without a person at the keyboard.
2
+
3
+ One source for the command line (`loopcaptain mcp-config <agent>`), the board's Connect page and the
4
+ "Works with every agent" page (`docs/agents.md`, written by `make agents`). Each entry was taken from
5
+ that agent's own documentation on the date in CHECKED.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ CHECKED = "2026-11-15"
15
+ #: The MCP server as a command and its arguments, run from a checkout; `{checkout}` is its folder.
16
+ SERVER = ("uv", "run", "--project", "{checkout}", "loopcaptain", "mcp")
17
+ #: The same, where the package is installed.
18
+ INSTALLED_SERVER = ("loopcaptain", "mcp")
19
+
20
+
21
+ def checkout_of(folder: Path) -> str:
22
+ """The folder of this repository if the code runs from a checkout of it, else "" (installed)."""
23
+ return str(folder) if (folder / "pyproject.toml").is_file() and (folder / "standard").is_dir() else ""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Agent:
28
+ key: str
29
+ name: str
30
+ reads: str # the instruction files it loads by itself
31
+ add: str # how to add the server: a command, or the file and block
32
+ headless: str # the command to put in commands.start for an unattended loop
33
+
34
+
35
+ def server_argv(checkout: str) -> list[str]:
36
+ if not checkout:
37
+ return list(INSTALLED_SERVER)
38
+ return [part.format(checkout=checkout) for part in SERVER]
39
+
40
+
41
+ def mcp_servers_json(checkout: str, top: str = "mcpServers", stdio_type: bool = False) -> str:
42
+ argv = server_argv(checkout)
43
+ entry: dict[str, object] = {"command": argv[0], "args": argv[1:]}
44
+ if stdio_type:
45
+ entry = {"type": "stdio", **entry}
46
+ return json.dumps({top: {"loopcaptain": entry}}, indent=2)
47
+
48
+
49
+ def agents(checkout: str) -> list[Agent]:
50
+ command = " ".join(server_argv(checkout))
51
+ argv = server_argv(checkout)
52
+ opencode = json.dumps(
53
+ {"mcp": {"loopcaptain": {"type": "local", "command": argv, "enabled": True}}}, indent=2
54
+ )
55
+ codex_toml = f'[mcp_servers.loopcaptain]\ncommand = "{argv[0]}"\nargs = {json.dumps(argv[1:])}'
56
+ return [
57
+ Agent(
58
+ "claude-code",
59
+ "Claude Code",
60
+ "`CLAUDE.md` (import `AGENTS.md` with a line `@AGENTS.md`)",
61
+ f"claude mcp add loopcaptain -- {command}",
62
+ 'claude -p "Run one round of the loop as AGENTS.md says" --output-format json',
63
+ ),
64
+ Agent(
65
+ "codex",
66
+ "Codex CLI",
67
+ "`AGENTS.md`",
68
+ f"codex mcp add loopcaptain -- {command}\n\n# or in ~/.codex/config.toml:\n{codex_toml}",
69
+ 'codex exec --json "Run one round of the loop as AGENTS.md says"',
70
+ ),
71
+ Agent(
72
+ "gemini",
73
+ "Gemini CLI",
74
+ "`GEMINI.md` (import `AGENTS.md` with a line `@AGENTS.md`)",
75
+ f"gemini mcp add loopcaptain {command}\n\n"
76
+ f"# or in .gemini/settings.json:\n{mcp_servers_json(checkout)}",
77
+ 'gemini -p "Run one round of the loop as AGENTS.md says" --output-format json',
78
+ ),
79
+ Agent(
80
+ "cursor",
81
+ "Cursor",
82
+ "`AGENTS.md`, `.cursor/rules/`",
83
+ f"# .cursor/mcp.json\n{mcp_servers_json(checkout)}",
84
+ 'cursor-agent -p "Run one round of the loop as AGENTS.md says" --output-format json',
85
+ ),
86
+ Agent(
87
+ "vscode",
88
+ "VS Code (Copilot agent mode)",
89
+ "`AGENTS.md`, `.github/copilot-instructions.md`",
90
+ f"# .vscode/mcp.json\n{mcp_servers_json(checkout, top='servers', stdio_type=True)}",
91
+ "none: start the loop another way",
92
+ ),
93
+ Agent(
94
+ "opencode",
95
+ "opencode",
96
+ "`AGENTS.md`",
97
+ f"# opencode.json\n{opencode}",
98
+ 'opencode run "Run one round of the loop as AGENTS.md says"',
99
+ ),
100
+ Agent(
101
+ "amp",
102
+ "Amp",
103
+ "`AGENTS.md`",
104
+ f"amp mcp add loopcaptain -- {command}",
105
+ 'amp -x "Run one round of the loop as AGENTS.md says"',
106
+ ),
107
+ Agent(
108
+ "other",
109
+ "Any other agent with MCP",
110
+ "its own instruction file; point it at `AGENTS.md`",
111
+ f"# the usual mcpServers block, in the file the agent reads\n{mcp_servers_json(checkout)}",
112
+ "the agent's own non-interactive command",
113
+ ),
114
+ Agent(
115
+ "none",
116
+ "An agent without MCP, or a script",
117
+ "-",
118
+ "no server: copy `standard/loops.py` into the loop,\n"
119
+ "or call `loopcaptain emit <loop> <event> key=value`",
120
+ "the script itself",
121
+ ),
122
+ ]
123
+
124
+
125
+ def agent(key: str, checkout: str) -> Agent:
126
+ found = next((item for item in agents(checkout) if item.key == key), None)
127
+ if found is None:
128
+ raise KeyError(f"no agent {key!r}; one of {', '.join(item.key for item in agents(checkout))}")
129
+ return found
130
+
131
+
132
+ def markdown(checkout: str = "<checkout>") -> str:
133
+ """The "Works with every agent" page."""
134
+ parts = [
135
+ "# Works with every agent",
136
+ "",
137
+ "The protocol names no vendor: a loop writes its files, whatever runs it. Around it, each",
138
+ "agent adds the MCP server its own way. The server is `loopcaptain mcp`, over stdio; from a",
139
+ f"checkout it is `{' '.join(server_argv(checkout))}`.",
140
+ "",
141
+ f"Taken from each agent's documentation as of {CHECKED}. Agents change their commands: where a",
142
+ "line fails, the agent's own documentation wins, and a report of what works is welcome.",
143
+ "",
144
+ "This page is written by `make agents` from `src/loopcaptain/agents.py`; edit that, not this.",
145
+ "`loopcaptain mcp-config <agent>` prints one block.",
146
+ ]
147
+ for item in agents(checkout):
148
+ parts += [
149
+ "",
150
+ f"## {item.name}",
151
+ "",
152
+ f"Reads by itself: {item.reads}.",
153
+ "",
154
+ "Add the server:",
155
+ "",
156
+ "```text",
157
+ item.add,
158
+ "```",
159
+ "",
160
+ f"Unattended, as `commands.start`: `{item.headless}`",
161
+ ]
162
+ return "\n".join(parts) + "\n"
@@ -0,0 +1,39 @@
1
+ # board/: the front end
2
+
3
+ Read [the root AGENTS.md](../../../AGENTS.md) first. This folder is the
4
+ dashboard: a FastAPI app that renders pages on the server from the loops'
5
+ folders. No build step, no JavaScript framework, no database.
6
+
7
+ | File | Holds |
8
+ |---|---|
9
+ | `app.py` | The routes, the host and token guards, the content policy |
10
+ | `files.py` | Which files of a repository the viewer opens: the deny-list and the text suffixes |
11
+ | `filters.py` | How times, money, tokens and places read on a page |
12
+ | `overview.py` | The first page: loops grouped by repository, the text and status filters |
13
+ | `trend.py` | The small SVG line per measure and the bars against the caps |
14
+ | `discover.py` | Which repositories are watched (`loopcaptain.local.yaml`) and loading a loop into a `LoopView` |
15
+ | `graph.py` | The picture of one round: each step gets a column and a row; the template draws the SVG |
16
+ | `steer.py` | `Runner`: starts the one command a loop declares, remembers its pid, tails its log, ends it |
17
+ | `templates/` | `overview.html` (all loops), `loop.html` (one loop: the tabs Overview, Rounds, Open findings, Items, What you sent, chosen by `?tab=`), `unreadable.html` (a loop whose `loop.json` cannot be read), `file.html` (a file of the loop), `connect.html` (the standard), `base.html` |
18
+ | `static/` | `style.css` (the design tokens and every component), `mark.svg` (the logo, its one source), `board.js` (reload on change, copy button), `fonts/` (Inter and Inter Tight, vendored, SIL OFL) |
19
+
20
+ Rules here:
21
+
22
+ 1. Pages are read fresh on every request; add no cache.
23
+ 2. No inline `<script>`, `style=` or `on...=` in a template, and nothing
24
+ from another origin: the content policy blocks it and
25
+ `tests/test_board.py` checks it.
26
+ 3. Design: the tokens at the top of `style.css` are the whole palette,
27
+ type scale (12/14/16/20/26/36) and spacing (4/8 point). One action
28
+ colour, iris: links, the answer button and "waiting for you", nothing
29
+ decorative. Status colours only for status, and every status also has
30
+ its own shape (dot, ring, bar, square), so the page reads without
31
+ colour. Open findings are an ink ramp, darker for more. Dark mode only
32
+ redefines tokens. Controls are at least 44 px high; text keeps 4.5:1.
33
+ No company name, logo or colour anywhere: the board is neutral.
34
+ 4. A route that changes anything is a POST form with the hidden `token`
35
+ and calls `check_token` first.
36
+ 5. A file is shown only through `inside()`, which refuses any path that
37
+ leaves the repository. Markdown renders with raw HTML off.
38
+ 6. After a visible change, look at it: `make demo`, `make serve`, and
39
+ retake the README screenshots if the page changed.
File without changes
@@ -0,0 +1,430 @@
1
+ """The board: server-rendered pages over the loops' folders, on this machine only.
2
+
3
+ Every request reads the files again; there is no cache and no database. Requests that change
4
+ anything (a reply, a stop, a start) are forms that carry the token of this server process, and
5
+ every request must name this server as its host, so a web page open in the same browser cannot
6
+ steer a loop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hmac
12
+ import logging
13
+ import secrets
14
+ from collections.abc import Awaitable, Callable
15
+ from pathlib import Path
16
+ from typing import Annotated
17
+ from urllib.parse import quote
18
+
19
+ from fastapi import FastAPI, Form, HTTPException, Request
20
+ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
21
+ from fastapi.staticfiles import StaticFiles
22
+ from fastapi.templating import Jinja2Templates
23
+ from markdown_it import MarkdownIt
24
+ from markupsafe import Markup
25
+
26
+ from loopcaptain.agents import agents, checkout_of
27
+ from loopcaptain.board.discover import (
28
+ Config,
29
+ LoopView,
30
+ Repo,
31
+ find_loop,
32
+ find_repo,
33
+ find_repos,
34
+ git_branch,
35
+ load_config,
36
+ load_loop,
37
+ )
38
+ from loopcaptain.board.files import MAX_FILE_BYTES, TEXT_SUFFIXES, inside
39
+ from loopcaptain.board.filters import ago, minutes, place, stamp, tokens, usd
40
+ from loopcaptain.board.graph import layout
41
+ from loopcaptain.board.overview import filter_views, projects_of
42
+ from loopcaptain.board.steer import Runner
43
+ from loopcaptain.board.trend import caps_of, charts_of, figure, trail_of
44
+ from loopcaptain.prompts import prompts
45
+ from loopcaptain.protocol import spec_text, store
46
+ from loopcaptain.protocol.models import Manifest
47
+ from loopcaptain.protocol.signing import board_key, is_signed, read_key
48
+ from loopcaptain.protocol.state import STATUS_TEXT, LoopState, no_progress_signs
49
+ from loopcaptain.protocol.store import StoreError
50
+ from loopcaptain.protocol.validate import validate_loop
51
+
52
+ log = logging.getLogger("loopcaptain.web")
53
+
54
+ HERE = Path(__file__).parent
55
+ HOST = "127.0.0.1"
56
+ MAX_REPLY_CHARS = 20_000
57
+ #: The parts of a loop's page, each with its own address: ?tab=<name>.
58
+ TABS = ("overview", "rounds", "findings", "items", "inbox")
59
+ #: The two parts of the first page: every loop under its project, and what waits for the person.
60
+ OVERVIEW_TABS = ("loops", "waiting")
61
+ #: The statuses where "Stop after this round" means something: the loop may still start a round.
62
+ STOPPABLE = frozenset({"running", "sleeping", "stalled", "waiting", "idle"})
63
+ #: Overview order: what needs the person first, then what moves, then the rest.
64
+ STATUS_ORDER = (
65
+ "waiting",
66
+ "failed",
67
+ "stalled",
68
+ "running",
69
+ "sleeping",
70
+ "capped",
71
+ "idle",
72
+ "stopped",
73
+ "new",
74
+ "done",
75
+ )
76
+
77
+ #: Everything the pages load comes from this server: no inline script or style, no other origin.
78
+ CONTENT_POLICY = (
79
+ "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
80
+ )
81
+ NextHandler = Callable[[Request], Awaitable[Response]]
82
+
83
+ MARKDOWN = MarkdownIt("commonmark", {"html": False}).enable("table")
84
+
85
+
86
+ def make_templates() -> Jinja2Templates:
87
+ templates = Jinja2Templates(directory=HERE / "templates")
88
+ templates.env.filters.update(
89
+ ago=ago, usd=usd, minutes=minutes, tokens=tokens, stamp=stamp, place=place, figure=figure
90
+ )
91
+ templates.env.globals["status_text"] = STATUS_TEXT
92
+ templates.env.globals["stoppable"] = STOPPABLE
93
+ templates.env.globals["no_progress"] = no_progress_signs
94
+ templates.env.globals["trail_of"] = trail_of
95
+ return templates
96
+
97
+
98
+ def create_app(
99
+ config: Callable[[], Config] = load_config,
100
+ runner: Runner | None = None,
101
+ *,
102
+ allowed_hosts: frozenset[str],
103
+ ) -> FastAPI:
104
+ """The board. `allowed_hosts` is required: a board that answers to any host name can be reached
105
+ by a web page that points its own name at this machine."""
106
+ app = FastAPI(title="Loopcaptain", docs_url=None, redoc_url=None, openapi_url=None)
107
+ templates = make_templates()
108
+ steer = runner or Runner()
109
+ token = secrets.token_urlsafe(24)
110
+ app.state.token = token
111
+ app.mount("/static", StaticFiles(directory=HERE / "static"), name="static")
112
+
113
+ @app.middleware("http")
114
+ async def only_this_host(
115
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
116
+ ) -> Response:
117
+ host = request.headers.get("host", "")
118
+ if host not in allowed_hosts:
119
+ return PlainTextResponse("this board answers only on its own address", status_code=400)
120
+ response = await call_next(request)
121
+ response.headers["Content-Security-Policy"] = CONTENT_POLICY
122
+ response.headers["X-Content-Type-Options"] = "nosniff"
123
+ response.headers["Referrer-Policy"] = "no-referrer"
124
+ response.headers["Cache-Control"] = "no-store"
125
+ return response
126
+
127
+ def check_token(given: str) -> None:
128
+ if not hmac.compare_digest(given.encode(), token.encode()):
129
+ raise HTTPException(403, "this form is from an older page; reload and send again")
130
+
131
+ def view_of(repo_key: str, loop_id: str) -> tuple[Repo, Manifest, LoopView]:
132
+ found = find_loop(config(), repo_key, loop_id)
133
+ if found is None:
134
+ raise HTTPException(404, "no such loop on this board")
135
+ repo, manifest = found
136
+ process = steer.process(repo, loop_id)
137
+ return repo, manifest, load_loop(repo, loop_id, process_alive=bool(process and process.alive))
138
+
139
+ def all_views() -> list[LoopView]:
140
+ views = []
141
+ for repo in find_repos(config()):
142
+ for loop_id in store.loop_ids(repo.path):
143
+ process = steer.process(repo, loop_id)
144
+ views.append(load_loop(repo, loop_id, process_alive=bool(process and process.alive)))
145
+ return views
146
+
147
+ def overview_fingerprint() -> str:
148
+ """From file stats alone, in discovery order: the overview polls it every few seconds."""
149
+ return "|".join(
150
+ store.fingerprint(repo.path, loop_id)
151
+ for repo in find_repos(config())
152
+ for loop_id in store.loop_ids(repo.path)
153
+ )
154
+
155
+ def seal(note: str, error: str) -> str:
156
+ """A mark only this server process can make, so a crafted link cannot put words on a page."""
157
+ return hmac.new(token.encode(), f"{note}\0{error}".encode(), "sha256").hexdigest()
158
+
159
+ def back(repo_key: str, loop_id: str, note: str = "", error: str = "") -> RedirectResponse:
160
+ url = str(app.url_path_for("loop_page", repo_key=repo_key, loop_id=loop_id))
161
+ if not (note or error):
162
+ return RedirectResponse(url, status_code=303)
163
+ pairs = (("note", note), ("error", error), ("seal", seal(note, error)))
164
+ query = "&".join(f"{key}={quote(value)}" for key, value in pairs if value)
165
+ return RedirectResponse(f"{url}?{query}", status_code=303)
166
+
167
+ def message_of(request: Request) -> tuple[str, str]:
168
+ note = request.query_params.get("note", "")
169
+ error = request.query_params.get("error", "")
170
+ given = request.query_params.get("seal", "")
171
+ return (note, error) if hmac.compare_digest(given.encode(), seal(note, error).encode()) else ("", "")
172
+
173
+ @app.get("/", response_class=HTMLResponse, name="overview")
174
+ def overview(
175
+ request: Request, tab: str = "", q: str = "", status: str = "", finished: str = ""
176
+ ) -> Response:
177
+ try:
178
+ cfg = config()
179
+ views = all_views()
180
+ error = ""
181
+ except StoreError as problem:
182
+ cfg, views, error = Config(), [], str(problem)
183
+
184
+ def rank(view: LoopView) -> tuple[int, float]:
185
+ status = view.state.status if view.state else "failed"
186
+ last = view.state.last_event if view.state else None
187
+ return STATUS_ORDER.index(status), -(last.timestamp() if last else 0.0)
188
+
189
+ views.sort(key=rank)
190
+ shown, hidden = filter_views(views, q, status, show_finished=finished == "1")
191
+ context = {
192
+ "views": views,
193
+ "projects": projects_of(shown),
194
+ "project_count": len({view.repo.key for view in views}),
195
+ "hidden": hidden,
196
+ "filters": {
197
+ "q": q,
198
+ "status": status if status in STATUS_ORDER else "",
199
+ "finished": finished == "1",
200
+ },
201
+ "statuses": STATUS_ORDER,
202
+ "prompts": prompts(),
203
+ "tab": tab if tab in OVERVIEW_TABS else "loops",
204
+ "waiting": [view for view in views if view.state and view.state.question],
205
+ "config": cfg,
206
+ "error": error,
207
+ "token": token,
208
+ "pulse": overview_fingerprint() if not error else "error",
209
+ "total_usd": sum(view.state.cost_usd for view in views if view.state),
210
+ }
211
+ return templates.TemplateResponse(request, "overview.html", context)
212
+
213
+ @app.get("/pulse", name="overview_pulse")
214
+ def overview_pulse() -> JSONResponse:
215
+ try:
216
+ return JSONResponse({"pulse": overview_fingerprint()})
217
+ except StoreError:
218
+ return JSONResponse({"pulse": "error"})
219
+
220
+ @app.get("/loop/{repo_key}/{loop_id}", response_class=HTMLResponse, name="loop_page")
221
+ def loop_page(
222
+ request: Request, repo_key: str, loop_id: str, tab: str = "", round: int | None = None
223
+ ) -> Response:
224
+ found = find_loop(config(), repo_key, loop_id)
225
+ broken = find_repo(config(), repo_key, loop_id) if found is None else None
226
+ if broken is not None:
227
+ # Listed, but its loop.json cannot be read: say what is wrong instead of a bare error.
228
+ problems = validate_loop(broken.path, loop_id).problems or ["loop.json cannot be read"]
229
+ context = {"repo": broken, "loop_id": loop_id, "problems": problems}
230
+ return templates.TemplateResponse(request, "unreadable.html", context)
231
+ repo, manifest, view = view_of(repo_key, loop_id)
232
+ note, error = message_of(request)
233
+ state = view.state
234
+ if state is None:
235
+ context = {"repo": repo, "loop_id": loop_id, "problems": [view.error]}
236
+ return templates.TemplateResponse(request, "unreadable.html", context)
237
+ shown = next((r for r in state.rounds if r.number == round), state.last_round)
238
+ process = steer.process(repo, loop_id)
239
+ context = {
240
+ "view": view,
241
+ "state": state,
242
+ "manifest": manifest,
243
+ "shown": shown,
244
+ # A link to a round opens the rounds tab; an unknown tab is the overview.
245
+ "tab": tab if tab in TABS else ("rounds" if round is not None else "overview"),
246
+ "graph": layout(manifest, shown) if manifest.steps else None,
247
+ "branch": git_branch(repo.path),
248
+ "process": process,
249
+ "log_tail": steer.log_tail(repo, loop_id) if process else "",
250
+ "confirmed": steer.is_confirmed(repo, manifest.commands.get("start", "")),
251
+ "signed": inbox_marks(state),
252
+ "charts": charts_of(state),
253
+ "stuck": no_progress_signs(state),
254
+ "report": validate_loop(repo.path, loop_id),
255
+ "caps": caps_of(state),
256
+ "token": token,
257
+ "pulse": pulse_of(repo, loop_id, steer),
258
+ "note": note,
259
+ "error": error,
260
+ }
261
+ return templates.TemplateResponse(request, "loop.html", context)
262
+
263
+ @app.get("/loop/{repo_key}/{loop_id}/pulse", name="loop_pulse")
264
+ def loop_pulse(repo_key: str, loop_id: str) -> JSONResponse:
265
+ found = find_loop(config(), repo_key, loop_id)
266
+ if found is None:
267
+ raise HTTPException(404, "no such loop on this board")
268
+ return JSONResponse({"pulse": pulse_of(found[0], loop_id, steer)})
269
+
270
+ @app.get("/loop/{repo_key}/{loop_id}/file", response_class=HTMLResponse, name="loop_file")
271
+ def loop_file(request: Request, repo_key: str, loop_id: str, path: str) -> Response:
272
+ repo, manifest, view = view_of(repo_key, loop_id)
273
+ target = inside(repo.path, path)
274
+ size = target.stat().st_size
275
+ body: Markup | None = None
276
+ text = ""
277
+ reason = ""
278
+ if target.suffix.lower() not in TEXT_SUFFIXES:
279
+ reason = "not shown: the board opens only text files it knows"
280
+ elif size > MAX_FILE_BYTES:
281
+ reason = f"{size // 1000} kB: too large to show here"
282
+ else:
283
+ try:
284
+ text = target.read_text(encoding="utf-8")
285
+ except UnicodeDecodeError:
286
+ reason = "not a text file"
287
+ if not reason and target.suffix.lower() in {".md", ".markdown"}:
288
+ body = Markup(MARKDOWN.render(text))
289
+ context = {
290
+ "view": view,
291
+ "manifest": manifest,
292
+ "path": path,
293
+ "body": body,
294
+ "text": text,
295
+ "reason": reason,
296
+ }
297
+ return templates.TemplateResponse(request, "file.html", context)
298
+
299
+ @app.post("/loop/{repo_key}/{loop_id}/reply", name="loop_reply")
300
+ def loop_reply(
301
+ repo_key: str,
302
+ loop_id: str,
303
+ token: Annotated[str, Form()],
304
+ text: Annotated[str, Form()] = "",
305
+ choice: Annotated[str, Form()] = "",
306
+ ) -> Response:
307
+ check_token(token)
308
+ repo, _, view = view_of(repo_key, loop_id)
309
+ asked = view.state.question if view.state else None
310
+ picked = next((option for option in asked.options if option.id == choice), None) if asked else None
311
+ if choice and picked is None:
312
+ return back(repo_key, loop_id, error="Nothing was sent: that choice is no longer offered.")
313
+ message = text.strip() or (picked.label if picked else "")
314
+ if not message:
315
+ return back(repo_key, loop_id, error="Nothing was sent: the reply was empty.")
316
+ if len(message) > MAX_REPLY_CHARS:
317
+ return back(repo_key, loop_id, error=f"Nothing was sent: over {MAX_REPLY_CHARS} characters.")
318
+ question = view.state.question if view.state else None
319
+ last = view.state.last_round if view.state else None
320
+ round_ = question.round if question and question.round else (last.number if last else None)
321
+ try:
322
+ store.append_inbox(
323
+ repo.path,
324
+ loop_id,
325
+ "reply",
326
+ message,
327
+ round_,
328
+ key=board_key(),
329
+ question=asked.id if asked else None,
330
+ choice=picked.id if picked else None,
331
+ )
332
+ except StoreError as problem:
333
+ return back(repo_key, loop_id, error=str(problem))
334
+ return back(repo_key, loop_id, note="Sent. The loop reads it at its next round.")
335
+
336
+ @app.post("/loop/{repo_key}/{loop_id}/stop", name="loop_stop")
337
+ def loop_stop(repo_key: str, loop_id: str, token: Annotated[str, Form()]) -> Response:
338
+ check_token(token)
339
+ repo, _, view = view_of(repo_key, loop_id)
340
+ if view.state and view.state.stop_requested:
341
+ return back(repo_key, loop_id, note="A stop is already waiting for the loop.")
342
+ try:
343
+ store.append_inbox(repo.path, loop_id, "stop", key=board_key())
344
+ except StoreError as problem:
345
+ return back(repo_key, loop_id, error=str(problem))
346
+ return back(repo_key, loop_id, note="Stop sent. The loop ends after its current round.")
347
+
348
+ @app.post("/loop/{repo_key}/{loop_id}/start", name="loop_start")
349
+ def loop_start(
350
+ repo_key: str,
351
+ loop_id: str,
352
+ token: Annotated[str, Form()],
353
+ command: Annotated[str, Form()] = "",
354
+ confirm: Annotated[str, Form()] = "",
355
+ ) -> Response:
356
+ check_token(token)
357
+ repo, manifest, _ = view_of(repo_key, loop_id)
358
+ if command != manifest.commands.get("start", ""):
359
+ # The loop rewrote its command after the page was drawn: run only what the person saw.
360
+ return back(
361
+ repo_key, loop_id, error="Not started: the start command changed. Check it and press again."
362
+ )
363
+ first = not steer.is_confirmed(repo, command)
364
+ if first and confirm != "yes":
365
+ return back(
366
+ repo_key,
367
+ loop_id,
368
+ error="Not started: first start of this command. Read it, tick the box, press again.",
369
+ )
370
+ try:
371
+ process = steer.start(repo, manifest)
372
+ except StoreError as problem:
373
+ return back(repo_key, loop_id, error=f"Not started: {problem}.")
374
+ if first:
375
+ steer.confirm(repo, command)
376
+ return back(repo_key, loop_id, note=f"Started: {process.command} (pid {process.pid}).")
377
+
378
+ @app.post("/loop/{repo_key}/{loop_id}/kill", name="loop_kill")
379
+ def loop_kill(repo_key: str, loop_id: str, token: Annotated[str, Form()]) -> Response:
380
+ check_token(token)
381
+ repo, _, _ = view_of(repo_key, loop_id)
382
+ try:
383
+ steer.kill(repo, loop_id)
384
+ except StoreError as problem:
385
+ return back(repo_key, loop_id, error=f"{problem}.".capitalize())
386
+ return back(repo_key, loop_id, note="The process was told to end.")
387
+
388
+ @app.get("/connect", response_class=HTMLResponse, name="connect")
389
+ def connect(request: Request, agent: str = "") -> Response:
390
+ spec = spec_text()
391
+ known = agents(checkout_of(HERE.parents[2]))
392
+ picked = next((item for item in known if item.key == agent), known[0])
393
+ context = {
394
+ "spec": spec,
395
+ "body": Markup(MARKDOWN.render(spec)),
396
+ "agents": known,
397
+ "picked": picked,
398
+ "prompts": prompts(),
399
+ }
400
+ return templates.TemplateResponse(request, "connect.html", context)
401
+
402
+ @app.get("/connect/LOOPS.md", name="spec_file")
403
+ def spec_file() -> PlainTextResponse:
404
+ return PlainTextResponse(spec_text(), media_type="text/markdown; charset=utf-8")
405
+
406
+ return app
407
+
408
+
409
+ def inbox_marks(state: LoopState) -> dict[str, bool | None]:
410
+ """For each inbox entry, whether this board signed it; None where the board has no key yet."""
411
+ key = read_key()
412
+ return {item.entry.id: is_signed(item.entry.body(), key, state.manifest.id) for item in state.inbox}
413
+
414
+
415
+ def pulse_of(repo: Repo, loop_id: str, steer: Runner) -> str:
416
+ process = steer.process(repo, loop_id)
417
+ alive = "1" if process and process.alive else "0"
418
+ try:
419
+ log_size = steer.log_path(repo, loop_id).stat().st_size
420
+ except OSError:
421
+ log_size = 0
422
+ return f"{store.fingerprint(repo.path, loop_id)}.{alive}.{log_size}"
423
+
424
+
425
+ def serve(port: int) -> None:
426
+ import uvicorn
427
+
428
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
429
+ hosts = frozenset({f"{HOST}:{port}", f"localhost:{port}"})
430
+ uvicorn.run(create_app(allowed_hosts=hosts), host=HOST, port=port, log_level="warning")