@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 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
- # Den
2
-
3
- Den is a config-driven local development appliance. It starts the repos you
4
- work on in dependency order, puts them behind a local HTTPS edge, gives you a
5
- web UI and a CLI over all of it, and runs smoke, browser, API, journey,
6
- security and performance suites against the environments you declare.
7
-
8
- Den knows nothing about any particular organisation. Everything specific to
9
- yours — product name, repos, environments, hosts, secrets backend, test
10
- accounts — lives in **your distribution**, not in Den.
11
-
12
- > **Status: 0.2.0**: `npm install @monster-orange/den`. Den is pre-1.0; each release lists the changes a distribution makes under **Migrations** in [CHANGELOG.md](CHANGELOG.md).
13
-
14
- ## Make it your own
15
-
16
- 1. **Create** a distribution named after your product:
17
-
18
- ```sh
19
- npx @monster-orange/den create acme # → ./acme
20
- cd acme && npm install
21
- ```
22
-
23
- 2. **Name** it: `den.yml` holds `product: { name: acme }`, so the CLI is
24
- `./bin/acme`, env vars are `ACME_*`, state lives in `~/.acme`. Pass
25
- `--cli`, `--env-prefix` or `--title` to `create`, or edit `den.yml`.
26
- 3. **Brand** it: `brand:` in `config/acme.yml` sets the name, tagline and
27
- banner people see in the CLI and web UI.
28
- 4. **Point it at your repos**: set `github_org` in `config/acme.yml`, then
29
- `./bin/acme register ../shop-api` (or add a manifest per repo under
30
- `manifests/`).
31
- 5. **Run** it: `./bin/acme up`, `./bin/acme web`, `make test`.
32
-
33
- Every field, the bin shim, the make fragment and upgrading are in
34
- [docs/distributions.md](docs/distributions.md);
35
- [examples/distribution/](examples/distribution) is what `create acme` writes.
36
-
37
- ## Distributions
38
-
39
- You run Den through a small repo of your own (a _distribution_):
40
-
41
- ```
42
- my-dev/
43
- ├── den.yml # who this distribution is (below)
44
- ├── package.json # pins an exact @monster-orange/den version
45
- ├── config/<name>.yml # your org config
46
- ├── config/environments/ # one <env>.json per environment
47
- ├── manifests/ # manifests for repos that don't carry their own
48
- ├── tests/pillars/<pillar>/ # your own suites (e.g. tests/pillars/perf/<module>/test.js)
49
- ├── bin/<cli> # shim: DEN_ROOT=<this dir> exec node_modules/@monster-orange/den/bin/den
50
- └── Makefile # include node_modules/@monster-orange/den/make/den.mk
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
- `den.yml` names the product. Every runtime name derives from it:
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
- ```yaml
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's own checkout is a distribution named `den`, so in this repo the CLI is
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
- That is `examples/demo/demo-api/.den.yml`; the other demo repos are in
99
- [`examples/demo/`](examples/demo/).
100
-
101
- ## What it does
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
- In a distribution the command is that distribution's `cli` name.
87
+ ## Coming next
151
88
 
152
- ## Developing Den
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
- ```bash
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
- CI runs the same gates. The org-name gate fails the build when any file names
163
- an organisation, product, host or account; its patterns are kept out of this
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
- ## License
97
+ Found a bug or have an idea? [Open an issue](https://github.com/Monster-Orange/den/issues/new).
168
98
 
169
- [MIT](LICENSE)
99
+ MIT licence.
@@ -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
- - `agents.max_concurrent` caps the sessions running at once (default 4):
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`. The id defaults to the persona's id; an id is
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
- As a cooperative mitigation, den-claude passes Claude Code a settings file
1765
- (`--settings`) whose `permissions.deny` lists `Read` of `<state
1766
- dir>/web.secret` and `<state dir>/sessions/**`, and `Bash` rules in Claude
1767
- Code's wildcard form for the path (`Bash(*<state dir>/web.secret*)`, and
1768
- each of `cat`, `head`, `tail`, `less`, `more`, `xxd`, `od`, `strings`,
1769
- `base64` and `cp` followed by it), spelled as an absolute path, under `~`
1770
- and under `$HOME`, with a path that has spaces also backslash-escaped. A
1771
- deny list can't name every way to read a file (another program, a script,
1772
- a copy under another name), so it is a speed bump. **It binds cooperating
1773
- agents only**: an agent that ignores its permission config, or reads the
1774
- file some other way, can still read them. OS-user isolation (a separate
1775
- user, a container, a sandbox) is not in 0.4.x. `web logout --all` revokes every agent's token
1776
- (above, under Signing in).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -244,10 +244,16 @@ export interface DenConfig {
244
244
  */
245
245
  mcp?: McpConfig;
246
246
  /**
247
- * Agents: `max_concurrent` caps the sessions running at once in this
248
- * distribution (default 4). See readAgentsConfig.
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
- /** Sessions that may run at once (default 4). */
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: agents.max_concurrent. */
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 (await this.opts.client.start(spec, { limit: this.opts.limit })).ref;
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 { SESSION_SIGNALS, type SessionInfo } from "./types.js";
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
- const live = [...this.sessions.values()].filter(
532
- (s) => !s.info.exited,
533
- ).length;
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;
@@ -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";
@@ -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(` ${chalk.cyan(f.padEnd(20))}${d}`);
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. agents.max_concurrent caps every start.
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 { readAgentsConfig } from "../../shared/den-config.js";
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 agents.max_concurrent",
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.maxConcurrent();
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 agents.max_concurrent.`,
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
- readWebAuthConfig(getDistRoot(), product().configFile);
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: 429 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.
@@ -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 ? displayName(r) : p.label || p.id;
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 || "";