@monster-orange/den 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +88 -158
  3. package/docs/distributions.md +151 -28
  4. package/docs/reference.md +177 -0
  5. package/package.json +1 -1
  6. package/src/shared/agents/den-claude.ts +57 -12
  7. package/src/shared/agents/generic.ts +1 -0
  8. package/src/shared/agents/runtime-search.ts +416 -0
  9. package/src/shared/agents/runtime.ts +26 -1
  10. package/src/shared/agents/runtimes.ts +30 -3
  11. package/src/shared/den-config.ts +88 -3
  12. package/src/shared/edge/render.ts +31 -0
  13. package/src/shared/sessions/client.ts +7 -0
  14. package/src/shared/sessions/den-pty.ts +10 -2
  15. package/src/shared/sessions/host.ts +33 -8
  16. package/src/shared/sessions/types.ts +8 -0
  17. package/src/ui/cli/agent-ops.ts +109 -21
  18. package/src/ui/cli/agents-command.ts +100 -4
  19. package/src/ui/cli/index.ts +33 -1
  20. package/src/ui/cli/sessions-command.ts +26 -9
  21. package/src/ui/cli/web-command.ts +60 -9
  22. package/src/ui/server/agent-routes.ts +11 -3
  23. package/src/ui/server/edge-exposure.ts +143 -0
  24. package/src/ui/server/index.ts +105 -23
  25. package/src/ui/server/public/agent-start.js +202 -0
  26. package/src/ui/server/public/agents-page.js +90 -18
  27. package/src/ui/server/public/agents-section.js +6 -18
  28. package/src/ui/server/public/agents.html +70 -0
  29. package/src/ui/server/public/i18n/es.js +11 -0
  30. package/src/ui/server/public/i18n/ja.js +11 -0
  31. package/src/ui/server/public/local-dev.html +43 -14
  32. package/src/ui/server/public/settings-registry.js +1 -1
  33. package/src/ui/server/public/shell.js +40 -0
  34. package/src/ui/server/public/topbar.js +127 -28
  35. package/src/ui/server/request-guard.ts +122 -7
  36. package/src/ui/server/ui-assets.ts +2 -0
@@ -383,6 +383,14 @@ works; only the saved password needs `refs.sudo`.
383
383
  `configured`, `backendAvailable`, `reason` and `hasStored` (`opAvailable`
384
384
  is a deprecated alias of `backendAvailable`, kept for one minor).
385
385
 
386
+ The lock (🔒 in the top bar) is asked for only when an action needs it,
387
+ on any page: a page that thinks it's locked asks the server again first
388
+ (another tab or device may have unlocked it), then, when a password is
389
+ stored, loads it from the vault (once a page; a page view alone never
390
+ wakes the vault), and only then shows the password field. Its popover is
391
+ always on top, even over a dialog, and a dialog that needs the lock (Start
392
+ agent) asks for the password itself.
393
+
386
394
  A `1password` distribution with no refs gets one warning, in the web
387
395
  server's startup log and in `<cli> validate`, with the YAML to add.
388
396
 
@@ -1183,9 +1191,11 @@ are the same directory.
1183
1191
 
1184
1192
  On localhost nothing asks you to sign in: every page, the agents and the
1185
1193
  terminal work as they are, and the lock (R8) is the only prompt (for
1186
- privileged actions). Signing in is for when the server is reached beyond
1187
- this machine: through the edge hostname, a LAN address, or a bind that
1188
- isn't loopback (`DEN_BIND_HOST`).
1194
+ privileged actions). The edge counts as local too (0.4.3): opening
1195
+ `https://acme.acme.localhost` from this machine asks for nothing.
1196
+ Signing in is for when the server is reached from another machine: the
1197
+ edge for a client the edge says is elsewhere, a LAN address, or a bind
1198
+ that isn't loopback (`DEN_BIND_HOST`).
1189
1199
 
1190
1200
  There is no password. Reading `web.secret` (32 random bytes, mode 0600 in
1191
1201
  the state dir, made at the first `web up`) proves you are this computer's
@@ -1205,7 +1215,11 @@ user, and the CLI does that for you:
1205
1215
  within 60 seconds, and only this server's secret makes one. It is printed
1206
1216
  only on the one line you asked for (`web login`, or `web up` when it
1207
1217
  couldn't open the browser), never logged. Through the edge or a LAN name,
1208
- the cookie belongs to that name: `web login --origin https://acme.acme.localhost`
1218
+ the cookie belongs to that name: `web login --host acme.acme.localhost`
1219
+ mints the link for an edge hostname (any edge domain or alias, a VPN name
1220
+ among them; its URL comes from the edge config, and a name the edge
1221
+ doesn't route here is refused, saying what to add), and
1222
+ `web login --origin http://<lan name>:<port>` for any other allowed name
1209
1223
  (the signed-out page there shows the exact command).
1210
1224
  - **The cookie** (`den_session_<port>`) is HttpOnly, SameSite=Strict and
1211
1225
  Path=/, and Secure when the request came over HTTPS through the edge. The
@@ -1222,10 +1236,38 @@ user, and the CLI does that for you:
1222
1236
  open, and changes (an agent's start or resume and the terminal included)
1223
1237
  need only the page's own Origin and CSRF token, which the shell sends.
1224
1238
  The Host allowlist, the Origin check and the CSRF token are what stop
1225
- another site or a rebinding DNS name. Through the edge, or from another
1226
- machine, every page and call needs a signed-in browser (or a bearer). A
1227
- page there shows "Sign in to use <name>" with the command to run; it
1228
- never asks for a secret. `/api` answers 401 `signin_required`.
1239
+ another site or a rebinding DNS name. From another machine, every page
1240
+ and call needs a signed-in browser (or a bearer). A page there shows
1241
+ "Sign in to use <name>" with the command to run; it never asks for a
1242
+ secret. `/api` answers 401 `signin_required`.
1243
+ - **The edge counts as local.** A request for the edge hostname from the
1244
+ edge (an edge peer, below) is treated as loopback: no sign-in on any
1245
+ route, the agents and the terminal included. Host, Origin, CSRF and the
1246
+ agent-token rules still apply. Who the client is comes from the left-most
1247
+ `X-Forwarded-For` entry the edge sends:
1248
+ - loopback, or one of this machine's own interface addresses (a LAN
1249
+ address, or a VPN's such as a Tailscale `100.x`): local;
1250
+ - missing, or a container/VM network's address (the Docker ranges, or a
1251
+ subnet of one of this machine's Docker/OrbStack/VM bridges): Den can't
1252
+ tell (Docker Desktop and OrbStack on macOS hand the edge their
1253
+ gateway's address, and a TCP passthrough in front of the edge hides the
1254
+ client), so local;
1255
+ - any other address: another machine, which needs a sign-in.
1256
+
1257
+ With `DEN_BIND_HOST` beyond loopback (a deliberate exposure, other than
1258
+ the Linux edge's Docker bridge address) the edge needs a sign-in as
1259
+ before. With `forwardedHeaders.insecure` (the edge's default) Traefik
1260
+ keeps an `X-Forwarded-For` the client sent, so this is a convenience,
1261
+ not a boundary: whoever can reach the edge can use the terminal.
1262
+
1263
+ - **"Your edge is reachable from other machines".** When Den can tell, at
1264
+ start, that the edge is reachable beyond this machine (an edge hostname
1265
+ that resolves to an address that isn't loopback, such as a LAN or
1266
+ Tailscale address, or the edge container publishing a port on every
1267
+ interface, from `docker inspect`), it writes "Your edge is reachable from
1268
+ other machines; anyone who can reach it can use terminals. Auth is
1269
+ coming." once to `web.log`, and every page shows it as a banner (dismissed
1270
+ per browser).
1229
1271
  - **localhost is the canonical name.** A cookie set on `localhost` isn't
1230
1272
  sent to `127.0.0.1`, so the links Den prints use `localhost`.
1231
1273
  - **Browsers.** Chromium, Firefox and Safari 16.4 or later: a sign-in link
@@ -1581,9 +1623,16 @@ the profile's `state_dir` (`~/.<name>`), or `<PREFIX>_HOME`.
1581
1623
  - Stopping the host (`sessions host --stop`, or a reboot) ends its
1582
1624
  sessions. They stay listed as exited, with their scrollback, until
1583
1625
  `sessions kill` removes them.
1584
- - `agents.max_concurrent` caps the sessions running at once (default 4):
1626
+ - `sessions.max_concurrent` caps the plain sessions (`sessions start`)
1627
+ running at once (default 8), and `agents.max_concurrent` the agents
1628
+ (default 4). Each counts only its own, so a few plain shells never stop
1629
+ an agent from starting, nor agents a shell. Past either, the start is
1630
+ refused, naming the key ("2 sessions are running and
1631
+ sessions.max_concurrent is 2"); `<cli> validate` names a wrong value.
1585
1632
 
1586
1633
  ```yaml
1634
+ sessions:
1635
+ max_concurrent: 8
1587
1636
  agents:
1588
1637
  max_concurrent: 4
1589
1638
  ```
@@ -1609,12 +1658,13 @@ server scoped to its workspace.
1609
1658
  <cli> agents stop <id> [-w <ws>]
1610
1659
  <cli> agents resume <id> [-w <ws>]
1611
1660
  <cli> agents personas [--json]
1661
+ <cli> agents doctor [--json] # can each runtime run here, and why not
1612
1662
  <cli> sessions attach <ws>:<id> # its terminal; ctrl-] detaches
1613
1663
  ```
1614
1664
 
1615
1665
  - `start` runs the agent as session `<dist>-<ws>:<id>` in the workspace's
1616
1666
  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
1667
+ `agents.max_concurrent` (agents only: plain sessions never count). The id defaults to the persona's id; an id is
1618
1668
  unique in the distribution, so a second workspace's planner gets
1619
1669
  `planner-<ws>` unless you give `--id`.
1620
1670
  - The **agent record**, `<stateDir>/agents/<id>.json` (0600), holds its id,
@@ -1647,7 +1697,14 @@ started. The workspace header counts them ("2 agents").
1647
1697
  not running.
1648
1698
  - **+ Start agent** picks a persona (Den's pack, then
1649
1699
  `agents.persona_packs`) and a runtime (the persona's own unless you pick
1650
- one; one that can't run here is listed, disabled), then Start.
1700
+ one; one that can't run here is listed, disabled), then Start. Under the
1701
+ runtime a line says what Start will run, with its version and path
1702
+ ("Starts Claude Code 2.1.0 from ~/.local/bin/claude"). When that runtime
1703
+ can't run here, Start is disabled and the line says why and how to fix
1704
+ it: where it looked, then "Install it, or set
1705
+ `agents.runtimes.<id>.bin` to its path in <your user.yml>, or run
1706
+ `<cli> agents doctor`" (see [Runtimes](#runtimes)). Nothing fails only
1707
+ after Start.
1651
1708
  - The page and the CLI call the same operations and read the same agent
1652
1709
  records, so an agent started in one is listed in the other. A workspace
1653
1710
  lists its own agents only.
@@ -1688,7 +1745,10 @@ agent of the workspaces in view, and their terminals in the browser.
1688
1745
  running, and opening it again replays what you missed. **Stop** (an
1689
1746
  agent only) is `agents stop`, behind the lock. **+ Start agent** (or
1690
1747
  `/agents?start=1`) starts one and opens its pane; under All it asks for
1691
- the workspace.
1748
+ the workspace. It says before Start whether the runtime can run, as the
1749
+ Workspaces page's form does. While the lock is closed, the dialog asks
1750
+ for the admin password at its top, and Start unlocks, then starts, without
1751
+ closing anything.
1692
1752
  - ⌘K lists each as "Agents › <ws> / <name>", and "Start agent…".
1693
1753
  - Several browsers and `<cli> sessions attach` can attach to one session
1694
1754
  at once and each sees every byte; the terminal's size is the last
@@ -1714,7 +1774,8 @@ An agent's environment starts empty and gets, in order:
1714
1774
 
1715
1775
  1. `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TERM` (default
1716
1776
  `xterm-256color`), `COLORTERM`, `LANG`, `LC_*`, `TZ` and `TMPDIR` from
1717
- yours;
1777
+ yours (`PATH` with the login shell's and the runtimes' install
1778
+ directories added: [Runtimes](#runtimes));
1718
1779
  2. the variables its runtime names (`envPassthrough`; den-claude:
1719
1780
  `CLAUDE_CONFIG_DIR`);
1720
1781
  3. the runtime's launch env;
@@ -1761,19 +1822,22 @@ An agent runs as your OS user, so it could still read `web.secret` itself;
1761
1822
  the token keeps what Den does on its behalf attributable and bounded.
1762
1823
  **The boundary is the server's token rules** (above), not a deny list.
1763
1824
 
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).
1825
+ **The deny list is a convenience, not the boundary.** den-claude passes
1826
+ Claude Code a settings file (`--settings`) whose `permissions.deny` lists
1827
+ `Read` of `<state dir>/web.secret` and `<state dir>/sessions/**`, and
1828
+ `Bash` rules in Claude Code's wildcard form for the path
1829
+ (`Bash(*<state dir>/web.secret*)`, and each of `cat`, `head`, `tail`,
1830
+ `less`, `more`, `xxd`, `od`, `strings`, `base64` and `cp` followed by it),
1831
+ spelled as an absolute path, under `~` and under `$HOME`, with a path that
1832
+ has spaces also backslash-escaped. It binds cooperating agents only: an
1833
+ agent that ignores its permission config can read the files anyway. And
1834
+ it does not catch every spelling: a path relative to the agent's cwd
1835
+ (`cat ../.den/web.secret`, say), another program, a script or a copy under
1836
+ another name all get past it. The server's token rules (above)
1837
+ are the boundary: whatever an agent reads, its token reaches only its own
1838
+ session's routes, never a shell-grade route or the lock. OS-user isolation
1839
+ (a separate user, a container, a sandbox) is not in 0.4.x. `web logout
1840
+ --all` revokes every agent's token (above, under Signing in).
1777
1841
 
1778
1842
  **What the scope does not do.** An agent's MCP scope binds cooperating
1779
1843
  agents only. A runtime with a shell can still run `<cli>` itself, outside
@@ -1822,8 +1886,9 @@ are errors naming the file or the key.
1822
1886
 
1823
1887
  `<cli> doctor` lists the runtime kind. Two are built in:
1824
1888
 
1825
- - **`den-claude`** (the default) needs `claude` on PATH; doctor reads its
1826
- version from the install, without running it. It runs `claude --session-id
1889
+ - **`den-claude`** (the default) runs Claude Code's `claude`, found as
1890
+ below; `<cli> doctor` reads its version from the install, without running
1891
+ it. It runs `claude --session-id
1827
1892
  <id> --mcp-config <file> --strict-mcp-config --settings <file> [--model <m>]
1828
1893
  --append-system-prompt <role prompt>`, so Den's MCP server is the agent's
1829
1894
  only one, and resumes with `claude --resume <id>`. Permission prompts stay
@@ -1836,6 +1901,64 @@ are errors naming the file or the key.
1836
1901
  `{mcp_config}` is the path of the agent's MCP config, written only when the
1837
1902
  template references it. It declares no resume.
1838
1903
 
1904
+ **Where a runtime's CLI is found.** As your terminal would find it, not
1905
+ only on the web server's own PATH (a launchd service's PATH leaves out
1906
+ `~/.local/bin`, where Claude Code's native installer puts `claude`). The
1907
+ first match wins:
1908
+
1909
+ 1. **`agents.runtimes.<id>.bin`** in `config/local/user.yml`: the absolute
1910
+ path of the runtime's CLI. Paths are per machine, so it is read from
1911
+ user.yml only, never from the distribution's config. A bad value (not
1912
+ absolute, missing, not an executable file) makes the runtime
1913
+ unavailable with that reason; Den doesn't fall back to a search.
1914
+
1915
+ ```yaml
1916
+ # config/local/user.yml (gitignored)
1917
+ agents:
1918
+ runtimes:
1919
+ den-claude:
1920
+ bin: /Users/me/.local/bin/claude
1921
+ ```
1922
+
1923
+ 2. **Your login shell's PATH**: the web server asks `$SHELL -lc` once at
1924
+ start, in the background, under a 3-second timeout. On a timeout or
1925
+ any failure it is left out.
1926
+ 3. **The server's own PATH.**
1927
+ 4. **The runtime's install directories.** For den-claude: `~/.local/bin`,
1928
+ `~/.claude/local`, `/opt/homebrew/bin`, `/usr/local/bin` and npm's
1929
+ global bin (`$npm_config_prefix/bin`, and node's own directory). Den
1930
+ never runs npm to find it.
1931
+
1932
+ Sessions and agents Den starts get the same directories on their PATH
1933
+ (and a configured CLI's own directory), so the runtime's own tools are
1934
+ found too. Two engine variables override the search for tests and
1935
+ diagnosis: `DEN_LOGIN_SHELL` (the shell to ask instead of `$SHELL`; `off`
1936
+ for none) and `DEN_RUNTIME_DIRS` (a PATH-style list that replaces every
1937
+ runtime's install directories; empty for none).
1938
+
1939
+ **`<cli> agents doctor`** prints, for each runtime, its status, the CLI it
1940
+ runs and where that came from (the `agents.runtimes` setting, the login
1941
+ shell's PATH, PATH, or an install directory), its version (`claude
1942
+ --version`, run once under a timeout), the directories it searched (names
1943
+ only, never an environment value), and the fix when it's missing. It
1944
+ resolves the login shell's PATH first, as the web server does. It exits 1
1945
+ when the default runtime is unavailable; `--json` prints what the Start
1946
+ dialog gets.
1947
+
1948
+ ```
1949
+ den-claude (Claude Code) [default]
1950
+ status ✗ unavailable: claude not found (searched 5 directories)
1951
+ searched /usr/bin
1952
+ /bin
1953
+ …
1954
+ fix install Claude Code, or set agents.runtimes.den-claude.bin to its absolute path in /…/config/local/user.yml
1955
+ then run `<cli> agents doctor` again
1956
+ ```
1957
+
1958
+ `GET /api/personas` gives each runtime its `version`, `path`, `source`
1959
+ (`setting`, `login-shell`, `well-known` or `PATH`), `searched` and
1960
+ `setting` (the user.yml key), and `userConfig`, the user.yml to edit.
1961
+
1839
1962
  ### Workspace scope and grants
1840
1963
 
1841
1964
  An agent's MCP config, `<stateDir>/agent-files/<id>/mcp.json` (0600), runs
@@ -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.3",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,27 +28,50 @@
28
28
  * session Den starts (an agent is not a child of the session that
29
29
  * started it).
30
30
  *
31
- * detect runs nothing: `claude` on PATH, and its version read from where it
32
- * is installed (a native install's versions/<version>, or the npm package's
33
- * package.json).
31
+ * detect runs nothing (AG-0.1, runtime-search.ts): agents.runtimes.den-claude.bin
32
+ * in user.yml when set, else `claude` on the login shell's PATH, the
33
+ * caller's PATH, then its install directories (~/.local/bin, the native
34
+ * installer's; ~/.claude/local; /opt/homebrew/bin; /usr/local/bin; npm's
35
+ * global bin). Its version is read from where it is installed (a native
36
+ * install's versions/<version>, or the npm package's package.json); the
37
+ * Start dialog and `agents doctor` ask `claude --version` once as well.
34
38
  */
35
39
  import { randomUUID } from "crypto";
36
40
  import * as fs from "fs";
37
41
  import * as path from "path";
42
+ import { configuredRuntimeBin } from "../den-config.js";
38
43
  import { rolePrompt } from "./personas.js";
39
44
  import {
40
45
  LaunchError,
41
46
  mcpConfigJson,
42
47
  mcpConfigPath,
43
- whichBin,
48
+ type DetectOptions,
44
49
  type LaunchInput,
45
50
  type LaunchOutput,
46
51
  type Runtime,
47
52
  type RuntimeDetect,
48
53
  } from "./runtime.js";
54
+ import {
55
+ locateRuntime,
56
+ systemBinDirs,
57
+ type RuntimeFind,
58
+ } from "./runtime-search.js";
49
59
 
50
60
  export const DEN_CLAUDE_ID = "den-claude";
51
61
  const BIN = "claude";
62
+
63
+ /** Where Claude Code is found, and how its version is asked. */
64
+ export const DEN_CLAUDE_FIND: RuntimeFind = {
65
+ bins: [BIN],
66
+ dirs: (home, env) => [
67
+ // The native installer's, then the older local install's.
68
+ ...(home
69
+ ? [path.join(home, ".local", "bin"), path.join(home, ".claude", "local")]
70
+ : []),
71
+ ...systemBinDirs(env),
72
+ ],
73
+ versionArgs: ["--version"],
74
+ };
52
75
  const PACKAGE = "@anthropic-ai/claude-code";
53
76
  const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
54
77
 
@@ -97,11 +120,29 @@ export function installedVersion(bin: string): string | undefined {
97
120
  return undefined;
98
121
  }
99
122
 
100
- function detect(env: NodeJS.ProcessEnv = process.env): RuntimeDetect {
101
- const bin = whichBin(BIN, env);
102
- if (!bin) return { available: false, why: `${BIN} is not on PATH` };
103
- const version = installedVersion(bin);
104
- return version ? { available: true, version } : { available: true };
123
+ function detect(
124
+ env: NodeJS.ProcessEnv = process.env,
125
+ opts: DetectOptions = {},
126
+ ): RuntimeDetect {
127
+ const setting =
128
+ opts.bin === undefined ? configuredRuntimeBin(DEN_CLAUDE_ID) : opts.bin;
129
+ const found = locateRuntime(DEN_CLAUDE_ID, DEN_CLAUDE_FIND, env, setting);
130
+ if (!found.available || !found.path)
131
+ return {
132
+ available: false,
133
+ why: found.why ?? `${BIN} not found`,
134
+ // "setting": a bad agents.runtimes.<id>.bin is the reason, not the search.
135
+ ...(found.source ? { source: found.source } : {}),
136
+ searched: found.searched,
137
+ };
138
+ const version = installedVersion(found.path);
139
+ return {
140
+ available: true,
141
+ ...(version ? { version } : {}),
142
+ path: found.path,
143
+ ...(found.source ? { source: found.source } : {}),
144
+ searched: found.searched,
145
+ };
105
146
  }
106
147
 
107
148
  /**
@@ -231,13 +272,17 @@ function common(input: LaunchInput): {
231
272
  }
232
273
 
233
274
  function binFor(input: LaunchInput): string {
234
- const bin = whichBin(BIN, input.env);
235
- if (!bin) throw new LaunchError(`${DEN_CLAUDE_ID}: ${BIN} is not on PATH`);
236
- return bin;
275
+ if (input.bin) return input.bin;
276
+ const d = detect(input.env);
277
+ if (!d.available || !d.path)
278
+ throw new LaunchError(`${DEN_CLAUDE_ID}: ${d.why ?? `${BIN} not found`}`);
279
+ return d.path;
237
280
  }
238
281
 
239
282
  export const denClaude: Runtime = {
240
283
  id: DEN_CLAUDE_ID,
284
+ label: "Claude Code",
285
+ find: DEN_CLAUDE_FIND,
241
286
  detect,
242
287
  launch(input): LaunchOutput {
243
288
  const bin = binFor(input);
@@ -20,6 +20,7 @@ export const GENERIC_ID = "generic";
20
20
 
21
21
  export const generic: Runtime = {
22
22
  id: GENERIC_ID,
23
+ label: "Generic command",
23
24
  // The CLI is the persona's; there is nothing to look for until launch.
24
25
  detect: () => ({ available: true }),
25
26
  scrubEnv: [],