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.
- loopcaptain/__init__.py +0 -0
- loopcaptain/agents.py +162 -0
- loopcaptain/board/AGENTS.md +39 -0
- loopcaptain/board/__init__.py +0 -0
- loopcaptain/board/app.py +430 -0
- loopcaptain/board/discover.py +249 -0
- loopcaptain/board/files.py +94 -0
- loopcaptain/board/filters.py +48 -0
- loopcaptain/board/graph.py +139 -0
- loopcaptain/board/overview.py +59 -0
- loopcaptain/board/static/board.js +41 -0
- loopcaptain/board/static/fonts/OFL.txt +94 -0
- loopcaptain/board/static/fonts/inter-latin-wght-normal.woff2 +0 -0
- loopcaptain/board/static/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
- loopcaptain/board/static/mark.svg +5 -0
- loopcaptain/board/static/style.css +425 -0
- loopcaptain/board/steer.py +207 -0
- loopcaptain/board/templates/base.html +27 -0
- loopcaptain/board/templates/connect.html +35 -0
- loopcaptain/board/templates/file.html +9 -0
- loopcaptain/board/templates/loop.html +357 -0
- loopcaptain/board/templates/overview.html +108 -0
- loopcaptain/board/templates/unreadable.html +17 -0
- loopcaptain/board/trend.py +185 -0
- loopcaptain/cli.py +204 -0
- loopcaptain/demo/__init__.py +59 -0
- loopcaptain/demo/loop.py +311 -0
- loopcaptain/demo/loops.py +76 -0
- loopcaptain/mcp_server/AGENTS.md +23 -0
- loopcaptain/mcp_server/__init__.py +0 -0
- loopcaptain/mcp_server/server.py +181 -0
- loopcaptain/prompt_texts/build-a-loop.md +6 -0
- loopcaptain/prompt_texts/watch-my-loops.md +6 -0
- loopcaptain/prompts.py +27 -0
- loopcaptain/protocol/AGENTS.md +24 -0
- loopcaptain/protocol/LOOPS.md +402 -0
- loopcaptain/protocol/__init__.py +17 -0
- loopcaptain/protocol/doctor.py +79 -0
- loopcaptain/protocol/models.py +496 -0
- loopcaptain/protocol/signing.py +105 -0
- loopcaptain/protocol/state.py +595 -0
- loopcaptain/protocol/store.py +275 -0
- loopcaptain/protocol/validate.py +199 -0
- loopcaptain-0.3.1.dist-info/METADATA +220 -0
- loopcaptain-0.3.1.dist-info/RECORD +50 -0
- loopcaptain-0.3.1.dist-info/WHEEL +4 -0
- loopcaptain-0.3.1.dist-info/entry_points.txt +2 -0
- loopcaptain-0.3.1.dist-info/licenses/LICENSE +202 -0
- loopcaptain-0.3.1.dist-info/licenses/NOTICE +11 -0
- 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 <folder></code>, and check it with:</p>
|
|
30
|
+
<pre>loopcaptain validate <repository></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 %}
|