ramure 0.0.1__tar.gz

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 (44) hide show
  1. ramure-0.0.1/.gitignore +4 -0
  2. ramure-0.0.1/PKG-INFO +207 -0
  3. ramure-0.0.1/README.md +198 -0
  4. ramure-0.0.1/cli-plan.md +134 -0
  5. ramure-0.0.1/code-smells.md +169 -0
  6. ramure-0.0.1/considerations +59 -0
  7. ramure-0.0.1/examples/new/01_simple.py +26 -0
  8. ramure-0.0.1/examples/new/03_concurrent.py +40 -0
  9. ramure-0.0.1/examples/new/04_spawn_observe.py +50 -0
  10. ramure-0.0.1/examples/new/05_attach_endpoints.py +62 -0
  11. ramure-0.0.1/pyproject.toml +29 -0
  12. ramure-0.0.1/ramure/__init__.py +49 -0
  13. ramure-0.0.1/ramure/agent.py +68 -0
  14. ramure-0.0.1/ramure/cli.py +168 -0
  15. ramure-0.0.1/ramure/context.py +18 -0
  16. ramure-0.0.1/ramure/control.py +139 -0
  17. ramure-0.0.1/ramure/extension.py +10 -0
  18. ramure-0.0.1/ramure/extension.ts +377 -0
  19. ramure-0.0.1/ramure/helpers/__init__.py +17 -0
  20. ramure-0.0.1/ramure/helpers/agent.py +91 -0
  21. ramure-0.0.1/ramure/helpers/schema.py +81 -0
  22. ramure-0.0.1/ramure/log.py +169 -0
  23. ramure-0.0.1/ramure/machines.py +130 -0
  24. ramure-0.0.1/ramure/notes +3 -0
  25. ramure-0.0.1/ramure/process.py +359 -0
  26. ramure-0.0.1/ramure/runtime.py +262 -0
  27. ramure-0.0.1/ramure/server.py +142 -0
  28. ramure-0.0.1/ramure/stream.py +81 -0
  29. ramure-0.0.1/ramure/types.py +55 -0
  30. ramure-0.0.1/spec-replication.md +192 -0
  31. ramure-0.0.1/tests/__init__.py +0 -0
  32. ramure-0.0.1/tests/helpers.py +146 -0
  33. ramure-0.0.1/tests/test_context_runtime.py +391 -0
  34. ramure-0.0.1/tests/test_control.py +126 -0
  35. ramure-0.0.1/tests/test_e2e.py +151 -0
  36. ramure-0.0.1/tests/test_event_log.py +109 -0
  37. ramure-0.0.1/tests/test_events.py +98 -0
  38. ramure-0.0.1/tests/test_logging.py +1 -0
  39. ramure-0.0.1/tests/test_machines.py +19 -0
  40. ramure-0.0.1/tests/test_process.py +657 -0
  41. ramure-0.0.1/tests/test_schema.py +26 -0
  42. ramure-0.0.1/tests/test_spawn_readiness.py +126 -0
  43. ramure-0.0.1/uv.lock +225 -0
  44. ramure-0.0.1/wasm-2026-report.md +52 -0
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ logs/
ramure-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: ramure
3
+ Version: 0.0.1
4
+ Summary: A multi-agent orchestration runtime
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: typer>=0.12
7
+ Requires-Dist: websockets>=13.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # ramure
11
+
12
+ A lightweight async multi-agent orchestration library. Agents run as [pi](https://github.com/mariozechner/pi-coding-agent) instances in tmux sessions, coordinated by Python process functions.
13
+
14
+ ## Quick start
15
+
16
+ ```python
17
+ import asyncio
18
+ from ramure import LocalImage, agent, agent_process, done, wait
19
+
20
+
21
+ @agent_process(image=LocalImage(), timeout=30)
22
+ async def summarize(text: str) -> str:
23
+ worker = await agent("worker")
24
+
25
+ @worker.on("finish")
26
+ async def on_finish(summary: str) -> str:
27
+ """Call this with your summary when done."""
28
+ done(summary)
29
+ return "Done."
30
+
31
+ await worker.send(f"Summarize this text, then call finish:\n\n{text}")
32
+ return await wait()
33
+
34
+
35
+ asyncio.run(summarize("The quick brown fox jumped over the lazy dog. " * 20))
36
+ ```
37
+
38
+ ## Core concepts
39
+
40
+ ### Processes
41
+
42
+ The unit of composition is a **process function** — an async function decorated with `@agent_process` that creates agents, wires them up, and returns a result.
43
+
44
+ ```python
45
+ @agent_process
46
+ async def build_and_review(spec: str) -> str:
47
+ builder = await agent("builder")
48
+ auditor = await agent("auditor")
49
+ connect(builder, auditor)
50
+
51
+ @builder.on("submit")
52
+ async def on_submit(code: str):
53
+ await auditor.send(f"Review:\n{code}")
54
+ return "Submitted"
55
+
56
+ @auditor.on("approve")
57
+ async def on_approve(code: str):
58
+ done(code)
59
+ return "Approved"
60
+
61
+ @auditor.on("reject")
62
+ async def on_reject(feedback: str):
63
+ await builder.send(f"Fix: {feedback}")
64
+ return "Sent back"
65
+
66
+ await builder.send(f"Implement: {spec}")
67
+ await auditor.send("Review the builder's work.")
68
+ return await wait()
69
+ ```
70
+
71
+ - **Root process** (no active runtime): creates a `Runtime` and websocket server, tears them down on return.
72
+ - **Nested process** (runtime already active): creates a child scope, inherits the runtime.
73
+ - `done(value)` / `fail(reason)` signal completion from tool handlers.
74
+ - `await wait()` blocks until `done()` or `fail()` is called.
75
+ - Agents are cleaned up automatically when their owning process returns.
76
+
77
+ ### Composition
78
+
79
+ Processes compose by calling each other:
80
+
81
+ ```python
82
+ @agent_process(image=LocalImage())
83
+ async def main():
84
+ code = await write_code("fibonacci function")
85
+ review = await review_code(code)
86
+ return code
87
+ ```
88
+
89
+ Concurrent fan-out with `asyncio.gather`:
90
+
91
+ ```python
92
+ @agent_process(image=LocalImage())
93
+ async def main():
94
+ results = await asyncio.gather(
95
+ research("Rust"),
96
+ research("Python"),
97
+ )
98
+ return results
99
+ ```
100
+
101
+ ### Observation and retry
102
+
103
+ `spawn()` runs a process in the background and returns a handle with an event stream:
104
+
105
+ ```python
106
+ @agent_process(image=LocalImage())
107
+ async def main():
108
+ handle = spawn(flaky_task, "write a haiku")
109
+
110
+ async for event in handle.events:
111
+ if event.type == "failed":
112
+ handle = spawn(flaky_task, "write a haiku")
113
+ if event.type == "done":
114
+ return event.data
115
+ ```
116
+
117
+ Processes can emit custom events with `emit(type, data)`. Agent event logs are also async-iterable via `agent.events`.
118
+
119
+ ### Endpoints
120
+
121
+ A process can expose endpoints to its parent. The parent can call them
122
+ directly, or attach them to an agent as tools. A child's agents are
123
+ available to the parent via ``handle.agents`` without any extra step.
124
+
125
+ ```python
126
+ @agent_process
127
+ async def worker_pool():
128
+ @expose
129
+ async def submit_task(task: str) -> str:
130
+ w = await agent(f"worker-{uuid.uuid4().hex[:8]}")
131
+ await w.send(f"Do: {task}")
132
+ return w.name
133
+
134
+ return await wait()
135
+
136
+ handle = spawn(worker_pool)
137
+ name = await handle.call("submit_task", task="build a server")
138
+ worker = handle.agents[name]
139
+ ```
140
+
141
+ To let an agent consume a process's endpoints, attach the handle:
142
+
143
+ ```python
144
+ @agent_process
145
+ async def main():
146
+ pool = spawn(worker_pool)
147
+ dispatcher = await agent("dispatcher")
148
+ await pool.attach(dispatcher) # all endpoints as tools
149
+ # or: await pool.attach(dispatcher, only=["submit_task"], prefix="pool_")
150
+ await dispatcher.send("Use submit_task to delegate jobs.")
151
+ return await wait()
152
+ ```
153
+
154
+ Endpoints run inside the child process's scope, so calls to `emit`,
155
+ `done`, and `fail` inside an endpoint affect the child.
156
+
157
+ ## API
158
+
159
+ ### Decorator
160
+
161
+ - `@agent_process(image=, timeout=, log_dir=)` — wrap an async function as a process
162
+
163
+ ### Ambient functions
164
+
165
+ - `await agent(name, system_prompt=, image=, machine=)` — create an agent
166
+ - `await machine(image=)` — spawn a standalone machine
167
+ - `connect(a, b, direction=)` — allow agents to message/send files
168
+ - `done(result)` — signal process success
169
+ - `fail(reason)` — signal process failure
170
+ - `await wait()` — block until `done()` or `fail()`
171
+ - `emit(type, data)` — emit a process event
172
+ - `spawn(fn, *args, **kwargs)` — run a process in background, returns `ProcessHandle`
173
+ - `@expose` — register an async function as an endpoint callable via `handle.call()` or attachable via `handle.attach()`
174
+ - `current_runtime()` — access the runtime (rarely needed)
175
+
176
+ ### Agent methods
177
+
178
+ - `agent.on(tool_name)` — decorator to register a tool handler
179
+ - `agent.send(message)` — send a message to the agent
180
+ - `agent.exec(command)` — run a shell command on the agent's machine
181
+ - `agent.events` — async-iterable log of raw agent events
182
+
183
+ ### ProcessHandle
184
+
185
+ - `handle.events` — async-iterable stream of process events
186
+ - `handle.agents` — dict of the child's agents
187
+ - `await handle.call(name, **kwargs)` — call an endpoint
188
+ - `await handle.attach(agent, only=, prefix=)` — register endpoints as tools on an agent
189
+ - `handle.cancel()` — cancel the process
190
+
191
+ ## CLI
192
+
193
+ Running an `@agent_process` opens a Unix socket at
194
+ `~/.ramure/runtimes/{execution_id}.sock` and writes a per-run log tree
195
+ under `~/.ramure/logs/{execution_id}/`. The `ramure` CLI uses these:
196
+
197
+ ```
198
+ ramure ls # live runs
199
+ ramure status [--id <prefix>] # agents, machines, connections
200
+ ramure send <agent> <msg> [--id <prefix>]
201
+ ramure connect <agent> [--id <prefix>] # tmux attach
202
+ ramure ssh <agent> [--id <prefix>] # shell on the agent's machine
203
+ ```
204
+
205
+ `--id` takes an execution-id prefix. Omit when there's one live run.
206
+ All commands require the run to be live (socket present). Finished-run
207
+ logs are at `~/.ramure/logs/{execution_id}/` — read them directly.
ramure-0.0.1/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # ramure
2
+
3
+ A lightweight async multi-agent orchestration library. Agents run as [pi](https://github.com/mariozechner/pi-coding-agent) instances in tmux sessions, coordinated by Python process functions.
4
+
5
+ ## Quick start
6
+
7
+ ```python
8
+ import asyncio
9
+ from ramure import LocalImage, agent, agent_process, done, wait
10
+
11
+
12
+ @agent_process(image=LocalImage(), timeout=30)
13
+ async def summarize(text: str) -> str:
14
+ worker = await agent("worker")
15
+
16
+ @worker.on("finish")
17
+ async def on_finish(summary: str) -> str:
18
+ """Call this with your summary when done."""
19
+ done(summary)
20
+ return "Done."
21
+
22
+ await worker.send(f"Summarize this text, then call finish:\n\n{text}")
23
+ return await wait()
24
+
25
+
26
+ asyncio.run(summarize("The quick brown fox jumped over the lazy dog. " * 20))
27
+ ```
28
+
29
+ ## Core concepts
30
+
31
+ ### Processes
32
+
33
+ The unit of composition is a **process function** — an async function decorated with `@agent_process` that creates agents, wires them up, and returns a result.
34
+
35
+ ```python
36
+ @agent_process
37
+ async def build_and_review(spec: str) -> str:
38
+ builder = await agent("builder")
39
+ auditor = await agent("auditor")
40
+ connect(builder, auditor)
41
+
42
+ @builder.on("submit")
43
+ async def on_submit(code: str):
44
+ await auditor.send(f"Review:\n{code}")
45
+ return "Submitted"
46
+
47
+ @auditor.on("approve")
48
+ async def on_approve(code: str):
49
+ done(code)
50
+ return "Approved"
51
+
52
+ @auditor.on("reject")
53
+ async def on_reject(feedback: str):
54
+ await builder.send(f"Fix: {feedback}")
55
+ return "Sent back"
56
+
57
+ await builder.send(f"Implement: {spec}")
58
+ await auditor.send("Review the builder's work.")
59
+ return await wait()
60
+ ```
61
+
62
+ - **Root process** (no active runtime): creates a `Runtime` and websocket server, tears them down on return.
63
+ - **Nested process** (runtime already active): creates a child scope, inherits the runtime.
64
+ - `done(value)` / `fail(reason)` signal completion from tool handlers.
65
+ - `await wait()` blocks until `done()` or `fail()` is called.
66
+ - Agents are cleaned up automatically when their owning process returns.
67
+
68
+ ### Composition
69
+
70
+ Processes compose by calling each other:
71
+
72
+ ```python
73
+ @agent_process(image=LocalImage())
74
+ async def main():
75
+ code = await write_code("fibonacci function")
76
+ review = await review_code(code)
77
+ return code
78
+ ```
79
+
80
+ Concurrent fan-out with `asyncio.gather`:
81
+
82
+ ```python
83
+ @agent_process(image=LocalImage())
84
+ async def main():
85
+ results = await asyncio.gather(
86
+ research("Rust"),
87
+ research("Python"),
88
+ )
89
+ return results
90
+ ```
91
+
92
+ ### Observation and retry
93
+
94
+ `spawn()` runs a process in the background and returns a handle with an event stream:
95
+
96
+ ```python
97
+ @agent_process(image=LocalImage())
98
+ async def main():
99
+ handle = spawn(flaky_task, "write a haiku")
100
+
101
+ async for event in handle.events:
102
+ if event.type == "failed":
103
+ handle = spawn(flaky_task, "write a haiku")
104
+ if event.type == "done":
105
+ return event.data
106
+ ```
107
+
108
+ Processes can emit custom events with `emit(type, data)`. Agent event logs are also async-iterable via `agent.events`.
109
+
110
+ ### Endpoints
111
+
112
+ A process can expose endpoints to its parent. The parent can call them
113
+ directly, or attach them to an agent as tools. A child's agents are
114
+ available to the parent via ``handle.agents`` without any extra step.
115
+
116
+ ```python
117
+ @agent_process
118
+ async def worker_pool():
119
+ @expose
120
+ async def submit_task(task: str) -> str:
121
+ w = await agent(f"worker-{uuid.uuid4().hex[:8]}")
122
+ await w.send(f"Do: {task}")
123
+ return w.name
124
+
125
+ return await wait()
126
+
127
+ handle = spawn(worker_pool)
128
+ name = await handle.call("submit_task", task="build a server")
129
+ worker = handle.agents[name]
130
+ ```
131
+
132
+ To let an agent consume a process's endpoints, attach the handle:
133
+
134
+ ```python
135
+ @agent_process
136
+ async def main():
137
+ pool = spawn(worker_pool)
138
+ dispatcher = await agent("dispatcher")
139
+ await pool.attach(dispatcher) # all endpoints as tools
140
+ # or: await pool.attach(dispatcher, only=["submit_task"], prefix="pool_")
141
+ await dispatcher.send("Use submit_task to delegate jobs.")
142
+ return await wait()
143
+ ```
144
+
145
+ Endpoints run inside the child process's scope, so calls to `emit`,
146
+ `done`, and `fail` inside an endpoint affect the child.
147
+
148
+ ## API
149
+
150
+ ### Decorator
151
+
152
+ - `@agent_process(image=, timeout=, log_dir=)` — wrap an async function as a process
153
+
154
+ ### Ambient functions
155
+
156
+ - `await agent(name, system_prompt=, image=, machine=)` — create an agent
157
+ - `await machine(image=)` — spawn a standalone machine
158
+ - `connect(a, b, direction=)` — allow agents to message/send files
159
+ - `done(result)` — signal process success
160
+ - `fail(reason)` — signal process failure
161
+ - `await wait()` — block until `done()` or `fail()`
162
+ - `emit(type, data)` — emit a process event
163
+ - `spawn(fn, *args, **kwargs)` — run a process in background, returns `ProcessHandle`
164
+ - `@expose` — register an async function as an endpoint callable via `handle.call()` or attachable via `handle.attach()`
165
+ - `current_runtime()` — access the runtime (rarely needed)
166
+
167
+ ### Agent methods
168
+
169
+ - `agent.on(tool_name)` — decorator to register a tool handler
170
+ - `agent.send(message)` — send a message to the agent
171
+ - `agent.exec(command)` — run a shell command on the agent's machine
172
+ - `agent.events` — async-iterable log of raw agent events
173
+
174
+ ### ProcessHandle
175
+
176
+ - `handle.events` — async-iterable stream of process events
177
+ - `handle.agents` — dict of the child's agents
178
+ - `await handle.call(name, **kwargs)` — call an endpoint
179
+ - `await handle.attach(agent, only=, prefix=)` — register endpoints as tools on an agent
180
+ - `handle.cancel()` — cancel the process
181
+
182
+ ## CLI
183
+
184
+ Running an `@agent_process` opens a Unix socket at
185
+ `~/.ramure/runtimes/{execution_id}.sock` and writes a per-run log tree
186
+ under `~/.ramure/logs/{execution_id}/`. The `ramure` CLI uses these:
187
+
188
+ ```
189
+ ramure ls # live runs
190
+ ramure status [--id <prefix>] # agents, machines, connections
191
+ ramure send <agent> <msg> [--id <prefix>]
192
+ ramure connect <agent> [--id <prefix>] # tmux attach
193
+ ramure ssh <agent> [--id <prefix>] # shell on the agent's machine
194
+ ```
195
+
196
+ `--id` takes an execution-id prefix. Omit when there's one live run.
197
+ All commands require the run to be live (socket present). Finished-run
198
+ logs are at `~/.ramure/logs/{execution_id}/` — read them directly.
@@ -0,0 +1,134 @@
1
+ # CLI plan for interacting with live ramure runtimes
2
+
3
+ ## What the old CLI does, what's relevant
4
+
5
+ Ignoring everything that's about "remote server, auth, billing, devboxes,
6
+ repos, PRs, SSH":
7
+
8
+ | Old command | What it does | Analog in new ramure |
9
+ |---|---|---|
10
+ | `ramure execution ls` | list running executions | `ramure ls` — list live runtimes |
11
+ | `ramure execution status <slug>` | show status (agents, connections, machines) | `ramure status <id>` |
12
+ | `ramure execution activity <slug> -n 50` | recent events | `ramure tail <id> [--agent X] [-n 50]` |
13
+ | `ramure execution stop <slug>` | stop a run | no need for this |
14
+ | `ramure execution send <slug> <msg> -a <agent>` | send a message to an agent | `ramure send <id> <agent> <msg>` |
15
+ | `ramure execution ssh <slug> -a <agent>` | open shell on agent VM | needed, there can be remote machines |
16
+ | `ramure execution connect <slug> -a <agent>` | resume the pi session | needed |
17
+ | `ramure tool <name> key=value` (inside VM) | call a tool | **interesting** — see below |
18
+ | `ramure tools` (inside VM) | list tools for current agent | same context |
19
+ | `ramure exec program.py` | launch a new run | **unnecessary** — `python program.py` does it. |
20
+ | `ramure apply <slug>` | apply diff to local | not our problem |
21
+ | `ramure auth / init / devbox` | auth, repo setup | not our problem |
22
+
23
+ So the "translation" of the old CLI into the new world is actually quite
24
+ small: `ls`, `status`, `tail`, `stop`, `send`. Five commands. SSH/connect
25
+ are replaced by `tmux attach`, which is better — more direct, no key
26
+ management.
27
+
28
+ ## The interesting remnant: `ramure tool <name>`
29
+
30
+ This is different from everything else. In the old model, an agent
31
+ running inside a VM could shell out to the `ramure` CLI to call a tool
32
+ on itself — it was a way to invoke runtime-registered Python handlers
33
+ from bash. Equivalent in the new world would be: if you ssh'd into a
34
+ ramure agent's machine (or cd'd into its tmux), you could type
35
+ `ramure tool submit diff=... summary=...` to call the tool handler.
36
+
37
+ That's an actual capability to think about: **invoking tools from
38
+ outside the LLM.** In the old code it existed because agents could run
39
+ bash tools that exercised Python handlers. In the new code it could be
40
+ done, but it's not obvious we want it — the whole point of tool handlers
41
+
42
+ is that the LLM calls them.
43
+
44
+ I'd shelve this.
45
+
46
+ no need for this
47
+
48
+ ## What's worth stealing from the old structure
49
+
50
+ A few concrete things the old CLI got right:
51
+
52
+ 1. **Subcommand grouping by noun.** `ramure execution {ls, status, tail,
53
+ stop, send}`, `ramure devbox {create, ls, snapshot}`. Discoverable,
54
+ future-proof. For us: probably just flat (`ramure ls`, `ramure tail`,
55
+ `ramure send`), because there's only one noun (execution/run).
56
+
57
+ 2. **`--agent` defaults to "first" or "builder".** Instead of erroring
58
+ when omitted. Saves typing for the common single-agent case. We
59
+ should do the same: if a command needs an agent and only one exists,
60
+ use it; if none specified and multiple exist, list them and ask.
61
+
62
+ 3. **`ls` with `--all` to include stopped runs.** Assumes history. In
63
+ our world "stopped" isn't really a thing — the socket goes away. But
64
+ we do have the JSONL logs. `ramure ls --all` could list
65
+ `~/.ramure/runtimes/*.sock` (live) + `~/.ramure/logs/*/` (finished).
66
+ Probably overkill for now.
67
+
68
+ 4. **Event formatting via a shared `format_event`.** The old CLI had
69
+ `ramure/display.py` with one function that turns a LogEntry into a
70
+ pretty line. We want this for `tail`. Important detail because the
71
+ events we log are the same ones we'd display, and
72
+ format-once-used-many avoids a bunch of inconsistency.
73
+
74
+ 5. **Execution slugs instead of UUIDs.** Old used `{verb-noun-hash}`
75
+ slugs, so you could tab-complete or remember them. Our UUIDs are
76
+ awful to type. Worth considering slug generation on
77
+ `runtime.start()` — or at least displaying a prefix (`ramure ls`
78
+ showing first 8 chars, and accepting prefixes when you refer to
79
+ them).
80
+
81
+ 6. **Streaming semantics in `exec`.** The old `exec` command streamed
82
+ events to stdout while the run was live. Ours doesn't need a `exec`
83
+ command since you run Python directly, but: when the user runs
84
+ `python program.py`, they're staring at whatever the program prints.
85
+ They may also want the log stream. A `ramure tail <id>` in another
86
+ terminal covers this. Separate concerns, not the same tool.
87
+
88
+ ## Revised plan
89
+
90
+ Given what I saw in the old CLI, the minimal useful surface is:
91
+
92
+ ```
93
+ ramure ls list live runtimes
94
+ ramure status [<id>] show execution metadata (agents, etc.)
95
+ ramure tail [<id>] [--agent X] [-n N] stream or recent events
96
+ ramure send [<id>] [--agent X] <message> inject a message
97
+ ramure stop [<id>] stop a run (soft: emit runtime-shutdown; fallback SIGTERM)
98
+ ```
99
+
100
+ Plus:
101
+ - `[<id>]` accepts prefixes (`ramure tail a3f1`).
102
+ - `[<id>]` is optional when only one runtime is live.
103
+ - `--agent` defaults to the only agent, or errors with list.
104
+ - Event formatting lives in a `ramure/display.py` that's imported by
105
+ `tail` and `status`.
106
+ - Logs go to `~/.ramure/logs/{execution_id}/*.jsonl` by default (same
107
+ base as the socket, so discovery is symmetrical).
108
+
109
+ The transport question: **let's do registry + loopback WebSocket, not
110
+ Unix socket.** Reason: the server already speaks WS, adding Unix would
111
+ mean a second dispatcher. Registry file at
112
+ `~/.ramure/runtimes/{execution_id}.json` has
113
+ `{pid, server_url, program, started_at}`. CLI reads it. On `close`,
114
+ delete it. On startup, scan for stale entries and clean them up.
115
+
116
+ Slight downside vs. Unix sockets: a stale JSON is harder to detect than
117
+ a stale socket (you need to check the pid). But you'd check the pid
118
+ anyway for `stop`, so the code is there.
119
+
120
+ ## Suggested build order
121
+
122
+ 1. **Registry file** (`runtime.py`): write on start, delete on close.
123
+ ~15 lines.
124
+ 2. **Default `log_dir`** to `~/.ramure/logs/` if not set. ~3 lines.
125
+ 3. **`ramure/display.py`**: `format_event(entry) -> str | None`. Moved
126
+ or copied from old CLI if patterns match, rewritten if not.
127
+ 4. **`ramure/cli.py`**: Typer (or argparse — Typer is fine, small dep)
128
+ with `ls`, `status`, `tail`, `send`, `stop`. Uses the registry and
129
+ the WS protocol.
130
+ 5. **Entry point** in `pyproject.toml`: `ramure = "ramure.cli:app"`.
131
+
132
+ Probably ~300 lines of CLI total, plus ~50 in runtime.py.
133
+
134
+ how does the loopback websocket work? how does it get all these things we need?