@futurelastic/muster 0.5.0 → 0.6.0-rc.2

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 (2) hide show
  1. package/README.md +64 -273
  2. package/package.json +5 -5
package/README.md CHANGED
@@ -106,83 +106,21 @@ and merges branches, say — is that the dependency runs one way:
106
106
  ## The parts that are unusual
107
107
 
108
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.
109
+ behind a TUI. These are the properties that turned out to be rare — all of them
110
+ consequences of taking failure seriously. Each is worked through, with its state
111
+ and delivery diagrams, in [`docs/unusual-parts.md`](docs/unusual-parts.md); the
112
+ survey behind "rare" is [`docs/positioning.md`](docs/positioning.md).
113
+
114
+ - **A session has a state, not a pulse.** Finished, out of quota, died on an
115
+ error and holding unsent text all look like a quiet terminal; here they are
116
+ different states, each saying whether it was `observed` or `inferred`.
117
+ - **Sending a prompt is not fire-and-forget.** `submitted`, `queued`, `unknown`
118
+ and `refused` are distinct outcomes; `unknown` means the text is stranded, not
119
+ sent, and a retry never resubmits a human's text.
120
+ - **A session blocked on a dialog can be answered remotely** — by option index,
121
+ carrying a nonce so the answer cannot land on a question that changed.
122
+ - **Every route is authenticated.** No unauthenticated mode, loopback bind by
123
+ default, named principals with per-verb grants, every mutation audited.
186
124
 
187
125
  ---
188
126
 
@@ -197,18 +135,12 @@ npm i -g @futurelastic/muster # or install the `muster` command
197
135
  muster --version # muster <version> (...)
198
136
  ```
199
137
 
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
- Windows is built and tested by every release but not published to npm yet, and
210
- has no interactive-terminal driver; see
211
- [`docs/install.md`](docs/install.md#windows).
138
+ `@futurelastic/muster` is a small launcher: npm installs exactly one platform
139
+ package beside it and runs that binary. Nothing is downloaded at install time,
140
+ and every release carries npm provenance. Release candidates publish under the
141
+ `next` dist-tag; `latest` only moves to a final release. Windows is built and
142
+ tested by every release but not published to npm yet and has no
143
+ interactive-terminal driver — see [`docs/install.md`](docs/install.md#windows).
212
144
 
213
145
  Configure it with the `FLEET_*` environment the [Quickstart](#quickstart) shows
214
146
  and start it with `muster serve`. A service that survives a reboot is
@@ -228,7 +160,7 @@ go build ./... && go test ./...
228
160
  export FLEET_MACHINE=machine-a
229
161
  export FLEET_TOKEN="$(openssl rand -hex 32)"
230
162
  export FLEET_ADDR=127.0.0.1:9000
231
- go run ./cmd/muster
163
+ go run ./cmd/muster serve
232
164
  ```
233
165
 
234
166
  ```sh
@@ -239,185 +171,44 @@ There is no default port — pick one and keep it. The default bind is loopback
239
171
  an ephemeral port, so a service you have not deliberately configured is
240
172
  reachable only from its own machine.
241
173
 
242
- ### Two machines, over a LAN or a VPN
174
+ ### Two machines
243
175
 
244
- Four steps. Do **machine-b first** — machine-a needs an address to point at.
245
-
246
- **1. Give each machine its own credential.** Not one shared token: the whole
247
- point of the next three steps is that the two machines have distinct identities.
248
-
249
- ```sh
250
- mkdir -p ~/.config/muster
251
- openssl rand -hex 32 > ~/.config/muster/machine-a.token
252
- chmod 600 ~/.config/muster/machine-a.token
253
- ```
254
-
255
- **2. On machine-b — the one that owns the sessions.** Bind the interface the
256
- peer will actually reach: **a specific address, never `0.0.0.0`**. On a VPN that
257
- is the VPN address; on a trusted LAN it is the LAN address. Loopback is added
258
- alongside it automatically, so the service never becomes undiagnosable from its
259
- own machine.
260
-
261
- ```sh
262
- export FLEET_MACHINE=machine-b
263
- export FLEET_TOKEN="$(cat ~/.config/muster/machine-b.token)"
264
- export FLEET_ADDR=10.8.0.12:9000 # this machine's VPN or LAN address
265
- export FLEET_ALLOW_MUTATIONS=1 # permit writes to its own sessions
266
- muster
267
- ```
268
-
269
- **3. On machine-a — the one that will call it.** A peer is statically
270
- configured; there is no discovery. The address is one **you** have confirmed
271
- reachable from this machine, never the peer's own idea of its name — that is how
272
- a fleet ends up pointing at a hostname which resolves on only one side.
273
-
274
- ```sh
275
- export FLEET_MACHINE=machine-a
276
- export FLEET_TOKEN="$(cat ~/.config/muster/machine-a.token)"
277
- export FLEET_PEERS="machine-b=http://10.8.0.12:9000"
278
- export FLEET_ALLOW_RELAY=1 # permit forwarding writes to a peer
279
- muster
280
- ```
281
-
282
- **4. Verify, from machine-a.**
283
-
284
- ```sh
285
- curl -s -H "Authorization: Bearer $(cat ~/.config/muster/machine-a.token)" \
286
- http://127.0.0.1:9000/v1/machines
287
- ```
288
-
289
- ```json
290
- { "items": [ { "machine": "machine-a", "self": true, "status": "ok" },
291
- { "machine": "machine-b", "self": false, "status": "ok" } ],
292
- "complete": true }
293
- ```
294
-
295
- Both reading `ok` is the entire handshake. If machine-b reads `unreachable`,
296
- the bind address or the network path is wrong — **not** the token: a bad
297
- credential is a `401`, not a silence.
298
-
299
- > **The two credentials are not symmetric.** The token machine-a presents for a
300
- > peer is *machine-a's identity on machine-b*, not machine-b's identity here.
301
- > They are different secrets, and conflating them is how a fleet quietly ends up
302
- > back on one shared token.
303
-
304
- You now have one API over both machines. What to do with it is below.
176
+ Federating two instances is four steps — a credential per machine, the owner's
177
+ bind address, the caller's peer list, and one `GET /v1/machines` to verify. The
178
+ full walk-through is [`docs/multi-machine.md`](docs/multi-machine.md); what a
179
+ caller can then do (one fleet-wide session list, input and dialog answers sent
180
+ across the hop) is [`docs/driving-a-machine.md`](docs/driving-a-machine.md).
305
181
 
306
182
  > **This is a demo, not an install.** Every process above ends with its shell.
307
183
  > A service that survives a reboot — a principal table, a state directory, a
308
184
  > service unit, and a `muster doctor` run that checks all of it — is
309
185
  > [`docs/install.md`](docs/install.md).
310
186
 
187
+ ## Who it serves
311
188
 
312
- ## One machine drives another
313
-
314
- What the two-machine setup above actually buys you, end to end. Every call is
315
- made against the **local** service; that it crosses a LAN or a VPN to reach the
316
- other machine is the service's problem, not the caller's.
317
-
318
- ```mermaid
319
- sequenceDiagram
320
- autonumber
321
- actor C as client
322
- box transparent machine-a
323
- participant A as muster (a)
324
- end
325
- box transparent machine-b
326
- participant B as muster (b)
327
- participant T as tmux driver
328
- participant X as agent CLI
329
- end
330
-
331
- C->>A: GET /v1/machines/machine-b/sessions/s42
332
- Note over A,B: one hop · LAN / VPN<br/>HTTP + bearer token
333
- A->>B: proxy — same API, service's own credential
334
- B->>T: read session
335
- T->>X: capture + classify screen
336
- X-->>T: screen
337
- T-->>B: state: waiting_input
338
- B-->>A: session s42
339
- A-->>C: session s42
340
- Note over C,A: the caller never learns the session was remote.<br/>Fan-out stops here — peers never recurse.
341
- ```
342
-
343
- ### Drive it
189
+ Clients, not end users. Three kinds are in mind, and the API treats them alike:
344
190
 
345
- ```sh
346
- FLEET=http://127.0.0.1:9000
347
- AUTH="Authorization: Bearer $(cat ~/.config/muster/machine-a.token)"
348
- ```
191
+ - **Session dashboards** — a view over every session on every machine, with the
192
+ state each one is in and a way to answer the one that is stuck.
193
+ - **Coordinators** — programs that decide what work should happen and need a
194
+ session to run it on; they hold the work vocabulary, this service holds the
195
+ session.
196
+ - **Desktop and command-line apps** — a launcher or picker that lists sessions
197
+ and attaches to one.
349
198
 
350
- **Every session on both machines, as one list:**
199
+ Which of these you are building decides where to start in
200
+ [`docs/client-guide.md`](docs/client-guide.md).
351
201
 
352
- ```sh
353
- curl -s -H "$AUTH" "$FLEET/v1/sessions?scope=fleet"
354
- ```
355
-
356
- ```json
357
- {
358
- "items": [
359
- { "machine": "machine-a", "id": "s17", "state": { "status": "working", "confidence": "inferred" } },
360
- { "machine": "machine-b", "id": "s42", "state": { "status": "waiting_input", "confidence": "inferred",
361
- "waitingOn": "prompt",
362
- "prompt": { "question": "Allow running tests?", "options": ["No", "Yes", "Yes, always"],
363
- "selected": 1, "kind": "tool-permission", "nonce": "a3f1" } } }
364
- ],
365
- "sources": [ { "machine": "machine-a", "status": "ok" }, { "machine": "machine-b", "status": "ok" } ],
366
- "complete": true
367
- }
368
- ```
202
+ ## Layout
369
203
 
370
- Read `complete` before you trust that list. If the VPN was down, `machine-b`
371
- would report unreachable, `complete` would be `false`, and the response would
372
- still be a `200` — because "the machine did not answer" and "the session does
373
- not exist" are different facts and must never look the same.
374
-
375
- **Send an instruction to a session on the other machine:**
376
-
377
- ```sh
378
- curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \
379
- -d '{"text":"run the test suite","submit":true}' \
380
- "$FLEET/v1/machines/machine-b/sessions/s42/input"
381
- ```
382
-
383
- ```json
384
- { "outcome": "submitted" }
385
- ```
386
-
387
- If that comes back `"unknown"`, the text is stranded in the composer on the
388
- other machine and was not sent. Retry the identical call with
389
- `"resumeIfStranded": true`.
390
-
391
- **Answer the dialog it is blocked on** — quoting the nonce from the read above:
392
-
393
- ```sh
394
- curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \
395
- -d '{"choice":2,"nonce":"a3f1"}' \
396
- "$FLEET/v1/machines/machine-b/sessions/s42/respond"
397
- ```
398
-
399
- `choice` is 1-based against `prompt.options`, so this answers **"Yes"**. If the
400
- prompt changed while you were deciding, the nonce no longer matches and the
401
- answer is refused rather than applied to a different question.
402
-
403
- ### What has to be true for that to work
404
-
405
- | | machine-a (calls) | machine-b (owns the session) |
406
- |---|---|---|
407
- | Reachable address | — | `FLEET_ADDR` on the LAN/VPN interface |
408
- | Peer configured | `FLEET_PEERS` | — |
409
- | Write to own sessions | — | `FLEET_ALLOW_MUTATIONS=1` |
410
- | Forward writes to a peer | `FLEET_ALLOW_RELAY=1` | — |
411
-
412
- With a principal table those two booleans become per-identity grants: the caller
413
- needs `relay` on machine-a, and the verb grant (`send`) is checked on machine-b.
414
- They are separate refusals and you meet them one at a time. Both default to
415
- denied, on a fresh deployment as much as an established one.
416
-
417
- > **Exposing this is a decision, not a default.** The service reads paths and,
418
- > when mutations are enabled, starts processes. Put it on a VPN or a trusted
419
- > LAN segment, bind a specific interface, and give each caller its own
420
- > credential with only the grants it needs.
204
+ | Path | What lives there |
205
+ |---|---|
206
+ | `cmd/muster/` | The `muster` binary: `serve`, `doctor`, `compat`, `principal` |
207
+ | `internal/` | The service: HTTP API, state store, session drivers, delivery, peer federation |
208
+ | `clients/` | Reference shell clients — [`clients/README.md`](clients/README.md) |
209
+ | `scripts/` | Gate, smoke, build and release scripts |
210
+ | `npm/` | The launcher and platform packages published to npm |
211
+ | `docs/` | API reference, specs, install and deploy procedures, ADRs — see [Documentation](#documentation) |
421
212
 
422
213
  ---
423
214
 
@@ -432,6 +223,7 @@ denied, on a fresh deployment as much as an established one.
432
223
  | 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 |
433
224
  | Work on the service | [`docs/internals.md`](docs/internals.md) — measurements, decided questions, known gaps |
434
225
  | 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 |
226
+ | Set up two machines | [`docs/multi-machine.md`](docs/multi-machine.md) — credentials, bind address, peer list, verify; then [`docs/driving-a-machine.md`](docs/driving-a-machine.md) for what a caller can do across the hop |
435
227
  | Install it on a new machine | [`docs/install.md`](docs/install.md) — from nothing to a running service, ending with `muster doctor` |
436
228
  | Deploy it | [`docs/deploy.md`](docs/deploy.md) — from a merged commit to a service that already runs |
437
229
  | Declare a machine-wide session identity | [`docs/session-identity.md`](docs/session-identity.md) — `sessionEnv`, precedence against a caller, verification |
@@ -457,34 +249,33 @@ implemented it:
457
249
  - ✅ **A first working driver** — a terminal multiplexer running an interactive
458
250
  agent CLI. It amended four sections of the spec and found one defect the spec
459
251
  cannot fix on its own.
460
- - ✅ **Event subscription** — over the substrate's own push channel, served as
461
- SSE with cursors, epoch, retention and announced resync. No polling anywhere.
462
- - ✅ **A second driver: a remote peer** — federation, proven. A caller asks a
463
- service holding no local drivers, which proxies into a second service and
464
- back with real sessions in ~30 ms. Neither service needed a special case.
252
+ - ✅ **Event subscription** — served as SSE with cursors, epoch, retention and
253
+ announced resync. No polling anywhere.
254
+ - ✅ **A second driver: a remote peer** — federation, proven; neither service
255
+ needed a special case.
465
256
  - ✅ **Answering, not just observing** — enumerated options, a nonce, and
466
257
  verification that the prompt actually cleared.
467
258
  - ✅ **A second *local* driver** — a third-party agent spawned as a subprocess,
468
- the first able to declare `observesState: true`. Two runtimes on one machine
469
- at once, which is the proof the interface was worth having.
259
+ the first able to declare `observesState: true`.
470
260
  - ✅ **State that survives a restart** — idempotency keys, event epoch and
471
- cursor, and driver session records persisted atomically, so reconciliation
472
- can tell adopted from orphaned rather than calling everything orphaned.
261
+ cursor, and driver session records persisted atomically.
262
+ - ✅ **A supervisor as a client** — a supervisor now talks to this service
263
+ instead of to the terminal multiplexer. The plan that got there, including the
264
+ precondition that could not be discharged from inside this repository, is
265
+ [`docs/adoption.md`](docs/adoption.md).
266
+ - ✅ **A release path** — one npm install surface, release candidates and
267
+ finals, and a Windows build that is tested on every release.
473
268
 
474
- Next, and this is where the value actually lands:
269
+ Next:
475
270
 
476
- - ⬜ **A supervisor as a client**, replacing direct terminal-multiplexer access.
477
- Until that happens this is a second implementation of session management
478
- rather than a replacement for one. Planned in
479
- [`docs/adoption.md`](docs/adoption.md) — including the one precondition that
480
- cannot be discharged from inside this repository: nothing here prevents two
481
- machines editing one working tree, because repository state is a non-goal.
482
271
  - ⬜ **Credential lifecycle.** Grants are per principal and per verb, and
483
272
  enrolment is a command. Nothing expires, a compromised token is revoked by
484
273
  editing a file, and changing a principal's grants means removing and
485
274
  re-adding it.
486
275
  - ⬜ **Metrics.** Subprocess spawn cost is known to degrade with host load —
487
276
  8× on a machine under heavy load — and nothing measures it in production.
277
+ - ⬜ **Windows publication and an interactive-terminal driver there.** Built and
278
+ tested today; not yet on npm, and the multiplexer driver is not available.
488
279
 
489
280
  The honest list of what is missing is in
490
281
  [`docs/internals.md`](docs/internals.md), kept current on purpose.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@futurelastic/muster",
3
- "version": "0.5.0",
3
+ "version": "0.6.0-rc.2",
4
4
  "description": "muster — a machine-local service that owns session agents across a fleet. Launcher: runs the prebuilt binary for this platform.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/futurelastic/muster#readme",
@@ -20,10 +20,10 @@
20
20
  "LICENSE"
21
21
  ],
22
22
  "optionalDependencies": {
23
- "@futurelastic/muster-darwin-arm64": "0.5.0",
24
- "@futurelastic/muster-darwin-x64": "0.5.0",
25
- "@futurelastic/muster-linux-x64": "0.5.0",
26
- "@futurelastic/muster-linux-arm64": "0.5.0"
23
+ "@futurelastic/muster-darwin-arm64": "0.6.0-rc.2",
24
+ "@futurelastic/muster-darwin-x64": "0.6.0-rc.2",
25
+ "@futurelastic/muster-linux-x64": "0.6.0-rc.2",
26
+ "@futurelastic/muster-linux-arm64": "0.6.0-rc.2"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public",