@monster-orange/den 0.4.1 → 0.4.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.
- package/CHANGELOG.md +48 -0
- package/README.md +88 -158
- package/docs/distributions.md +25 -15
- package/docs/reference.md +177 -0
- package/package.json +1 -1
- package/src/shared/den-config.ts +52 -3
- package/src/shared/sessions/client.ts +7 -0
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/host.ts +33 -8
- package/src/shared/sessions/types.ts +8 -0
- package/src/ui/cli/agent-ops.ts +2 -2
- package/src/ui/cli/agents-command.ts +1 -1
- package/src/ui/cli/index.ts +33 -1
- package/src/ui/cli/sessions-command.ts +17 -5
- package/src/ui/cli/web-command.ts +7 -1
- package/src/ui/server/agent-routes.ts +2 -1
- package/src/ui/server/index.ts +11 -4
- package/src/ui/server/public/agents-page.js +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ All notable changes to Den are documented here. Each release lists the
|
|
|
4
4
|
config and manifest changes a distribution must make to move to it under
|
|
5
5
|
**Migrations**.
|
|
6
6
|
|
|
7
|
+
## [0.4.2] - 2026-11-06
|
|
8
|
+
|
|
9
|
+
The 0.4.1 carry-overs.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`sessions.max_concurrent`** (default 8): the cap on plain sessions
|
|
14
|
+
(`sessions start`) running at once. Past it, `sessions start` is refused
|
|
15
|
+
as the agents' cap is: "2 sessions are running and
|
|
16
|
+
sessions.max_concurrent is 2", then "Stop one first (`<cli> sessions
|
|
17
|
+
kill <name>`), or raise sessions.max_concurrent." `<cli> validate` names
|
|
18
|
+
a wrong value of it or of `agents.max_concurrent`.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **`agents.max_concurrent` counts agents only.** Plain sessions count
|
|
23
|
+
against `sessions.max_concurrent` instead, so a few plain shells never
|
|
24
|
+
stop an agent from starting (from the CLI, the Workspaces page, the
|
|
25
|
+
Agents tab or MCP). Its refusal says "1 agent is running and
|
|
26
|
+
agents.max_concurrent is 1".
|
|
27
|
+
- **The Agents tab's pane title and Close** name the agent by its id
|
|
28
|
+
(its display name once AG-10 adds one), never its persona; the list row
|
|
29
|
+
still shows the persona, with the id beside it.
|
|
30
|
+
- **den-claude's deny list, in the docs**: it is a convenience, not the
|
|
31
|
+
boundary. It binds cooperating agents only and does not catch every
|
|
32
|
+
spelling (a path relative to the agent's cwd, another program); the
|
|
33
|
+
server's token rules are the boundary.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **A distribution config's `web.allow_plain_http_remote`** (ignored: it is
|
|
38
|
+
read only from `config/local/user.yml`) was warned about only in the
|
|
39
|
+
in-app server log. It is now in `web.log` too (before a bind refusal it
|
|
40
|
+
often explains), in the `web up` and `web restart` output, and in
|
|
41
|
+
`<cli> validate`.
|
|
42
|
+
- **`up --help`**: `-w, --workspace <ws>` ran into its text ("<ws>act in
|
|
43
|
+
that…"); there are two spaces now. `down`, `restart` and `setup --help`
|
|
44
|
+
were already spaced.
|
|
45
|
+
|
|
46
|
+
### Migrations
|
|
47
|
+
|
|
48
|
+
- **More than 4 plain sessions at once**, or a script that relied on
|
|
49
|
+
`agents.max_concurrent` to cap plain sessions: set
|
|
50
|
+
`sessions.max_concurrent` (default 8). `agents.max_concurrent` no longer
|
|
51
|
+
counts them. A session host already running from 0.4.1 keeps counting
|
|
52
|
+
every session until it restarts (`sessions host --stop`, which ends its
|
|
53
|
+
sessions, or a reboot).
|
|
54
|
+
|
|
7
55
|
## [0.4.1] - 2026-11-06
|
|
8
56
|
|
|
9
57
|
### Added
|
package/README.md
CHANGED
|
@@ -1,169 +1,99 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/images/preview/mark.svg" width="72" alt="Den">
|
|
3
|
+
</p>
|
|
4
|
+
<h1 align="center">Den</h1>
|
|
5
|
+
<p align="center"><b>Your whole stack, up and green, in one place. Built so you and your agents work by the same rules.</b></p>
|
|
6
|
+
<p align="center">
|
|
7
|
+
<a href="https://www.npmjs.com/package/@monster-orange/den"><img src="https://img.shields.io/npm/v/@monster-orange/den" alt="npm"></a>
|
|
8
|
+
<img src="https://img.shields.io/npm/l/@monster-orange/den" alt="licence">
|
|
9
|
+
<a href="https://github.com/Monster-Orange/den/actions/workflows/ci.yml"><img src="https://github.com/Monster-Orange/den/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<picture>
|
|
13
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.1/hero-dark.png">
|
|
14
|
+
<img src="docs/images/0.4.1/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
|
|
15
|
+
</picture>
|
|
16
|
+
|
|
17
|
+
## What Den is
|
|
18
|
+
|
|
19
|
+
Den is an opinionated local development framework for solo developers and small teams. You point it at the
|
|
20
|
+
repos you work on. Den starts them in the right order, puts them behind a local edge, and shows you the
|
|
21
|
+
whole thing in one web shell and one CLI: what's up, what's down, why, and the fix.
|
|
22
|
+
|
|
23
|
+
It doesn't replace your tools. Your repos, Docker, your test runners, your secrets store and your agents stay
|
|
24
|
+
yours. Den connects them and turns on good practice from the start.
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="docs/images/0.4.1/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
## How it helps
|
|
31
|
+
|
|
32
|
+
- **"How do I run this?" goes away.** Each repo declares how it starts, what it needs and how to check it's
|
|
33
|
+
healthy. Den works out the order and brings everything up, the same way on every machine.
|
|
34
|
+
- **One place to look.** Health, logs, runs, routes and certificates for every service, in one shell, instead
|
|
35
|
+
of eight terminals and a wiki page.
|
|
36
|
+
- **Real hostnames locally.** Every service gets a proper hostname behind a local edge, over HTTPS when you
|
|
37
|
+
turn it on (a local CA with mkcert, or certificates you bring). Cookies, OAuth redirects and CORS behave as
|
|
38
|
+
they do in production.
|
|
39
|
+
- **Several workspaces at once.** Keep feature branches side by side as workspaces. Health covers every active
|
|
40
|
+
one, and `up`, `down`, `restart` and `setup` take `-w <workspace>`, or find the service you name across them.
|
|
41
|
+
- **Tests are part of the loop.** Smoke, browser, API, user journeys, security and performance suites run against
|
|
42
|
+
the environments you declare.
|
|
43
|
+
- **Agents work by the same rules as you.** Start an agent from a persona (planner, orchestrator, developers,
|
|
44
|
+
devops, reviewer, UX designer, or your own) in a workspace. Its terminal opens in the Agents tab and keeps
|
|
45
|
+
running when you close the page or restart the web server. Over MCP it sees your stack with the same guards
|
|
46
|
+
and audit trail as a person, under a token of its own. Agents can read and investigate. Acting is opt-in,
|
|
47
|
+
and the lock still guards starting and stopping things.
|
|
48
|
+
- **Safe on localhost, signed in beyond it.** On localhost there is nothing to sign in to. Reached through the
|
|
49
|
+
edge or your network, every page and call needs a sign-in, and there is no password: `acme web login` prints a
|
|
50
|
+
one-time link.
|
|
51
|
+
- **Opinionated, not locked in.** Good defaults are switched on, and almost everything can be switched off. The
|
|
52
|
+
safety guards stay on. Den never needs our accounts, secrets or certificates. Everything specific to you
|
|
53
|
+
lives in your own repo.
|
|
54
|
+
|
|
55
|
+
<p align="center">
|
|
56
|
+
<img src="docs/images/0.4.1/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
|
|
57
|
+
<img src="docs/images/0.4.1/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
|
|
58
|
+
<img src="docs/images/0.4.1/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
|
|
59
|
+
<img src="docs/images/0.4.1/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
|
|
60
|
+
</p>
|
|
61
|
+
|
|
62
|
+
## Get started
|
|
63
|
+
|
|
64
|
+
You need Node 22.13 or later, and Docker for anything that runs in containers. mkcert is only needed if you want Den to issue local HTTPS certificates.
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npx @monster-orange/den create acme # your own Den, named after your product
|
|
68
|
+
cd acme && npm install
|
|
69
|
+
./bin/acme register ../shop-api # add a repo (repeat for each one)
|
|
70
|
+
./bin/acme up # start everything, in order
|
|
71
|
+
./bin/acme web # open the shell
|
|
51
72
|
```
|
|
52
73
|
|
|
53
|
-
`
|
|
74
|
+
That's it: `acme` is now your tool, with your name, your CLI and your config, in a repo you own.
|
|
75
|
+
[Step by step →](docs/reference.md#make-it-your-own)
|
|
54
76
|
|
|
55
|
-
|
|
56
|
-
product:
|
|
57
|
-
name:
|
|
58
|
-
acme # CLI "acme", env vars ACME_*, config/acme.yml,
|
|
59
|
-
# manifests .acme.yml, state ~/.acme,
|
|
60
|
-
# managed clones .acme-services/, workspaces acme-workspaces/
|
|
61
|
-
# Override any of them: cli, env_prefix, config, manifest, state_dir,
|
|
62
|
-
# services_dir, workspaces_dir
|
|
63
|
-
```
|
|
77
|
+
## How it fits together
|
|
64
78
|
|
|
65
|
-
Den
|
|
66
|
-
`./bin/den`, the config is `config/den.yml`, and env vars are `DEN_*`.
|
|
67
|
-
|
|
68
|
-
## Repos opt in with a manifest
|
|
69
|
-
|
|
70
|
-
A repo joins by carrying a manifest at its root (`.<name>.yml`, e.g.
|
|
71
|
-
`.den.yml`) whose commands delegate to the repo's own targets. Den never
|
|
72
|
-
reaches inside a repo beyond that. When a repo has no manifest, the
|
|
73
|
-
distribution can hold one for it in `manifests/<repo>.<name>.yml`; the repo's
|
|
74
|
-
own manifest always wins.
|
|
75
|
-
|
|
76
|
-
```yaml
|
|
77
|
-
# demo-api: a self-contained service. Nothing it calls, so it starts alone.
|
|
78
|
-
version: 1
|
|
79
|
-
name: demo-api
|
|
80
|
-
application: shop # the application this repo belongs to (den apps)
|
|
81
|
-
compose:
|
|
82
|
-
files: [docker-compose.yml]
|
|
83
|
-
routes:
|
|
84
|
-
api: api:8080 # https://api.demo-api.<edge domain>
|
|
85
|
-
services:
|
|
86
|
-
demo-api:
|
|
87
|
-
commands:
|
|
88
|
-
local:
|
|
89
|
-
setup: make setup
|
|
90
|
-
preflight: make test
|
|
91
|
-
# OWASP ZAP target for the security pillar (`make test-sec-api env=local`).
|
|
92
|
-
security:
|
|
93
|
-
api:
|
|
94
|
-
hosts:
|
|
95
|
-
local: ["https://api.demo-api.globex.localhost"]
|
|
96
|
-
```
|
|
79
|
+
<p align="center"><img src="docs/images/preview/how-it-fits.svg" width="100%" alt="Den, the engine, runs your distribution, which holds your config; the distribution groups repos into workspaces, and each repo declares its services"></p>
|
|
97
80
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
- **Workspaces.** A workspace is a set of repos. Each is either
|
|
104
|
-
_dev-managed_ (your checkout, your branch) or _managed_ (cloned by Den and
|
|
105
|
-
kept on a tracking branch).
|
|
106
|
-
- **Derived dependency graph.** Start and stop order comes from real signals in
|
|
107
|
-
each repo — SDK imports, `*_URL` env vars, IaC references, event rules,
|
|
108
|
-
readiness probes — tuned in config with `graph.aliases`, `graph.edges` and
|
|
109
|
-
an optional shared local-infra repo (`graph.infra`).
|
|
110
|
-
- **Local HTTPS edge.** One local proxy routes `https://<route>.<domain>` to
|
|
111
|
-
each service, with an optional project CA.
|
|
112
|
-
- **Test pillars.** `smoke`, `browser` (synthetic Playwright), `api`,
|
|
113
|
-
`journeys` (agent-driven), `perf` (k6), `security` (OWASP ZAP against hosts
|
|
114
|
-
you own) and `suites` (a repo's own commands).
|
|
115
|
-
- **Releases.** A release view over the repos and deploy workflows your config
|
|
116
|
-
lists.
|
|
117
|
-
- **Web UI and CLI** over the same state.
|
|
118
|
-
|
|
119
|
-
## Strategies
|
|
120
|
-
|
|
121
|
-
Concerns that vary by org are strategies selected in config, never hard-wired:
|
|
122
|
-
|
|
123
|
-
| Concern | Built-in choices | Chosen in |
|
|
124
|
-
| ---------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
125
|
-
| Secrets backend | `none`, `env`, `doppler`, `1password` | `secrets.provider` in the org config; per engineer in `config/local/user.yml`; per shell `<PREFIX>_SECRETS_PROVIDER` |
|
|
126
|
-
| Test-user tokens | `local-jwt`, `cognito`, `auth0`, `none` | `auth.provider` in `config/environments/<env>.json` |
|
|
127
|
-
|
|
128
|
-
A new secrets backend implements `SecretsProvider`
|
|
129
|
-
(`src/shared/secrets/types.ts`) and registers as a strategy
|
|
130
|
-
(`src/shared/secrets/strategies.ts`); a distribution needs no store at all
|
|
131
|
-
(`none`, see "Secrets" in `docs/distributions.md`). A new identity provider implements a token
|
|
132
|
-
provider in `src/shared/auth/token-providers/`.
|
|
133
|
-
|
|
134
|
-
## CLI
|
|
135
|
-
|
|
136
|
-
```bash
|
|
137
|
-
den up [repo] # start the repo's derived dependency closure (-x deps only, -n dry run)
|
|
138
|
-
den down [repo] # stop it cleanly (--with-deps: and what it sits on)
|
|
139
|
-
den status # service health for the active workspace
|
|
140
|
-
den apps # Org > Application > Repo > Services
|
|
141
|
-
den graph [repo] # the derived dependency graph
|
|
142
|
-
den logs [repo] # tail a repo's local logs
|
|
143
|
-
den use-local [repo] # use your checkout · use-managed [repo] to switch back
|
|
144
|
-
den workspace list # workspaces (activate · deactivate · use · create · delete)
|
|
145
|
-
den run # run test suites (interactive, or with options)
|
|
146
|
-
den web # the web UI
|
|
147
|
-
den --help # everything else
|
|
148
|
-
```
|
|
81
|
+
- **Den** is the engine, from npm. It knows nothing about your company.
|
|
82
|
+
- **Your distribution** (`acme`) is a small repo you own. It holds your product name, brand, environments,
|
|
83
|
+
hosts and secrets setup.
|
|
84
|
+
- **Workspaces** group the repos you work on together. **Manifests** in each repo say how its services start,
|
|
85
|
+
connect and get checked.
|
|
149
86
|
|
|
150
|
-
|
|
87
|
+
## Coming next
|
|
151
88
|
|
|
152
|
-
|
|
89
|
+
One workspace focus with Switch, agent pane layouts, and a Start dialog with personas, roles and a choice of
|
|
90
|
+
runtime and model.
|
|
153
91
|
|
|
154
|
-
|
|
155
|
-
npm ci
|
|
156
|
-
npm run typecheck
|
|
157
|
-
npm run lint
|
|
158
|
-
npm test
|
|
159
|
-
DEN_ORG_NAMES_FILE=<patterns file> node scripts/org-name-gate.mjs
|
|
160
|
-
```
|
|
92
|
+
## Docs
|
|
161
93
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
repo (a CI secret) so the list itself is never published. See
|
|
165
|
-
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
94
|
+
[Reference](docs/reference.md) (configuration, manifests, strategies, CLI) · [Distributions](docs/distributions.md) ·
|
|
95
|
+
[Changelog](CHANGELOG.md)
|
|
166
96
|
|
|
167
|
-
|
|
97
|
+
Found a bug or have an idea? [Open an issue](https://github.com/Monster-Orange/den/issues/new).
|
|
168
98
|
|
|
169
|
-
|
|
99
|
+
MIT licence.
|
package/docs/distributions.md
CHANGED
|
@@ -1581,9 +1581,16 @@ the profile's `state_dir` (`~/.<name>`), or `<PREFIX>_HOME`.
|
|
|
1581
1581
|
- Stopping the host (`sessions host --stop`, or a reboot) ends its
|
|
1582
1582
|
sessions. They stay listed as exited, with their scrollback, until
|
|
1583
1583
|
`sessions kill` removes them.
|
|
1584
|
-
- `
|
|
1584
|
+
- `sessions.max_concurrent` caps the plain sessions (`sessions start`)
|
|
1585
|
+
running at once (default 8), and `agents.max_concurrent` the agents
|
|
1586
|
+
(default 4). Each counts only its own, so a few plain shells never stop
|
|
1587
|
+
an agent from starting, nor agents a shell. Past either, the start is
|
|
1588
|
+
refused, naming the key ("2 sessions are running and
|
|
1589
|
+
sessions.max_concurrent is 2"); `<cli> validate` names a wrong value.
|
|
1585
1590
|
|
|
1586
1591
|
```yaml
|
|
1592
|
+
sessions:
|
|
1593
|
+
max_concurrent: 8
|
|
1587
1594
|
agents:
|
|
1588
1595
|
max_concurrent: 4
|
|
1589
1596
|
```
|
|
@@ -1614,7 +1621,7 @@ server scoped to its workspace.
|
|
|
1614
1621
|
|
|
1615
1622
|
- `start` runs the agent as session `<dist>-<ws>:<id>` in the workspace's
|
|
1616
1623
|
checkout dir (its `directory`, else `den-workspaces/<ws>`), capped by
|
|
1617
|
-
`agents.max_concurrent
|
|
1624
|
+
`agents.max_concurrent` (agents only: plain sessions never count). The id defaults to the persona's id; an id is
|
|
1618
1625
|
unique in the distribution, so a second workspace's planner gets
|
|
1619
1626
|
`planner-<ws>` unless you give `--id`.
|
|
1620
1627
|
- The **agent record**, `<stateDir>/agents/<id>.json` (0600), holds its id,
|
|
@@ -1761,19 +1768,22 @@ An agent runs as your OS user, so it could still read `web.secret` itself;
|
|
|
1761
1768
|
the token keeps what Den does on its behalf attributable and bounded.
|
|
1762
1769
|
**The boundary is the server's token rules** (above), not a deny list.
|
|
1763
1770
|
|
|
1764
|
-
|
|
1765
|
-
(`--settings`) whose `permissions.deny` lists
|
|
1766
|
-
dir>/web.secret` and `<state dir>/sessions/**`, and
|
|
1767
|
-
Code's wildcard form for the path
|
|
1768
|
-
each of `cat`, `head`, `tail`,
|
|
1769
|
-
`base64` and `cp` followed by it),
|
|
1770
|
-
and under `$HOME`, with a path that
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1771
|
+
**The deny list is a convenience, not the boundary.** den-claude passes
|
|
1772
|
+
Claude Code a settings file (`--settings`) whose `permissions.deny` lists
|
|
1773
|
+
`Read` of `<state dir>/web.secret` and `<state dir>/sessions/**`, and
|
|
1774
|
+
`Bash` rules in Claude Code's wildcard form for the path
|
|
1775
|
+
(`Bash(*<state dir>/web.secret*)`, and each of `cat`, `head`, `tail`,
|
|
1776
|
+
`less`, `more`, `xxd`, `od`, `strings`, `base64` and `cp` followed by it),
|
|
1777
|
+
spelled as an absolute path, under `~` and under `$HOME`, with a path that
|
|
1778
|
+
has spaces also backslash-escaped. It binds cooperating agents only: an
|
|
1779
|
+
agent that ignores its permission config can read the files anyway. And
|
|
1780
|
+
it does not catch every spelling: a path relative to the agent's cwd
|
|
1781
|
+
(`cat ../.den/web.secret`, say), another program, a script or a copy under
|
|
1782
|
+
another name all get past it. The server's token rules (above)
|
|
1783
|
+
are the boundary: whatever an agent reads, its token reaches only its own
|
|
1784
|
+
session's routes, never a shell-grade route or the lock. OS-user isolation
|
|
1785
|
+
(a separate user, a container, a sandbox) is not in 0.4.x. `web logout
|
|
1786
|
+
--all` revokes every agent's token (above, under Signing in).
|
|
1777
1787
|
|
|
1778
1788
|
**What the scope does not do.** An agent's MCP scope binds cooperating
|
|
1779
1789
|
agents only. A runtime with a shell can still run `<cli>` itself, outside
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Den reference
|
|
2
|
+
|
|
3
|
+
The technical reference that used to be the README. It will be split into separate pages (configuration, CLI, manifests, edge, secrets, tests, MCP) as part of DOC-2.
|
|
4
|
+
|
|
5
|
+
## Make it your own
|
|
6
|
+
|
|
7
|
+
1. **Create** a distribution named after your product:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx @monster-orange/den create acme # → ./acme
|
|
11
|
+
cd acme && npm install
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
2. **Name** it: `den.yml` holds `product: { name: acme }`, so the CLI is
|
|
15
|
+
`./bin/acme`, env vars are `ACME_*`, state lives in `~/.acme`. Pass
|
|
16
|
+
`--cli`, `--env-prefix` or `--title` to `create`, or edit `den.yml`.
|
|
17
|
+
3. **Brand** it: `brand:` in `config/acme.yml` sets the name, tagline and
|
|
18
|
+
banner people see in the CLI and web UI.
|
|
19
|
+
4. **Point it at your repos**: set `github_org` in `config/acme.yml`, then
|
|
20
|
+
`./bin/acme register ../shop-api` (or add a manifest per repo under
|
|
21
|
+
`manifests/`).
|
|
22
|
+
5. **Run** it: `./bin/acme up`, `./bin/acme web`, `make test`.
|
|
23
|
+
|
|
24
|
+
Every field, the bin shim, the make fragment and upgrading are in
|
|
25
|
+
[docs/distributions.md](distributions.md);
|
|
26
|
+
[examples/distribution/](../examples/distribution) is what `create acme` writes.
|
|
27
|
+
|
|
28
|
+
## Distributions
|
|
29
|
+
|
|
30
|
+
You run Den through a small repo of your own (a _distribution_):
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
my-dev/
|
|
34
|
+
├── den.yml # who this distribution is (below)
|
|
35
|
+
├── package.json # pins an exact @monster-orange/den version
|
|
36
|
+
├── config/<name>.yml # your org config
|
|
37
|
+
├── config/environments/ # one <env>.json per environment
|
|
38
|
+
├── manifests/ # manifests for repos that don't carry their own
|
|
39
|
+
├── tests/pillars/<pillar>/ # your own suites (e.g. tests/pillars/perf/<module>/test.js)
|
|
40
|
+
├── bin/<cli> # shim: DEN_ROOT=<this dir> exec node_modules/@monster-orange/den/bin/den
|
|
41
|
+
└── Makefile # include node_modules/@monster-orange/den/make/den.mk
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`den.yml` names the product. Every runtime name derives from it:
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
product:
|
|
48
|
+
name:
|
|
49
|
+
acme # CLI "acme", env vars ACME_*, config/acme.yml,
|
|
50
|
+
# manifests .acme.yml, state ~/.acme,
|
|
51
|
+
# managed clones .acme-services/, workspaces acme-workspaces/
|
|
52
|
+
# Override any of them: cli, env_prefix, config, manifest, state_dir,
|
|
53
|
+
# services_dir, workspaces_dir
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Den's own checkout is a distribution named `den`, so in this repo the CLI is
|
|
57
|
+
`./bin/den`, the config is `config/den.yml`, and env vars are `DEN_*`.
|
|
58
|
+
|
|
59
|
+
## Repos opt in with a manifest
|
|
60
|
+
|
|
61
|
+
A repo joins by carrying a manifest at its root (`.<name>.yml`, e.g.
|
|
62
|
+
`.den.yml`) whose commands delegate to the repo's own targets. Den never
|
|
63
|
+
reaches inside a repo beyond that. When a repo has no manifest, the
|
|
64
|
+
distribution can hold one for it in `manifests/<repo>.<name>.yml`; the repo's
|
|
65
|
+
own manifest always wins.
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
# demo-api: a self-contained service. Nothing it calls, so it starts alone.
|
|
69
|
+
version: 1
|
|
70
|
+
name: demo-api
|
|
71
|
+
application: shop # the application this repo belongs to (den apps)
|
|
72
|
+
compose:
|
|
73
|
+
files: [docker-compose.yml]
|
|
74
|
+
routes:
|
|
75
|
+
api: api:8080 # https://api.demo-api.<edge domain>
|
|
76
|
+
services:
|
|
77
|
+
demo-api:
|
|
78
|
+
commands:
|
|
79
|
+
local:
|
|
80
|
+
setup: make setup
|
|
81
|
+
preflight: make test
|
|
82
|
+
# OWASP ZAP target for the security pillar (`make test-sec-api env=local`).
|
|
83
|
+
security:
|
|
84
|
+
api:
|
|
85
|
+
hosts:
|
|
86
|
+
local: ["https://api.demo-api.globex.localhost"]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
That is `examples/demo/demo-api/.den.yml`; the other demo repos are in
|
|
90
|
+
[`examples/demo/`](../examples/demo/).
|
|
91
|
+
|
|
92
|
+
## What it does
|
|
93
|
+
|
|
94
|
+
- **Workspaces.** A workspace is a set of repos. Each is either
|
|
95
|
+
_dev-managed_ (your checkout, your branch) or _managed_ (cloned by Den and
|
|
96
|
+
kept on a tracking branch).
|
|
97
|
+
- **Derived dependency graph.** Start and stop order comes from real signals in
|
|
98
|
+
each repo — SDK imports, `*_URL` env vars, IaC references, event rules,
|
|
99
|
+
readiness probes — tuned in config with `graph.aliases`, `graph.edges` and
|
|
100
|
+
an optional shared local-infra repo (`graph.infra`).
|
|
101
|
+
- **Local HTTPS edge.** One local proxy routes `https://<route>.<domain>` to
|
|
102
|
+
each service, with an optional project CA.
|
|
103
|
+
- **Test pillars.** `smoke`, `browser` (synthetic Playwright), `api`,
|
|
104
|
+
`journeys` (agent-driven), `perf` (k6), `security` (OWASP ZAP against hosts
|
|
105
|
+
you own) and `suites` (a repo's own commands).
|
|
106
|
+
- **Releases.** A release view over the repos and deploy workflows your config
|
|
107
|
+
lists.
|
|
108
|
+
- **Agents and sessions.** `sessions start` runs a terminal that outlives the
|
|
109
|
+
web server; `agents start` runs a persona (Den ships a default pack) in a
|
|
110
|
+
workspace's checkout, with an MCP server pinned to that workspace and a
|
|
111
|
+
token of its own. The web UI's Agents tab lists them and opens their
|
|
112
|
+
terminals.
|
|
113
|
+
- **Browser sign-in beyond loopback.** On localhost no route needs a sign-in.
|
|
114
|
+
Through the edge or on a non-loopback bind, every route needs one:
|
|
115
|
+
`<cli> web login` prints a one-time link (`web open` opens it).
|
|
116
|
+
- **Web UI and CLI** over the same state.
|
|
117
|
+
|
|
118
|
+
## Strategies
|
|
119
|
+
|
|
120
|
+
Concerns that vary by org are strategies selected in config, never hard-wired:
|
|
121
|
+
|
|
122
|
+
| Concern | Built-in choices | Chosen in |
|
|
123
|
+
| ---------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
124
|
+
| Secrets backend | `none`, `env`, `doppler`, `1password` | `secrets.provider` in the org config; per engineer in `config/local/user.yml`; per shell `<PREFIX>_SECRETS_PROVIDER` |
|
|
125
|
+
| Test-user tokens | `local-jwt`, `cognito`, `auth0`, `none` | `auth.provider` in `config/environments/<env>.json` |
|
|
126
|
+
|
|
127
|
+
A new secrets backend implements `SecretsProvider`
|
|
128
|
+
(`src/shared/secrets/types.ts`) and registers as a strategy
|
|
129
|
+
(`src/shared/secrets/strategies.ts`); a distribution needs no store at all
|
|
130
|
+
(`none`, see "Secrets" in `docs/distributions.md`). A new identity provider implements a token
|
|
131
|
+
provider in `src/shared/auth/token-providers/`.
|
|
132
|
+
|
|
133
|
+
## CLI
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
den up [repo] # start the repo's derived dependency closure (-x deps only, -n dry run)
|
|
137
|
+
den down [repo] # stop it cleanly (--with-deps: and what it sits on)
|
|
138
|
+
den restart [svc...] # restart services (-A <app> for a whole application)
|
|
139
|
+
den status # service health for the active workspace
|
|
140
|
+
den apps # Org > Application > Repo > Services
|
|
141
|
+
den graph [repo] # the derived dependency graph
|
|
142
|
+
den logs [repo] # tail a repo's local logs
|
|
143
|
+
den use-local [repo] # use your checkout · use-managed [repo] to switch back
|
|
144
|
+
den workspace list # workspaces (activate · deactivate · use · create · delete)
|
|
145
|
+
den run # run test suites (interactive, or with options)
|
|
146
|
+
den web # the web UI (open · login · sessions · logout · token)
|
|
147
|
+
den sessions list # terminals that outlive the web server (start · attach · kill)
|
|
148
|
+
den agents list -w ws # a workspace's agents (start · stop · resume · personas)
|
|
149
|
+
den doctor # the strategies in use and whether each can work (--probe)
|
|
150
|
+
den --help # everything else
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`up`, `down`, `restart` and `setup` take `-w <workspace>`. Without it, a
|
|
154
|
+
service you name is found in the focused workspace first, then across the
|
|
155
|
+
other active workspaces; when it is in several, the error lists the `-w`
|
|
156
|
+
command for each.
|
|
157
|
+
|
|
158
|
+
In a distribution the command is that distribution's `cli` name.
|
|
159
|
+
|
|
160
|
+
## Developing Den
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npm ci
|
|
164
|
+
npm run typecheck
|
|
165
|
+
npm run lint
|
|
166
|
+
npm test
|
|
167
|
+
DEN_ORG_NAMES_FILE=<patterns file> node scripts/org-name-gate.mjs
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
CI runs the same gates. The org-name gate fails the build when any file names
|
|
171
|
+
an organisation, product, host or account; its patterns are kept out of this
|
|
172
|
+
repo (a CI secret) so the list itself is never published. See
|
|
173
|
+
[CONTRIBUTING.md](../CONTRIBUTING.md).
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
[MIT](../LICENSE)
|
package/package.json
CHANGED
package/src/shared/den-config.ts
CHANGED
|
@@ -244,10 +244,16 @@ export interface DenConfig {
|
|
|
244
244
|
*/
|
|
245
245
|
mcp?: McpConfig;
|
|
246
246
|
/**
|
|
247
|
-
* Agents: `max_concurrent` caps the
|
|
248
|
-
* distribution (default 4). See
|
|
247
|
+
* Agents: `max_concurrent` caps the agents running at once in this
|
|
248
|
+
* distribution (default 4); plain sessions never count. See
|
|
249
|
+
* readAgentsConfig.
|
|
249
250
|
*/
|
|
250
251
|
agents?: AgentsConfig;
|
|
252
|
+
/**
|
|
253
|
+
* Plain sessions (`<cli> sessions start`): `max_concurrent` caps those
|
|
254
|
+
* running at once (default 8); agents never count. See readSessionsConfig.
|
|
255
|
+
*/
|
|
256
|
+
sessions?: SessionsConfig;
|
|
251
257
|
/**
|
|
252
258
|
* The web UI: `port` (read by edge/config.ts readWebPort) and `service`,
|
|
253
259
|
* the launchd service `<cli> service` installs. See readWebServiceConfig.
|
|
@@ -558,6 +564,7 @@ export function readDenConfig(): DenConfig {
|
|
|
558
564
|
security: parsed?.security,
|
|
559
565
|
mcp: parsed?.mcp,
|
|
560
566
|
agents: parsed?.agents,
|
|
567
|
+
sessions: parsed?.sessions,
|
|
561
568
|
web: parsed?.web,
|
|
562
569
|
test_accounts: parsed?.test_accounts,
|
|
563
570
|
defaults: parsed?.defaults,
|
|
@@ -739,7 +746,7 @@ export function readMcpConfig(
|
|
|
739
746
|
|
|
740
747
|
/** `agents:` in the org config. */
|
|
741
748
|
export interface AgentsConfig {
|
|
742
|
-
/**
|
|
749
|
+
/** Agents that may run at once (default 4); plain sessions never count. */
|
|
743
750
|
max_concurrent?: number;
|
|
744
751
|
/**
|
|
745
752
|
* Persona packs (AG-2a): directories of persona files, relative to the
|
|
@@ -845,6 +852,48 @@ export function readAgentsConfig(
|
|
|
845
852
|
return out;
|
|
846
853
|
}
|
|
847
854
|
|
|
855
|
+
/** `sessions:` in the org config: plain sessions (`<cli> sessions start`). */
|
|
856
|
+
export interface SessionsConfig {
|
|
857
|
+
/** Plain sessions that may run at once (default 8); agents never count. */
|
|
858
|
+
max_concurrent?: number;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export const DEFAULT_SESSIONS_MAX_CONCURRENT = 8;
|
|
862
|
+
|
|
863
|
+
/** `sessions:` with its defaults applied. */
|
|
864
|
+
export interface ResolvedSessionsConfig {
|
|
865
|
+
maxConcurrent: number;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* The effective sessions config. Anything malformed throws, naming the key,
|
|
870
|
+
* as readAgentsConfig does: a typo must not lift the cap.
|
|
871
|
+
*/
|
|
872
|
+
export function readSessionsConfig(
|
|
873
|
+
config: DenConfig = readDenConfig(),
|
|
874
|
+
): ResolvedSessionsConfig {
|
|
875
|
+
const raw = (config.sessions ?? {}) as unknown;
|
|
876
|
+
if (!isMapping(raw))
|
|
877
|
+
throw new Error(`sessions: must be a mapping (got ${JSON.stringify(raw)})`);
|
|
878
|
+
for (const k of Object.keys(raw))
|
|
879
|
+
if (k !== "max_concurrent")
|
|
880
|
+
throw new Error(
|
|
881
|
+
`sessions.${k}: unknown key (sessions: takes max_concurrent)`,
|
|
882
|
+
);
|
|
883
|
+
const out: ResolvedSessionsConfig = {
|
|
884
|
+
maxConcurrent: DEFAULT_SESSIONS_MAX_CONCURRENT,
|
|
885
|
+
};
|
|
886
|
+
const max = raw.max_concurrent;
|
|
887
|
+
if (max !== undefined) {
|
|
888
|
+
if (typeof max !== "number" || !Number.isInteger(max) || max < 1)
|
|
889
|
+
throw new Error(
|
|
890
|
+
`sessions.max_concurrent: must be a whole number of at least 1 (got ${JSON.stringify(max)})`,
|
|
891
|
+
);
|
|
892
|
+
out.maxConcurrent = max;
|
|
893
|
+
}
|
|
894
|
+
return out;
|
|
895
|
+
}
|
|
896
|
+
|
|
848
897
|
// ── Web UI service (`<cli> service`) ──────────────────────────────────────────
|
|
849
898
|
|
|
850
899
|
/** `web:` in the org config. */
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
AttachHandlers,
|
|
17
17
|
Attachment,
|
|
18
18
|
SessionInfo,
|
|
19
|
+
SessionPool,
|
|
19
20
|
SessionSignal,
|
|
20
21
|
SessionSpec,
|
|
21
22
|
} from "./types.js";
|
|
@@ -125,6 +126,11 @@ export class HostClient extends EventEmitter {
|
|
|
125
126
|
spec: SessionSpec,
|
|
126
127
|
opts: {
|
|
127
128
|
limit?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Which running sessions `limit` counts: agents (agents.max_concurrent)
|
|
131
|
+
* or plain ones (sessions.max_concurrent). Without it, all of them.
|
|
132
|
+
*/
|
|
133
|
+
pool?: SessionPool;
|
|
128
134
|
/** `--keep-env` names, for the host's audit line (never values). */
|
|
129
135
|
keptEnv?: { name: string; credentialShaped: boolean }[];
|
|
130
136
|
/** Who asked, for that line. */
|
|
@@ -139,6 +145,7 @@ export class HostClient extends EventEmitter {
|
|
|
139
145
|
const r = await this.request("start", {
|
|
140
146
|
spec,
|
|
141
147
|
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
|
148
|
+
...(opts.pool !== undefined ? { pool: opts.pool } : {}),
|
|
142
149
|
...(opts.keptEnv?.length || opts.refusedEnv?.length
|
|
143
150
|
? { actor: opts.actor ?? "unknown" }
|
|
144
151
|
: {}),
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
Attachment,
|
|
22
22
|
SessionBackend,
|
|
23
23
|
SessionInfo,
|
|
24
|
+
SessionPool,
|
|
24
25
|
SessionRef,
|
|
25
26
|
SessionSignal,
|
|
26
27
|
SessionSpec,
|
|
@@ -32,8 +33,10 @@ export { BACKEND_ID as DEN_PTY_ID };
|
|
|
32
33
|
export interface DenPtyOptions {
|
|
33
34
|
/** A client of the running host (ensureHost). */
|
|
34
35
|
client: HostClient;
|
|
35
|
-
/** Applied to every start
|
|
36
|
+
/** Applied to every start, counting `pool`'s running sessions. */
|
|
36
37
|
limit?: number;
|
|
38
|
+
/** agents (agents.max_concurrent) or sessions (sessions.max_concurrent). */
|
|
39
|
+
pool?: SessionPool;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export class DenPtyBackend implements SessionBackend {
|
|
@@ -49,7 +52,12 @@ export class DenPtyBackend implements SessionBackend {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
async start(spec: SessionSpec): Promise<SessionRef> {
|
|
52
|
-
return (
|
|
55
|
+
return (
|
|
56
|
+
await this.opts.client.start(spec, {
|
|
57
|
+
limit: this.opts.limit,
|
|
58
|
+
...(this.opts.pool ? { pool: this.opts.pool } : {}),
|
|
59
|
+
})
|
|
60
|
+
).ref;
|
|
53
61
|
}
|
|
54
62
|
list(): Promise<SessionInfo[]> {
|
|
55
63
|
return this.opts.client.list();
|
|
@@ -29,7 +29,12 @@ import {
|
|
|
29
29
|
type Request,
|
|
30
30
|
} from "./protocol.js";
|
|
31
31
|
import type { PtyLoad, PtyProcess } from "./pty.js";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
SESSION_POOLS,
|
|
34
|
+
SESSION_SIGNALS,
|
|
35
|
+
type SessionInfo,
|
|
36
|
+
type SessionPool,
|
|
37
|
+
} from "./types.js";
|
|
33
38
|
|
|
34
39
|
export const BACKEND_ID = "den-pty";
|
|
35
40
|
|
|
@@ -58,6 +63,8 @@ interface Session {
|
|
|
58
63
|
log: ScrollbackLog;
|
|
59
64
|
/** aid → its connection. */
|
|
60
65
|
attachments: Map<string, Conn>;
|
|
66
|
+
/** The cap it counts against (start's `pool`), when one was named. */
|
|
67
|
+
pool?: SessionPool;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
class Conn {
|
|
@@ -82,6 +89,17 @@ class Refusal extends Error {
|
|
|
82
89
|
const isInt = (n: unknown, min: number, max: number): n is number =>
|
|
83
90
|
typeof n === "number" && Number.isInteger(n) && n >= min && n <= max;
|
|
84
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The cap refusal: "2 agents are running and agents.max_concurrent is 2".
|
|
94
|
+
* With no pool (an older client's limit) every session counts, and the key
|
|
95
|
+
* is agents.max_concurrent, as it was.
|
|
96
|
+
*/
|
|
97
|
+
function capMessage(live: number, limit: number, pool?: unknown): string {
|
|
98
|
+
const noun = pool === "agents" ? "agent" : "session";
|
|
99
|
+
const key = pool === "sessions" ? "sessions" : "agents";
|
|
100
|
+
return `${live} ${noun}${live === 1 ? " is" : "s are"} running and ${key}.max_concurrent is ${limit}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
function pidAlive(pid: number): boolean {
|
|
86
104
|
try {
|
|
87
105
|
process.kill(pid, 0);
|
|
@@ -528,20 +546,26 @@ export class SessionHost {
|
|
|
528
546
|
);
|
|
529
547
|
if (same.some((s) => !s.info.exited))
|
|
530
548
|
throw new Refusal("exists", `${name} is already running`);
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
).
|
|
549
|
+
// A cap counts its own pool's running sessions: agents.max_concurrent
|
|
550
|
+
// the agents, sessions.max_concurrent the plain ones. A limit with no
|
|
551
|
+
// pool (an older client) counts every running session, as before.
|
|
552
|
+
const pool = req.pool;
|
|
553
|
+
if (pool !== undefined && !SESSION_POOLS.includes(pool as SessionPool))
|
|
554
|
+
throw new Refusal(
|
|
555
|
+
"bad-request",
|
|
556
|
+
`pool must be ${SESSION_POOLS.join(" or ")}`,
|
|
557
|
+
);
|
|
534
558
|
if (req.limit !== undefined) {
|
|
535
559
|
if (!isInt(req.limit, 1, 100_000))
|
|
536
560
|
throw new Refusal(
|
|
537
561
|
"bad-request",
|
|
538
562
|
"limit must be a whole number of at least 1",
|
|
539
563
|
);
|
|
564
|
+
const live = [...this.sessions.values()].filter(
|
|
565
|
+
(s) => !s.info.exited && (pool === undefined || s.pool === pool),
|
|
566
|
+
).length;
|
|
540
567
|
if (live >= req.limit)
|
|
541
|
-
throw new Refusal(
|
|
542
|
-
"cap",
|
|
543
|
-
`${live} session${live === 1 ? " is" : "s are"} running and agents.max_concurrent is ${req.limit}`,
|
|
544
|
-
);
|
|
568
|
+
throw new Refusal("cap", capMessage(live, req.limit, pool));
|
|
545
569
|
}
|
|
546
570
|
const load = this.opts.pty();
|
|
547
571
|
if (!load.available) throw new Refusal("unavailable", load.reason);
|
|
@@ -582,6 +606,7 @@ export class SessionHost {
|
|
|
582
606
|
},
|
|
583
607
|
),
|
|
584
608
|
attachments: new Map(),
|
|
609
|
+
...(pool !== undefined ? { pool: pool as SessionPool } : {}),
|
|
585
610
|
};
|
|
586
611
|
this.sessions.set(id, s);
|
|
587
612
|
this.persist(s);
|
|
@@ -32,6 +32,14 @@ export interface SessionInfo {
|
|
|
32
32
|
prompted?: boolean;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The cap a start counts against: `agents` (agents.max_concurrent) or
|
|
37
|
+
* `sessions` (sessions.max_concurrent, the plain ones), each counting only
|
|
38
|
+
* its own running sessions.
|
|
39
|
+
*/
|
|
40
|
+
export const SESSION_POOLS = ["agents", "sessions"] as const;
|
|
41
|
+
export type SessionPool = (typeof SESSION_POOLS)[number];
|
|
42
|
+
|
|
35
43
|
/** What to run. `env` is the whole environment: nothing is inherited. */
|
|
36
44
|
export interface SessionSpec {
|
|
37
45
|
name: string;
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* actor token; the old one stops working.
|
|
33
33
|
*
|
|
34
34
|
* Nothing here takes a grant: grants come from the org config only. The
|
|
35
|
-
* host enforces agents.max_concurrent on every start.
|
|
35
|
+
* host enforces agents.max_concurrent on every start, counting agents only.
|
|
36
36
|
*/
|
|
37
37
|
import * as fs from "fs";
|
|
38
38
|
import * as path from "path";
|
|
@@ -515,7 +515,7 @@ async function startSession(
|
|
|
515
515
|
cols: (tty && d.stdout.columns) || 80,
|
|
516
516
|
rows: (tty && d.stdout.rows) || 24,
|
|
517
517
|
},
|
|
518
|
-
{ limit: d.maxConcurrent() },
|
|
518
|
+
{ limit: d.maxConcurrent(), pool: "agents" },
|
|
519
519
|
)
|
|
520
520
|
.catch((e: unknown) => capOrThrow(d, e));
|
|
521
521
|
} catch (e) {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* same conversation id (there is nothing to continue).
|
|
27
27
|
*
|
|
28
28
|
* Nothing here takes a grant: grants come from the org config only. The
|
|
29
|
-
* host enforces agents.max_concurrent on every start.
|
|
29
|
+
* host enforces agents.max_concurrent on every start, counting agents only.
|
|
30
30
|
*/
|
|
31
31
|
import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
|
|
32
32
|
import { HostError } from "../../shared/sessions/client.js";
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -2711,8 +2711,11 @@ class DenCLI {
|
|
|
2711
2711
|
);
|
|
2712
2712
|
console.log();
|
|
2713
2713
|
console.log(chalk.bold(" Flags:"));
|
|
2714
|
+
// Two spaces at least between a flag and its text, a long one too.
|
|
2714
2715
|
const row = (f: string, d: string) =>
|
|
2715
|
-
console.log(
|
|
2716
|
+
console.log(
|
|
2717
|
+
` ${chalk.cyan(f.padEnd(Math.max(20, f.length + 2)))}${d}`,
|
|
2718
|
+
);
|
|
2716
2719
|
row(
|
|
2717
2720
|
"-x, --exclude",
|
|
2718
2721
|
"start only the dependencies, not the current/seed repo",
|
|
@@ -4660,6 +4663,35 @@ class DenCLI {
|
|
|
4660
4663
|
if (!d.available) warn(`edge.tls ${edge.tls.strategy}: ${d.reason}`);
|
|
4661
4664
|
}
|
|
4662
4665
|
}
|
|
4666
|
+
// The web keys: a wrong value would stop `web up`; one read and
|
|
4667
|
+
// ignored (the distribution's web.allow_plain_http_remote: only
|
|
4668
|
+
// config/local/user.yml counts) is flagged, as `web up` says it.
|
|
4669
|
+
{
|
|
4670
|
+
const { ignoredConfigWarning, readWebAuthConfig } =
|
|
4671
|
+
await import("../../shared/web-session.js");
|
|
4672
|
+
try {
|
|
4673
|
+
const w = ignoredConfigWarning(
|
|
4674
|
+
readWebAuthConfig(getDistRoot(), product().configFile),
|
|
4675
|
+
);
|
|
4676
|
+
if (w) console.warn(chalk.yellow(`\n ⚠ ${w}`));
|
|
4677
|
+
} catch (e) {
|
|
4678
|
+
console.error(chalk.red(`\n ✗ web: ${(e as Error).message}`));
|
|
4679
|
+
process.exitCode = 1;
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
// The caps: agents.max_concurrent (agents) and sessions.max_concurrent
|
|
4683
|
+
// (plain sessions). A wrong value would refuse every start.
|
|
4684
|
+
{
|
|
4685
|
+
const { readAgentsConfig, readSessionsConfig } =
|
|
4686
|
+
await import("../../shared/den-config.js");
|
|
4687
|
+
for (const read of [readAgentsConfig, readSessionsConfig])
|
|
4688
|
+
try {
|
|
4689
|
+
read();
|
|
4690
|
+
} catch (e) {
|
|
4691
|
+
console.error(chalk.red(`\n ✗ ${(e as Error).message}`));
|
|
4692
|
+
process.exitCode = 1;
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4663
4695
|
const positional = args.filter((a) => !a.startsWith("-"));
|
|
4664
4696
|
const held = args.includes("--held");
|
|
4665
4697
|
// Bare `validate` in the distribution root (which has no manifest of its
|
|
@@ -13,12 +13,16 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The R8 lock is the web UI's (an in-memory admin password in the web
|
|
15
15
|
* server); CLI service actions (up, down) do not consult it, so neither do
|
|
16
|
-
* these.
|
|
16
|
+
* these. sessions.max_concurrent caps the plain sessions running at once;
|
|
17
|
+
* agents count against agents.max_concurrent instead, never this one.
|
|
17
18
|
*/
|
|
18
19
|
import { spawn } from "child_process";
|
|
19
20
|
import * as fs from "fs";
|
|
20
21
|
import * as path from "path";
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
readAgentsConfig,
|
|
24
|
+
readSessionsConfig,
|
|
25
|
+
} from "../../shared/den-config.js";
|
|
22
26
|
import { cliEntrypoint } from "../../shared/engine.js";
|
|
23
27
|
import { getDistRoot, getInvokeCwd, getStateDir } from "../../shared/paths.js";
|
|
24
28
|
import { product } from "../../shared/product.js";
|
|
@@ -64,7 +68,7 @@ export const SESSIONS_HELP = {
|
|
|
64
68
|
"session host (one per distribution, started on demand).",
|
|
65
69
|
"",
|
|
66
70
|
"{cli} sessions start --workspace <ws> --name <n> [--cwd <dir>] [--keep-env NAME…] -- <argv…>",
|
|
67
|
-
" run argv in a PTY as <dist>-<ws>:<n> (cwd: here); capped by
|
|
71
|
+
" run argv in a PTY as <dist>-<ws>:<n> (cwd: here); capped by sessions.max_concurrent",
|
|
68
72
|
" env: PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG, LC_*, TZ,",
|
|
69
73
|
" TMPDIR only; --keep-env NAME (exact, repeatable; audited) adds one",
|
|
70
74
|
" (never a person's credential: DEN_PERSON_TOKEN, a web.secret path)",
|
|
@@ -97,7 +101,10 @@ export interface SessionsDeps {
|
|
|
97
101
|
connectExisting?: () => Promise<HostClient | null>;
|
|
98
102
|
pty?: () => PtyLoad;
|
|
99
103
|
workspaceExists?: (ws: string) => boolean;
|
|
104
|
+
/** agents.max_concurrent: the agents running at once. */
|
|
100
105
|
maxConcurrent?: () => number;
|
|
106
|
+
/** sessions.max_concurrent: the plain sessions running at once. */
|
|
107
|
+
maxSessions?: () => number;
|
|
101
108
|
invokeCwd?: () => string;
|
|
102
109
|
env?: NodeJS.ProcessEnv;
|
|
103
110
|
out?: (line: string) => void;
|
|
@@ -114,7 +121,10 @@ export interface Resolved {
|
|
|
114
121
|
connectExisting: () => Promise<HostClient | null>;
|
|
115
122
|
pty: () => PtyLoad;
|
|
116
123
|
workspaceExists: (ws: string) => boolean;
|
|
124
|
+
/** agents.max_concurrent: the agents running at once. */
|
|
117
125
|
maxConcurrent: () => number;
|
|
126
|
+
/** sessions.max_concurrent: the plain sessions running at once. */
|
|
127
|
+
maxSessions: () => number;
|
|
118
128
|
invokeCwd: () => string;
|
|
119
129
|
env: NodeJS.ProcessEnv;
|
|
120
130
|
out: (line: string) => void;
|
|
@@ -180,6 +190,7 @@ export function resolveDeps(d: SessionsDeps): Resolved {
|
|
|
180
190
|
pty: d.pty ?? (() => loadPty()),
|
|
181
191
|
workspaceExists: d.workspaceExists ?? defaultWorkspaceExists,
|
|
182
192
|
maxConcurrent: d.maxConcurrent ?? (() => readAgentsConfig().maxConcurrent),
|
|
193
|
+
maxSessions: d.maxSessions ?? (() => readSessionsConfig().maxConcurrent),
|
|
183
194
|
invokeCwd: d.invokeCwd ?? getInvokeCwd,
|
|
184
195
|
env: d.env ?? process.env,
|
|
185
196
|
out: d.out ?? ((l) => console.log(l)),
|
|
@@ -437,7 +448,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
|
|
|
437
448
|
d.err(` \`${d.cli} doctor\` shows the session backend's state.`);
|
|
438
449
|
return 1;
|
|
439
450
|
}
|
|
440
|
-
const limit = d.
|
|
451
|
+
const limit = d.maxSessions();
|
|
441
452
|
const full = sessionName(d.dist, ws, name);
|
|
442
453
|
const tty = d.stdout.isTTY;
|
|
443
454
|
return withClient(d, async (c) => {
|
|
@@ -453,6 +464,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
|
|
|
453
464
|
},
|
|
454
465
|
{
|
|
455
466
|
limit,
|
|
467
|
+
pool: "sessions",
|
|
456
468
|
// Audited by the host: each name, and whether it is
|
|
457
469
|
// credential-shaped; never a value.
|
|
458
470
|
...(kept.length
|
|
@@ -480,7 +492,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
|
|
|
480
492
|
d.err(`✗ ${e.message}`);
|
|
481
493
|
if (e.code === "cap")
|
|
482
494
|
d.err(
|
|
483
|
-
` Stop one first (\`${d.cli} sessions kill <name>\`), or raise
|
|
495
|
+
` Stop one first (\`${d.cli} sessions kill <name>\`), or raise sessions.max_concurrent.`,
|
|
484
496
|
);
|
|
485
497
|
if (e.code === "exists")
|
|
486
498
|
d.err(
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
rotateWebSecret,
|
|
22
22
|
} from "../../shared/web-secret.js";
|
|
23
23
|
import {
|
|
24
|
+
ignoredConfigWarning,
|
|
24
25
|
localhostOrigin,
|
|
25
26
|
loginLink,
|
|
26
27
|
mintLoginCode,
|
|
@@ -1242,7 +1243,12 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1242
1243
|
|
|
1243
1244
|
// SEC-1b: a wrong web.allow_plain_http_remote would stop the server at
|
|
1244
1245
|
// start; say so here, naming the key, instead of "did not become ready".
|
|
1245
|
-
|
|
1246
|
+
// A key read and ignored (the distribution's web.allow_plain_http_remote:
|
|
1247
|
+
// only user.yml counts) is said here too, not only in the server's log.
|
|
1248
|
+
const ignored = ignoredConfigWarning(
|
|
1249
|
+
readWebAuthConfig(getDistRoot(), product().configFile),
|
|
1250
|
+
);
|
|
1251
|
+
if (ignored) console.log(chalk.yellow(`\n ⚠ ${ignored}`));
|
|
1246
1252
|
|
|
1247
1253
|
const apiRunning = await isDenWebApiRunning(options.port);
|
|
1248
1254
|
let running = apiRunning && (await isDenWebUiReady(options.port));
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* - a person only: an agent (its actor token, SEC-1c) can't start or
|
|
19
19
|
* stop agents from the page (403; agents use MCP agent_spawn, §5,
|
|
20
20
|
* under its own guards);
|
|
21
|
-
* - agents.max_concurrent (the host's cap
|
|
21
|
+
* - agents.max_concurrent (the host's cap on agents, never plain
|
|
22
|
+
* sessions: 429 cap).
|
|
22
23
|
* The env is the CLI's (§5a-env): the allowlist and the runtime's, built
|
|
23
24
|
* from this server's environment, never all of it. Every action, refused
|
|
24
25
|
* or done, is audited (recordAction) with the person as its actor.
|
package/src/ui/server/index.ts
CHANGED
|
@@ -4543,6 +4543,17 @@ try {
|
|
|
4543
4543
|
serverLog(` ⚠ cache warm-up failed: ${(e as Error).message}`);
|
|
4544
4544
|
}
|
|
4545
4545
|
|
|
4546
|
+
// Ignored config keys (a distribution's web.allow_plain_http_remote, the
|
|
4547
|
+
// old web.session lifetimes): the in-app log, and stderr, which is web.log
|
|
4548
|
+
// even when `web up` starts this --silent. Before the bind refusal, which
|
|
4549
|
+
// such a key often explains.
|
|
4550
|
+
{
|
|
4551
|
+
const ignored = ignoredConfigWarning(WEB_AUTH);
|
|
4552
|
+
if (ignored) {
|
|
4553
|
+
pushLog("warn", ` ⚠ ${ignored}`);
|
|
4554
|
+
if (!outputLost()) process.stderr.write(` ⚠ ${ignored}\n`);
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4546
4557
|
// §4: a non-loopback bind needs HTTPS in front, or the person's say-so.
|
|
4547
4558
|
{
|
|
4548
4559
|
const refusal = bindRefusal(BIND_HOST, {
|
|
@@ -4554,10 +4565,6 @@ try {
|
|
|
4554
4565
|
process.exit(1);
|
|
4555
4566
|
}
|
|
4556
4567
|
}
|
|
4557
|
-
{
|
|
4558
|
-
const ignored = ignoredConfigWarning(WEB_AUTH);
|
|
4559
|
-
if (ignored) serverLog(` ⚠ ${ignored}`, "warn");
|
|
4560
|
-
}
|
|
4561
4568
|
// SEC-1a: the secret the CLI's person token is derived from (0600 in the
|
|
4562
4569
|
// state dir). Never printed or logged; a failure leaves bearers unverifiable.
|
|
4563
4570
|
try {
|
|
@@ -104,6 +104,12 @@ function displayName(r) {
|
|
|
104
104
|
return r.kind === "agent" ? (r.persona ?? r.label) : r.label;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// A pane's title and its Close: the agent's display name (AG-10.8), else
|
|
108
|
+
// its id, never the persona. There is no display name before AG-10.
|
|
109
|
+
function paneName(r) {
|
|
110
|
+
return r.kind === "agent" ? r.displayName || r.agent || r.label : r.label;
|
|
111
|
+
}
|
|
112
|
+
|
|
107
113
|
function rowHtml(r) {
|
|
108
114
|
const open = st.open.includes(r.id);
|
|
109
115
|
const nm =
|
|
@@ -293,7 +299,7 @@ function paintLayout() {
|
|
|
293
299
|
function paintBar(p) {
|
|
294
300
|
const r = rowOf(p.id);
|
|
295
301
|
const bar = p.el.querySelector(".bar2");
|
|
296
|
-
const name = r ?
|
|
302
|
+
const name = r ? paneName(r) : p.label || p.id;
|
|
297
303
|
const where = r
|
|
298
304
|
? [r.workspace, r.runtime].filter(Boolean).join(" · ")
|
|
299
305
|
: p.workspace || "";
|