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
@@ -0,0 +1,207 @@
1
+ """Steering a loop from the board: the inbox, and starting the one command a loop declares.
2
+
3
+ The board runs nothing a browser typed. `start` runs `commands.start` from the loop's own
4
+ `loop.json`, split into arguments and run without a shell, in the repository root, with a
5
+ minimal environment: the board's own variables (keys, tokens) do not travel to a command a
6
+ watched repository wrote. The first start of a given command in a given repository needs the
7
+ person's tick; the hash of what they confirmed is kept in the state folder.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import logging
15
+ import os
16
+ import shlex
17
+ import signal
18
+ import subprocess
19
+ import time
20
+ from dataclasses import dataclass
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+
24
+ from loopcaptain.board.discover import Repo
25
+ from loopcaptain.protocol.models import Manifest, now
26
+ from loopcaptain.protocol.store import StoreError
27
+
28
+ log = logging.getLogger("loopcaptain.steer")
29
+
30
+ STATE_ENV = "LOOPCAPTAIN_STATE"
31
+ MAX_PID = 2**31
32
+ LOG_TAIL_LINES = 30
33
+ LOG_TAIL_BYTES = 20_000
34
+ #: The only variables a started loop inherits from the board.
35
+ PASSED_ENV = ("PATH", "HOME", "LANG", "TERM", "LOOPCAPTAIN_INBOX_KEY")
36
+ CONFIRMED_FILE = "confirmed.json"
37
+ PRIVATE_MODE = 0o600
38
+ #: How far two readings of the boot time may differ and still be the same boot.
39
+ BOOT_TOLERANCE_S = 120.0
40
+
41
+
42
+ def boot_time() -> float:
43
+ """When this machine started, in epoch seconds, from the clock that counts since boot.
44
+
45
+ A pid recorded before a reboot can belong to another process now; comparing boot times
46
+ tells the two apart without running anything (the board runs nothing but `commands.start`).
47
+ """
48
+ clock = getattr(time, "CLOCK_BOOTTIME", time.CLOCK_MONOTONIC)
49
+ return time.time() - time.clock_gettime(clock)
50
+
51
+
52
+ def minimal_env() -> dict[str, str]:
53
+ """The environment a started loop gets: enough to find programs and its home, nothing else."""
54
+ return {name: os.environ[name] for name in PASSED_ENV if name in os.environ}
55
+
56
+
57
+ def command_hash(repo: Repo, command: str) -> str:
58
+ return hashlib.sha256(f"{repo.path.resolve()}\0{command}".encode()).hexdigest()
59
+
60
+
61
+ def write_private(path: Path, text: str) -> None:
62
+ """Write a file only this user can read: the board's state names repositories and commands."""
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE_MODE)
65
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
66
+ handle.write(text)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Process:
71
+ pid: int
72
+ command: str
73
+ started: datetime
74
+ alive: bool
75
+
76
+
77
+ class Runner:
78
+ """Starts loops and remembers them: in memory while the board runs, in a small file across restarts."""
79
+
80
+ def __init__(self, state_dir: Path | None = None) -> None:
81
+ self.state_dir = state_dir or Path(os.environ.get(STATE_ENV, ".state"))
82
+ self.children: dict[str, subprocess.Popen[bytes]] = {}
83
+
84
+ def name(self, repo: Repo, loop_id: str) -> str:
85
+ return f"{repo.key}~{loop_id}"
86
+
87
+ def record_path(self, repo: Repo, loop_id: str) -> Path:
88
+ return self.state_dir / f"{self.name(repo, loop_id)}.json"
89
+
90
+ def log_path(self, repo: Repo, loop_id: str) -> Path:
91
+ return self.state_dir / f"{self.name(repo, loop_id)}.log"
92
+
93
+ def confirmed_hashes(self) -> frozenset[str]:
94
+ try:
95
+ listed = json.loads((self.state_dir / CONFIRMED_FILE).read_text(encoding="utf-8"))
96
+ except FileNotFoundError:
97
+ return frozenset()
98
+ except (OSError, ValueError) as error:
99
+ log.warning("unreadable %s, every command needs a tick again: %s", CONFIRMED_FILE, error)
100
+ return frozenset()
101
+ return frozenset(str(item) for item in listed) if isinstance(listed, list) else frozenset()
102
+
103
+ def is_confirmed(self, repo: Repo, command: str) -> bool:
104
+ """Whether the person has started this exact command in this repository before."""
105
+ return command_hash(repo, command) in self.confirmed_hashes()
106
+
107
+ def confirm(self, repo: Repo, command: str) -> None:
108
+ hashes = sorted(self.confirmed_hashes() | {command_hash(repo, command)})
109
+ write_private(self.state_dir / CONFIRMED_FILE, json.dumps(hashes) + "\n")
110
+
111
+ def process(self, repo: Repo, loop_id: str) -> Process | None:
112
+ """The process the board last started for this loop, and whether it still runs."""
113
+ try:
114
+ record = json.loads(self.record_path(repo, loop_id).read_text(encoding="utf-8"))
115
+ pid, command = int(record["pid"]), str(record["command"])
116
+ started = datetime.fromisoformat(str(record["started"]))
117
+ if not 1 < pid < MAX_PID:
118
+ # 0 and negative numbers name process groups, the board's own among them.
119
+ raise ValueError(f"pid {pid} is not a process the board could have started")
120
+ boot = record.get("boot")
121
+ same_boot = boot is None or abs(float(boot) - boot_time()) < BOOT_TOLERANCE_S
122
+ except FileNotFoundError:
123
+ return None
124
+ except (OSError, ValueError, KeyError, TypeError) as error:
125
+ log.warning("unreadable process record for %s: %s", self.name(repo, loop_id), error)
126
+ return None
127
+ return Process(pid, command, started, same_boot and self.is_alive(self.name(repo, loop_id), pid))
128
+
129
+ def is_alive(self, name: str, pid: int) -> bool:
130
+ child = self.children.get(name)
131
+ if child is not None and child.pid == pid:
132
+ return child.poll() is None
133
+ try:
134
+ os.kill(pid, 0) # after a board restart: asks only whether the pid exists
135
+ except ProcessLookupError:
136
+ return False
137
+ except PermissionError:
138
+ return False # the pid now belongs to someone else's process
139
+ return True
140
+
141
+ def start(self, repo: Repo, manifest: Manifest) -> Process:
142
+ command = manifest.commands.get("start", "").strip()
143
+ if not command:
144
+ raise StoreError("this loop declares no start command in loop.json")
145
+ current = self.process(repo, manifest.id)
146
+ if current and current.alive:
147
+ raise StoreError(f"already running (pid {current.pid})")
148
+ try:
149
+ argv = shlex.split(command)
150
+ except ValueError as error:
151
+ raise StoreError(f"start command cannot be read: {error}") from error
152
+ if not argv:
153
+ raise StoreError("start command is empty")
154
+ self.state_dir.mkdir(parents=True, exist_ok=True)
155
+ started = now()
156
+ descriptor = os.open(
157
+ self.log_path(repo, manifest.id), os.O_WRONLY | os.O_CREAT | os.O_APPEND, PRIVATE_MODE
158
+ )
159
+ with os.fdopen(descriptor, "ab") as out:
160
+ out.write(f"\n$ {command} ({started:%Y-%m-%d %H:%M} UTC)\n".encode())
161
+ out.flush()
162
+ try:
163
+ child = subprocess.Popen(
164
+ argv,
165
+ cwd=repo.path,
166
+ env=minimal_env(),
167
+ stdin=subprocess.DEVNULL,
168
+ stdout=out,
169
+ stderr=subprocess.STDOUT,
170
+ start_new_session=True, # its own group: closing the board does not kill the loop
171
+ )
172
+ except OSError as error:
173
+ raise StoreError(f"could not start {argv[0]!r}: {error.strerror or error}") from error
174
+ name = self.name(repo, manifest.id)
175
+ self.children[name] = child
176
+ record = {"pid": child.pid, "command": command, "started": started.isoformat(), "boot": boot_time()}
177
+ write_private(self.record_path(repo, manifest.id), json.dumps(record) + "\n")
178
+ log.info("started %s: %s (pid %d)", name, command, child.pid)
179
+ return Process(child.pid, command, started, True)
180
+
181
+ def kill(self, repo: Repo, loop_id: str) -> None:
182
+ """End the process the board started, and its children, now. The polite way is an inbox `stop`."""
183
+ current = self.process(repo, loop_id)
184
+ if not current or not current.alive:
185
+ raise StoreError("nothing the board started is running")
186
+ try:
187
+ os.killpg(current.pid, signal.SIGTERM)
188
+ except ProcessLookupError:
189
+ return
190
+ except PermissionError as error:
191
+ raise StoreError(f"may not end pid {current.pid}") from error
192
+ log.info("ended %s (pid %d)", self.name(repo, loop_id), current.pid)
193
+
194
+ def log_tail(self, repo: Repo, loop_id: str) -> str:
195
+ path = self.log_path(repo, loop_id)
196
+ try:
197
+ with path.open("rb") as handle:
198
+ handle.seek(0, os.SEEK_END)
199
+ size = handle.tell()
200
+ handle.seek(max(0, size - LOG_TAIL_BYTES))
201
+ text = handle.read().decode("utf-8", errors="replace")
202
+ except FileNotFoundError:
203
+ return ""
204
+ except OSError as error:
205
+ log.warning("cannot read %s: %s", path.name, error)
206
+ return ""
207
+ return "\n".join(text.splitlines()[-LOG_TAIL_LINES:])
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>{% block title %}Loopcaptain{% endblock %}</title>
8
+ <link rel="icon" type="image/svg+xml" href="{{ url_for('static', path='mark.svg') }}">
9
+ <link rel="preload" href="{{ url_for('static', path='fonts/inter-latin-wght-normal.woff2') }}" as="font" type="font/woff2" crossorigin>
10
+ <link rel="stylesheet" href="{{ url_for('static', path='style.css') }}">
11
+ <script src="{{ url_for('static', path='board.js') }}" defer></script>
12
+ </head>
13
+ <body{% block body_attrs %}{% endblock %}>
14
+ <header class="top">
15
+ <a class="brand" href="{{ url_for('overview') }}" aria-label="Loopcaptain, all loops"><img src="{{ url_for('static', path='mark.svg') }}" alt="" width="28" height="28">Loopcaptain</a>
16
+ <span class="where">{% block where %}{% endblock %}</span>
17
+ <nav class="strip" aria-label="Pages">
18
+ <a href="{{ url_for('overview') }}"{% if page == 'overview' %} aria-current="page"{% endif %}>Loops</a>
19
+ <a href="{{ url_for('connect') }}"{% if page == 'connect' %} aria-current="page"{% endif %}>Connect a loop</a>
20
+ </nav>
21
+ </header>
22
+ <main>
23
+ {% block main %}{% endblock %}
24
+ </main>
25
+ <footer class="foot">Reads each loop's <code>.loops/</code> folder on every request. Answers only on this machine.</footer>
26
+ </body>
27
+ </html>
@@ -0,0 +1,35 @@
1
+ {% extends "base.html" %}
2
+ {% set page = 'connect' %}
3
+ {% block title %}Connect a loop · Loopcaptain{% endblock %}
4
+ {% block main %}
5
+ <p class="eyebrow">Connect a loop</p>
6
+ <h1>A loop writes a few small files. That is the whole interface.</h1>
7
+ <p class="lede">Design the loop however you like. To show it here it says what it is and what happens, in one folder. Nothing to install, no server to reach.</p>
8
+ <section class="start-here" aria-label="Get started with your coding agent">
9
+ <h2>Start with your coding agent</h2>
10
+ <div class="prompts">
11
+ <div class="card"><p class="eyebrow">Build a loop</p><pre class="prompt" id="prompt-build">{{ prompts['build-a-loop'] }}</pre>
12
+ <p class="act"><button type="button" class="quiet" data-copy-from="prompt-build-text">Copy</button><span class="grey" data-copied hidden role="status">Copied.</span></p></div>
13
+ <div class="card"><p class="eyebrow">Watch the loops on this machine</p><pre class="prompt" id="prompt-watch">{{ prompts['watch-my-loops'] }}</pre>
14
+ <p class="act"><button type="button" class="quiet" data-copy-from="prompt-watch-text">Copy</button><span class="grey" data-copied hidden role="status">Copied.</span></p></div>
15
+ </div>
16
+ </section>
17
+ <textarea id="prompt-build-text" hidden readonly aria-hidden="true">{{ prompts['build-a-loop'] }}</textarea>
18
+ <textarea id="prompt-watch-text" hidden readonly aria-hidden="true">{{ prompts['watch-my-loops'] }}</textarea>
19
+ <section class="how">
20
+ <div class="card"><h3><b class="n">1</b>A loop with a script</h3>
21
+ <p>Give <a href="{{ url_for('spec_file') }}">LOOPS.md</a> to the agent that works on the loop, or paste it into the loop's repository. It holds the format and a helper of 60 lines, standard library only.</p>
22
+ <p class="act"><button type="button" class="quiet" data-copy-from="spec-text">Copy LOOPS.md</button><span class="grey" data-copied hidden role="status">Copied.</span></p></div>
23
+ <div class="card"><h3><b class="n">2</b>A loop an agent session drives</h3>
24
+ <p>Add the MCP server, <code>loopcaptain mcp</code> over stdio, to your agent. Its tools write the same files. Pick yours:</p>
25
+ <nav class="agents" aria-label="Coding agents">{% for item in agents %}<a href="{{ url_for('connect') }}?agent={{ item.key }}#mcp"{% if item.key == picked.key %} aria-current="true"{% endif %}>{{ item.name }}</a>{% endfor %}</nav>
26
+ <pre id="mcp">{{ picked.add }}</pre>
27
+ <p class="grey">Unattended, as <code>commands.start</code>: <code>{{ picked.headless }}</code></p></div>
28
+ <div class="card"><h3><b class="n">3</b>Show it here</h3>
29
+ <p>The board lists every repository it watches that has a <code>.loops/</code> folder. Add one with <code>loopcaptain watch &lt;folder&gt;</code>, and check it with:</p>
30
+ <pre>loopcaptain validate &lt;repository&gt;</pre></div>
31
+ </section>
32
+ <textarea id="spec-text" hidden readonly aria-hidden="true">{{ spec }}</textarea>
33
+ <h2>The standard</h2>
34
+ <article class="prose card">{{ body }}</article>
35
+ {% endblock %}
@@ -0,0 +1,9 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}{{ path }} · {{ view.title }}{% endblock %}
3
+ {% block where %}{{ view.repo.key }} / {{ view.loop_id }}{% endblock %}
4
+ {% block main %}
5
+ <p class="crumb"><a href="{{ url_for('overview') }}">Loops</a> / <a href="{{ url_for('loop_page', repo_key=view.repo.key, loop_id=view.loop_id) }}">{{ view.title }}</a> / {{ path }}</p>
6
+ {% if reason %}<p class="empty"><code>{{ path }}</code>: {{ reason }}.</p>
7
+ {% elif body is not none %}<article class="prose card">{{ body }}</article>
8
+ {% else %}<pre class="raw">{{ text }}</pre>{% endif %}
9
+ {% endblock %}