@futurelastic/muster 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +494 -0
- package/bin/muster.js +97 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 godx-jp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
# muster
|
|
2
|
+
|
|
3
|
+
**One API for every coding-agent session you are running — on every machine you
|
|
4
|
+
are running them on.**
|
|
5
|
+
|
|
6
|
+
Go, standard library only, no dependencies. MIT.
|
|
7
|
+
|
|
8
|
+
You have coding agents running in terminals. Probably several. Possibly on more
|
|
9
|
+
than one machine. Right now the only way to know what any of them is doing is to
|
|
10
|
+
look at it, and the only way to answer one is to walk over to it.
|
|
11
|
+
|
|
12
|
+
`muster` is a machine-local service that **owns** those sessions and puts
|
|
13
|
+
them behind an HTTP API — list them, read what state each one is in, send one an
|
|
14
|
+
instruction, answer the permission dialog one is stuck on. Peer instances
|
|
15
|
+
federate, so a session on the machine in the other room is one call away and
|
|
16
|
+
looks exactly like a local one.
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
flowchart TB
|
|
20
|
+
C["supervisors & clients<br/>dashboard · CLI · your program"]
|
|
21
|
+
|
|
22
|
+
subgraph MA["machine-a"]
|
|
23
|
+
S["muster<br/>HTTP API"]
|
|
24
|
+
DT["tmux driver"]
|
|
25
|
+
DO["opencode driver"]
|
|
26
|
+
DR["remote driver"]
|
|
27
|
+
CLI["agent CLI<br/>in tmux"]
|
|
28
|
+
SUB["agent subprocess"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
subgraph MB["machine-b"]
|
|
32
|
+
P["peer muster"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
C -- "HTTP + bearer token" --> S
|
|
36
|
+
S --> DT --> CLI
|
|
37
|
+
S --> DO --> SUB
|
|
38
|
+
S --> DR -- "HTTP · LAN / VPN" --> P
|
|
39
|
+
|
|
40
|
+
classDef hop stroke-width:2px,stroke-dasharray:6 4
|
|
41
|
+
class DR,P hop
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
*A session driver is an interface. The remote peer is just another driver —
|
|
45
|
+
which is why federation cost nothing to add.*
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## What problem this actually solves
|
|
50
|
+
|
|
51
|
+
A fleet of coding agents needs two separable things: something that decides
|
|
52
|
+
**what work should happen**, and something that knows **how a session is
|
|
53
|
+
actually run and where**. Most implementations fuse them — the supervisor shells
|
|
54
|
+
out to a terminal multiplexer, scrapes the screen to guess what the agent is
|
|
55
|
+
doing, and is thereby permanently bound to one runtime on one host. (Survey of
|
|
56
|
+
comparable projects, and what that split buys: [`docs/positioning.md`](docs/positioning.md).)
|
|
57
|
+
|
|
58
|
+
`muster` is the second half, extracted. Supervisors become clients.
|
|
59
|
+
|
|
60
|
+
Three properties fall out of that one decision:
|
|
61
|
+
|
|
62
|
+
- **Runtime independence.** A driver is an interface, not a hardcoded
|
|
63
|
+
subprocess. Three exist today: a terminal multiplexer driving an interactive
|
|
64
|
+
agent CLI, a third-party agent spawned as a subprocess and spoken to over
|
|
65
|
+
HTTP, and a peer service on another machine.
|
|
66
|
+
- **Machine-to-machine is free.** A remote peer is *just another driver*. There
|
|
67
|
+
was no separate federation feature to build.
|
|
68
|
+
- **Churn is contained.** When a runtime changes underneath you, the damage
|
|
69
|
+
lands on one driver instead of everywhere.
|
|
70
|
+
|
|
71
|
+
That last one matters more than it looks. The value of the abstraction does not
|
|
72
|
+
depend on any particular runtime being good; it is what makes betting on one
|
|
73
|
+
affordable.
|
|
74
|
+
|
|
75
|
+
## What it will never own
|
|
76
|
+
|
|
77
|
+
Version control state, worktrees, issue trackers, work claims, planning, or any
|
|
78
|
+
judgement about whether work is finished.
|
|
79
|
+
|
|
80
|
+
> **muster knows a session has a working directory.
|
|
81
|
+
> It does not know what a worktree is.**
|
|
82
|
+
|
|
83
|
+
A fleet layer that learns what an issue is has become a second supervisor, and
|
|
84
|
+
now two components believe they are in charge. Every field in this API is tested
|
|
85
|
+
against that sentence.
|
|
86
|
+
|
|
87
|
+
What that means for a consumer — a dashboard that claims issues, spawns sessions
|
|
88
|
+
and merges branches, say — is that the dependency runs one way:
|
|
89
|
+
|
|
90
|
+
- **Work vocabulary is layered above, never pushed down.** A consumer that needs
|
|
91
|
+
repositories, issues, claims, worktrees, locks or leases keeps them itself. The
|
|
92
|
+
only places its vocabulary can appear here are opaque, caller-supplied fields —
|
|
93
|
+
`marker` on session create, and a session's `labels`, set at create or changed
|
|
94
|
+
later — which the service bounds in size, carries and returns without
|
|
95
|
+
interpreting.
|
|
96
|
+
- **Consumers feature-detect and degrade.** What a machine can do is read, not
|
|
97
|
+
assumed: `GET /v1/runtimes` says which runtimes are wired, and `build` on
|
|
98
|
+
`GET /v1/machines` says which version answers. A consumer that finds a
|
|
99
|
+
capability missing does less, rather than failing.
|
|
100
|
+
- **This service never calls a consumer back.** It opens connections only to its
|
|
101
|
+
own runtimes and its peers. A consumer that wants to hear about change
|
|
102
|
+
subscribes to `GET /v1/events`; nothing here holds a consumer's address.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## The parts that are unusual
|
|
107
|
+
|
|
108
|
+
Most tools in this space manage sessions on one laptop, for one vendor's agent,
|
|
109
|
+
behind a TUI. The properties below are the ones that turned out to be rare, and
|
|
110
|
+
they are all consequences of taking failure seriously rather than features that
|
|
111
|
+
were designed for their own sake. ["Rare" is measured, not asserted —
|
|
112
|
+
`docs/positioning.md`](docs/positioning.md) is the survey behind this claim,
|
|
113
|
+
including the combination nothing else in it had.
|
|
114
|
+
|
|
115
|
+
### A session has a state, not a pulse
|
|
116
|
+
|
|
117
|
+
```mermaid
|
|
118
|
+
stateDiagram-v2
|
|
119
|
+
[*] --> idle
|
|
120
|
+
idle --> working: send prompt
|
|
121
|
+
working --> idle: turn completes
|
|
122
|
+
working --> waiting_input: permission dialog
|
|
123
|
+
waiting_input --> working: respond
|
|
124
|
+
working --> quota_blocked: quota exhausted
|
|
125
|
+
quota_blocked --> idle: quota window resets
|
|
126
|
+
|
|
127
|
+
note right of waiting_input
|
|
128
|
+
Dead end. Only a respond call moves it:
|
|
129
|
+
answer by index, checked against a nonce.
|
|
130
|
+
This is the state that strands work.
|
|
131
|
+
end note
|
|
132
|
+
|
|
133
|
+
note right of quota_blocked
|
|
134
|
+
Not idle. On screen both are a quiet
|
|
135
|
+
terminal with an empty composer.
|
|
136
|
+
The API keeps them apart.
|
|
137
|
+
end note
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
On screen, an agent that finished, an agent out of quota, an agent whose turn
|
|
141
|
+
died on a transient error, and an agent holding text nobody submitted all look
|
|
142
|
+
identical: a quiet terminal with an empty composer. Collapsing them is how
|
|
143
|
+
abandoned work goes unnoticed for a day.
|
|
144
|
+
|
|
145
|
+
So they are different states, each carrying its evidence, and every state says
|
|
146
|
+
whether it was `observed` from a structured read or `inferred` from a screen.
|
|
147
|
+
|
|
148
|
+
### Sending a prompt is not fire-and-forget
|
|
149
|
+
|
|
150
|
+
```mermaid
|
|
151
|
+
flowchart TB
|
|
152
|
+
A(["POST input — send a prompt"]) --> R{result}
|
|
153
|
+
R -->|submitted| S["delivered — the agent has it"]
|
|
154
|
+
R -->|queued| Q["accepted — submit registered"]
|
|
155
|
+
R -->|unknown| U["NOT sent — text stranded<br/>in the composer"]
|
|
156
|
+
U -. "retry: send resumes its own<br/>unconfirmed delivery, never a human's text" .-> A
|
|
157
|
+
|
|
158
|
+
classDef bad stroke-width:2px,stroke-dasharray:6 4
|
|
159
|
+
class U bad
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`unknown` means the text is sitting in the composer and was **not** submitted. A
|
|
163
|
+
system that reports success here loses instructions silently, and you find out
|
|
164
|
+
hours later when nothing happened. Retrying resubmits only text the service
|
|
165
|
+
itself placed there — never text a human typed.
|
|
166
|
+
|
|
167
|
+
There is a fourth outcome, `refused`, for when the driver actively protects the
|
|
168
|
+
session from input that would corrupt it. A refusal is information to act on,
|
|
169
|
+
not a fault to retry, so it arrives as an ordinary `200`.
|
|
170
|
+
|
|
171
|
+
### A session blocked on a dialog can be answered remotely
|
|
172
|
+
|
|
173
|
+
The dialog is read with every option enumerated, and you answer by index —
|
|
174
|
+
carrying a nonce, so your answer cannot land on a question that changed while
|
|
175
|
+
you were deciding. A session lost to a question nobody can reach is the most
|
|
176
|
+
expensive failure in a fleet, and an ordinary `send` cannot fix it: the property
|
|
177
|
+
that makes `send` safe for messages makes it useless for control.
|
|
178
|
+
|
|
179
|
+
### Every route is authenticated, and exposure is never accidental
|
|
180
|
+
|
|
181
|
+
There is no unauthenticated mode — not on loopback, not in development. A
|
|
182
|
+
service with no token refuses to start. The default bind is loopback on an
|
|
183
|
+
ephemeral port, and binding all interfaces logs a warning that names the risk.
|
|
184
|
+
Callers are named principals with per-verb grants; every mutation is audited
|
|
185
|
+
with an actor, not an address.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Install
|
|
190
|
+
|
|
191
|
+
Published to npm; one install surface. Needs Node 18 or newer to *launch* the
|
|
192
|
+
service — the service itself is a static Go binary and needs nothing else.
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
npx @futurelastic/muster serve # run it once, nothing installed
|
|
196
|
+
npm i -g @futurelastic/muster # or install the `muster` command
|
|
197
|
+
muster --version # muster <version> (...)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`@futurelastic/muster` is a small launcher. npm installs exactly one of the
|
|
201
|
+
platform packages beside it (`@futurelastic/muster-darwin-arm64`, `-darwin-x64`,
|
|
202
|
+
`-linux-x64`, `-linux-arm64`) and the launcher runs that binary. Nothing is
|
|
203
|
+
downloaded at install time, so `--ignore-scripts` and an offline mirror both
|
|
204
|
+
work. Every release is published with npm provenance, and the launcher refuses
|
|
205
|
+
a platform package whose version differs from its own. Release candidates
|
|
206
|
+
publish under the `next` dist-tag (`npx @futurelastic/muster@next`); `latest`
|
|
207
|
+
only ever moves to a final release.
|
|
208
|
+
|
|
209
|
+
Configure it with the `FLEET_*` environment the [Quickstart](#quickstart) shows
|
|
210
|
+
and start it with `muster serve`. A service that survives a reboot is
|
|
211
|
+
[`docs/install.md`](docs/install.md). Build from source only to hack on it.
|
|
212
|
+
|
|
213
|
+
## Quickstart
|
|
214
|
+
|
|
215
|
+
Building from source: Go 1.26 or newer. No dependencies.
|
|
216
|
+
|
|
217
|
+
```sh
|
|
218
|
+
go build ./... && go test ./...
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### One machine
|
|
222
|
+
|
|
223
|
+
```sh
|
|
224
|
+
export FLEET_MACHINE=machine-a
|
|
225
|
+
export FLEET_TOKEN="$(openssl rand -hex 32)"
|
|
226
|
+
export FLEET_ADDR=127.0.0.1:9000
|
|
227
|
+
go run ./cmd/muster
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
curl -s -H "Authorization: Bearer $FLEET_TOKEN" http://127.0.0.1:9000/v1/health
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
There is no default port — pick one and keep it. The default bind is loopback on
|
|
235
|
+
an ephemeral port, so a service you have not deliberately configured is
|
|
236
|
+
reachable only from its own machine.
|
|
237
|
+
|
|
238
|
+
### Two machines, over a LAN or a VPN
|
|
239
|
+
|
|
240
|
+
Four steps. Do **machine-b first** — machine-a needs an address to point at.
|
|
241
|
+
|
|
242
|
+
**1. Give each machine its own credential.** Not one shared token: the whole
|
|
243
|
+
point of the next three steps is that the two machines have distinct identities.
|
|
244
|
+
|
|
245
|
+
```sh
|
|
246
|
+
mkdir -p ~/.config/muster
|
|
247
|
+
openssl rand -hex 32 > ~/.config/muster/machine-a.token
|
|
248
|
+
chmod 600 ~/.config/muster/machine-a.token
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**2. On machine-b — the one that owns the sessions.** Bind the interface the
|
|
252
|
+
peer will actually reach: **a specific address, never `0.0.0.0`**. On a VPN that
|
|
253
|
+
is the VPN address; on a trusted LAN it is the LAN address. Loopback is added
|
|
254
|
+
alongside it automatically, so the service never becomes undiagnosable from its
|
|
255
|
+
own machine.
|
|
256
|
+
|
|
257
|
+
```sh
|
|
258
|
+
export FLEET_MACHINE=machine-b
|
|
259
|
+
export FLEET_TOKEN="$(cat ~/.config/muster/machine-b.token)"
|
|
260
|
+
export FLEET_ADDR=10.8.0.12:9000 # this machine's VPN or LAN address
|
|
261
|
+
export FLEET_ALLOW_MUTATIONS=1 # permit writes to its own sessions
|
|
262
|
+
muster
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**3. On machine-a — the one that will call it.** A peer is statically
|
|
266
|
+
configured; there is no discovery. The address is one **you** have confirmed
|
|
267
|
+
reachable from this machine, never the peer's own idea of its name — that is how
|
|
268
|
+
a fleet ends up pointing at a hostname which resolves on only one side.
|
|
269
|
+
|
|
270
|
+
```sh
|
|
271
|
+
export FLEET_MACHINE=machine-a
|
|
272
|
+
export FLEET_TOKEN="$(cat ~/.config/muster/machine-a.token)"
|
|
273
|
+
export FLEET_PEERS="machine-b=http://10.8.0.12:9000"
|
|
274
|
+
export FLEET_ALLOW_RELAY=1 # permit forwarding writes to a peer
|
|
275
|
+
muster
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**4. Verify, from machine-a.**
|
|
279
|
+
|
|
280
|
+
```sh
|
|
281
|
+
curl -s -H "Authorization: Bearer $(cat ~/.config/muster/machine-a.token)" \
|
|
282
|
+
http://127.0.0.1:9000/v1/machines
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
```json
|
|
286
|
+
{ "items": [ { "machine": "machine-a", "self": true, "status": "ok" },
|
|
287
|
+
{ "machine": "machine-b", "self": false, "status": "ok" } ],
|
|
288
|
+
"complete": true }
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Both reading `ok` is the entire handshake. If machine-b reads `unreachable`,
|
|
292
|
+
the bind address or the network path is wrong — **not** the token: a bad
|
|
293
|
+
credential is a `401`, not a silence.
|
|
294
|
+
|
|
295
|
+
> **The two credentials are not symmetric.** The token machine-a presents for a
|
|
296
|
+
> peer is *machine-a's identity on machine-b*, not machine-b's identity here.
|
|
297
|
+
> They are different secrets, and conflating them is how a fleet quietly ends up
|
|
298
|
+
> back on one shared token.
|
|
299
|
+
|
|
300
|
+
You now have one API over both machines. What to do with it is below.
|
|
301
|
+
|
|
302
|
+
> **This is a demo, not an install.** Every process above ends with its shell.
|
|
303
|
+
> A service that survives a reboot — a principal table, a state directory, a
|
|
304
|
+
> service unit, and a `muster doctor` run that checks all of it — is
|
|
305
|
+
> [`docs/install.md`](docs/install.md).
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
## One machine drives another
|
|
309
|
+
|
|
310
|
+
What the two-machine setup above actually buys you, end to end. Every call is
|
|
311
|
+
made against the **local** service; that it crosses a LAN or a VPN to reach the
|
|
312
|
+
other machine is the service's problem, not the caller's.
|
|
313
|
+
|
|
314
|
+
```mermaid
|
|
315
|
+
sequenceDiagram
|
|
316
|
+
autonumber
|
|
317
|
+
actor C as client
|
|
318
|
+
box transparent machine-a
|
|
319
|
+
participant A as muster (a)
|
|
320
|
+
end
|
|
321
|
+
box transparent machine-b
|
|
322
|
+
participant B as muster (b)
|
|
323
|
+
participant T as tmux driver
|
|
324
|
+
participant X as agent CLI
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
C->>A: GET /v1/machines/machine-b/sessions/s42
|
|
328
|
+
Note over A,B: one hop · LAN / VPN<br/>HTTP + bearer token
|
|
329
|
+
A->>B: proxy — same API, service's own credential
|
|
330
|
+
B->>T: read session
|
|
331
|
+
T->>X: capture + classify screen
|
|
332
|
+
X-->>T: screen
|
|
333
|
+
T-->>B: state: waiting_input
|
|
334
|
+
B-->>A: session s42
|
|
335
|
+
A-->>C: session s42
|
|
336
|
+
Note over C,A: the caller never learns the session was remote.<br/>Fan-out stops here — peers never recurse.
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Drive it
|
|
340
|
+
|
|
341
|
+
```sh
|
|
342
|
+
FLEET=http://127.0.0.1:9000
|
|
343
|
+
AUTH="Authorization: Bearer $(cat ~/.config/muster/machine-a.token)"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**Every session on both machines, as one list:**
|
|
347
|
+
|
|
348
|
+
```sh
|
|
349
|
+
curl -s -H "$AUTH" "$FLEET/v1/sessions?scope=fleet"
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
```json
|
|
353
|
+
{
|
|
354
|
+
"items": [
|
|
355
|
+
{ "machine": "machine-a", "id": "s17", "state": { "status": "working", "confidence": "inferred" } },
|
|
356
|
+
{ "machine": "machine-b", "id": "s42", "state": { "status": "waiting_input", "confidence": "inferred",
|
|
357
|
+
"waitingOn": "prompt",
|
|
358
|
+
"prompt": { "question": "Allow running tests?", "options": ["No", "Yes", "Yes, always"],
|
|
359
|
+
"selected": 1, "kind": "tool-permission", "nonce": "a3f1" } } }
|
|
360
|
+
],
|
|
361
|
+
"sources": [ { "machine": "machine-a", "status": "ok" }, { "machine": "machine-b", "status": "ok" } ],
|
|
362
|
+
"complete": true
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Read `complete` before you trust that list. If the VPN was down, `machine-b`
|
|
367
|
+
would report unreachable, `complete` would be `false`, and the response would
|
|
368
|
+
still be a `200` — because "the machine did not answer" and "the session does
|
|
369
|
+
not exist" are different facts and must never look the same.
|
|
370
|
+
|
|
371
|
+
**Send an instruction to a session on the other machine:**
|
|
372
|
+
|
|
373
|
+
```sh
|
|
374
|
+
curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \
|
|
375
|
+
-d '{"text":"run the test suite","submit":true}' \
|
|
376
|
+
"$FLEET/v1/machines/machine-b/sessions/s42/input"
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
```json
|
|
380
|
+
{ "outcome": "submitted" }
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
If that comes back `"unknown"`, the text is stranded in the composer on the
|
|
384
|
+
other machine and was not sent. Retry the identical call with
|
|
385
|
+
`"resumeIfStranded": true`.
|
|
386
|
+
|
|
387
|
+
**Answer the dialog it is blocked on** — quoting the nonce from the read above:
|
|
388
|
+
|
|
389
|
+
```sh
|
|
390
|
+
curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \
|
|
391
|
+
-d '{"choice":2,"nonce":"a3f1"}' \
|
|
392
|
+
"$FLEET/v1/machines/machine-b/sessions/s42/respond"
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
`choice` is 1-based against `prompt.options`, so this answers **"Yes"**. If the
|
|
396
|
+
prompt changed while you were deciding, the nonce no longer matches and the
|
|
397
|
+
answer is refused rather than applied to a different question.
|
|
398
|
+
|
|
399
|
+
### What has to be true for that to work
|
|
400
|
+
|
|
401
|
+
| | machine-a (calls) | machine-b (owns the session) |
|
|
402
|
+
|---|---|---|
|
|
403
|
+
| Reachable address | — | `FLEET_ADDR` on the LAN/VPN interface |
|
|
404
|
+
| Peer configured | `FLEET_PEERS` | — |
|
|
405
|
+
| Write to own sessions | — | `FLEET_ALLOW_MUTATIONS=1` |
|
|
406
|
+
| Forward writes to a peer | `FLEET_ALLOW_RELAY=1` | — |
|
|
407
|
+
|
|
408
|
+
With a principal table those two booleans become per-identity grants: the caller
|
|
409
|
+
needs `relay` on machine-a, and the verb grant (`send`) is checked on machine-b.
|
|
410
|
+
They are separate refusals and you meet them one at a time. Both default to
|
|
411
|
+
denied, on a fresh deployment as much as an established one.
|
|
412
|
+
|
|
413
|
+
> **Exposing this is a decision, not a default.** The service reads paths and,
|
|
414
|
+
> when mutations are enabled, starts processes. Put it on a VPN or a trusted
|
|
415
|
+
> LAN segment, bind a specific interface, and give each caller its own
|
|
416
|
+
> credential with only the grants it needs.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Documentation
|
|
421
|
+
|
|
422
|
+
| If you want to | Read |
|
|
423
|
+
|---|---|
|
|
424
|
+
| Call the API | [`docs/api.md`](docs/api.md) — every endpoint at a glance |
|
|
425
|
+
| Write a client | [`docs/client-guide.md`](docs/client-guide.md) — a walkthrough, every example taken from a running service |
|
|
426
|
+
| Understand the design | [`docs/spec/session-abstraction.md`](docs/spec/session-abstraction.md) — the domain model. If you read one section, read §5.7 |
|
|
427
|
+
| Know the wire protocol exactly | [`docs/spec/api-http.md`](docs/spec/api-http.md) — normative |
|
|
428
|
+
| Adopt this in an existing system | [`docs/adoption.md`](docs/adoption.md) — staged so each step is reversible; §2 is the precondition that surprised us |
|
|
429
|
+
| Work on the service | [`docs/internals.md`](docs/internals.md) — measurements, decided questions, known gaps |
|
|
430
|
+
| Check a runtime build before the fleet takes it | [`docs/compat.md`](docs/compat.md) — `muster compat`: a versioned report of whether a candidate build still behaves as this service assumes |
|
|
431
|
+
| Install it on a new machine | [`docs/install.md`](docs/install.md) — from nothing to a running service, ending with `muster doctor` |
|
|
432
|
+
| Deploy it | [`docs/deploy.md`](docs/deploy.md) — from a merged commit to a service that already runs |
|
|
433
|
+
| Declare a machine-wide session identity | [`docs/session-identity.md`](docs/session-identity.md) — `sessionEnv`, precedence against a caller, verification |
|
|
434
|
+
| See what this is positioned against | [`docs/positioning.md`](docs/positioning.md) — a survey of comparable projects: category, what turned out rare, trajectory |
|
|
435
|
+
|
|
436
|
+
The specs carry the reasoning; the code is a transcription of them, not the
|
|
437
|
+
other way round. The session spec is organised so you can tell current truth
|
|
438
|
+
from history: **§1–§13 are normative**, **§14 lists what the document requires
|
|
439
|
+
but cannot enforce** — read it before trusting any guarantee — and **Appendix A
|
|
440
|
+
is the findings log**, the measurements and bugs that produced the rules.
|
|
441
|
+
|
|
442
|
+
That appendix is kept rather than smoothed away on purpose. A reader who knows
|
|
443
|
+
only a rule will restate it; a reader who knows how it was violated will
|
|
444
|
+
recognise the next instance.
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Where this is going
|
|
449
|
+
|
|
450
|
+
Done, in order, each one having changed the specification rather than merely
|
|
451
|
+
implemented it:
|
|
452
|
+
|
|
453
|
+
- ✅ **A first working driver** — a terminal multiplexer running an interactive
|
|
454
|
+
agent CLI. It amended four sections of the spec and found one defect the spec
|
|
455
|
+
cannot fix on its own.
|
|
456
|
+
- ✅ **Event subscription** — over the substrate's own push channel, served as
|
|
457
|
+
SSE with cursors, epoch, retention and announced resync. No polling anywhere.
|
|
458
|
+
- ✅ **A second driver: a remote peer** — federation, proven. A caller asks a
|
|
459
|
+
service holding no local drivers, which proxies into a second service and
|
|
460
|
+
back with real sessions in ~30 ms. Neither service needed a special case.
|
|
461
|
+
- ✅ **Answering, not just observing** — enumerated options, a nonce, and
|
|
462
|
+
verification that the prompt actually cleared.
|
|
463
|
+
- ✅ **A second *local* driver** — a third-party agent spawned as a subprocess,
|
|
464
|
+
the first able to declare `observesState: true`. Two runtimes on one machine
|
|
465
|
+
at once, which is the proof the interface was worth having.
|
|
466
|
+
- ✅ **State that survives a restart** — idempotency keys, event epoch and
|
|
467
|
+
cursor, and driver session records persisted atomically, so reconciliation
|
|
468
|
+
can tell adopted from orphaned rather than calling everything orphaned.
|
|
469
|
+
|
|
470
|
+
Next, and this is where the value actually lands:
|
|
471
|
+
|
|
472
|
+
- ⬜ **A supervisor as a client**, replacing direct terminal-multiplexer access.
|
|
473
|
+
Until that happens this is a second implementation of session management
|
|
474
|
+
rather than a replacement for one. Planned in
|
|
475
|
+
[`docs/adoption.md`](docs/adoption.md) — including the one precondition that
|
|
476
|
+
cannot be discharged from inside this repository: nothing here prevents two
|
|
477
|
+
machines editing one working tree, because repository state is a non-goal.
|
|
478
|
+
- ⬜ **Credential lifecycle.** Grants are per principal and per verb, and
|
|
479
|
+
enrolment is a command. Nothing expires, a compromised token is revoked by
|
|
480
|
+
editing a file, and changing a principal's grants means removing and
|
|
481
|
+
re-adding it.
|
|
482
|
+
- ⬜ **Metrics.** Subprocess spawn cost is known to degrade with host load —
|
|
483
|
+
8× on a machine under heavy load — and nothing measures it in production.
|
|
484
|
+
|
|
485
|
+
The honest list of what is missing is in
|
|
486
|
+
[`docs/internals.md`](docs/internals.md), kept current on purpose.
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## License
|
|
491
|
+
|
|
492
|
+
[MIT](LICENSE). Copy it, run it, change it — the adoption path in
|
|
493
|
+
[`docs/adoption.md`](docs/adoption.md) exists for people who are not us, and a
|
|
494
|
+
documented adoption path with no licence grants them nothing.
|
package/bin/muster.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launcher for the muster binary (#243).
|
|
3
|
+
//
|
|
4
|
+
// This package carries no binary. npm installs exactly one of the sibling
|
|
5
|
+
// @futurelastic/muster-<os>-<cpu> packages (their `os`/`cpu` fields do the
|
|
6
|
+
// choosing, via optionalDependencies), and this file finds it and runs it.
|
|
7
|
+
// Nothing is downloaded at install time, so `--ignore-scripts` changes nothing.
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { spawn } = require('node:child_process');
|
|
13
|
+
|
|
14
|
+
const SUPPORTED = ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64'];
|
|
15
|
+
|
|
16
|
+
function fail(message) {
|
|
17
|
+
process.stderr.write(`muster: ${message}\n`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// resolveBinary returns the absolute path of the platform package's binary, or
|
|
22
|
+
// throws an Error whose message says what to do. Exported through `require.main`
|
|
23
|
+
// so the test can drive it without spawning anything.
|
|
24
|
+
function resolveBinary(platform, arch, resolve, ownVersion) {
|
|
25
|
+
const key = `${platform}-${arch}`;
|
|
26
|
+
if (!SUPPORTED.includes(key)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`no prebuilt binary for ${key} (supported: ${SUPPORTED.join(', ')}). ` +
|
|
29
|
+
'Build from source: https://github.com/futurelastic/muster#install'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const pkg = `@futurelastic/muster-${key}`;
|
|
33
|
+
let manifest;
|
|
34
|
+
try {
|
|
35
|
+
manifest = resolve(`${pkg}/package.json`);
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`${pkg} is not installed. It is an optional dependency of @futurelastic/muster, ` +
|
|
39
|
+
'so an install that skipped optional dependencies (--omit=optional) or copied ' +
|
|
40
|
+
'node_modules between platforms leaves it out. Reinstall on this machine.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const theirs = JSON.parse(fs.readFileSync(manifest, 'utf8')).version;
|
|
44
|
+
if (theirs !== ownVersion) {
|
|
45
|
+
// The versions are published together and must stay together: a launcher
|
|
46
|
+
// running a different build than the one it was released with is a
|
|
47
|
+
// support problem nobody can see from `muster --version` alone.
|
|
48
|
+
throw new Error(
|
|
49
|
+
`version mismatch: @futurelastic/muster is ${ownVersion} but ${pkg} is ${theirs}. ` +
|
|
50
|
+
'Reinstall so both come from the same release.'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return path.join(path.dirname(manifest), 'bin', 'muster');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function run() {
|
|
57
|
+
const own = require('../package.json').version;
|
|
58
|
+
let bin;
|
|
59
|
+
try {
|
|
60
|
+
bin = resolveBinary(process.platform, process.arch, require.resolve, own);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
fail(err.message);
|
|
63
|
+
}
|
|
64
|
+
// A tarball extracted without the executable bit (some mirrors do this)
|
|
65
|
+
// would otherwise fail with an opaque EACCES.
|
|
66
|
+
try {
|
|
67
|
+
fs.accessSync(bin, fs.constants.X_OK);
|
|
68
|
+
} catch {
|
|
69
|
+
try {
|
|
70
|
+
fs.chmodSync(bin, 0o755);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
fail(`${bin} is not executable and could not be made so: ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const child = spawn(bin, process.argv.slice(2), { stdio: 'inherit' });
|
|
77
|
+
// The service handles these itself (a graceful stop); pass them on rather
|
|
78
|
+
// than dying first and orphaning it.
|
|
79
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
80
|
+
process.on(sig, () => child.kill(sig));
|
|
81
|
+
}
|
|
82
|
+
child.on('error', (err) => fail(`could not start ${bin}: ${err.message}`));
|
|
83
|
+
child.on('exit', (code, signal) => {
|
|
84
|
+
if (signal) {
|
|
85
|
+
process.removeAllListeners(signal);
|
|
86
|
+
process.kill(process.pid, signal);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
process.exit(code === null ? 1 : code);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (require.main === module) {
|
|
94
|
+
run();
|
|
95
|
+
} else {
|
|
96
|
+
module.exports = { resolveBinary, SUPPORTED };
|
|
97
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@futurelastic/muster",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "muster — a machine-local service that owns session agents across a fleet. Launcher: runs the prebuilt binary for this platform.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/futurelastic/muster#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/futurelastic/muster.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/futurelastic/muster/issues"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"muster": "bin/muster.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin/muster.js",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"optionalDependencies": {
|
|
23
|
+
"@futurelastic/muster-darwin-arm64": "0.2.0",
|
|
24
|
+
"@futurelastic/muster-darwin-x64": "0.2.0",
|
|
25
|
+
"@futurelastic/muster-linux-x64": "0.2.0",
|
|
26
|
+
"@futurelastic/muster-linux-arm64": "0.2.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
}
|
|
35
|
+
}
|