@monster-orange/den 0.4.3 → 0.4.4

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 (58) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +215 -65
  4. package/package.json +1 -1
  5. package/src/shared/agents/catalog-runtime.ts +178 -0
  6. package/src/shared/agents/catalog.ts +343 -0
  7. package/src/shared/agents/den-claude.ts +14 -18
  8. package/src/shared/agents/runtime-search.ts +142 -15
  9. package/src/shared/agents/runtime.ts +18 -1
  10. package/src/shared/agents/runtimes.ts +23 -1
  11. package/src/shared/auth/sudo-vault.ts +16 -0
  12. package/src/shared/den-config.ts +57 -3
  13. package/src/shared/edge/status.ts +134 -0
  14. package/src/shared/environment-manager/health.ts +82 -8
  15. package/src/shared/environment-manager/workspaces.ts +54 -0
  16. package/src/shared/health/not-checked.ts +42 -0
  17. package/src/shared/health/rollup.ts +4 -0
  18. package/src/shared/process/spawn-streaming.ts +8 -3
  19. package/src/shared/sessions/client.ts +5 -1
  20. package/src/shared/sessions/host.ts +29 -1
  21. package/src/shared/sessions/protocol.ts +6 -1
  22. package/src/shared/sessions/types.ts +6 -0
  23. package/src/shared/stacks/status.ts +24 -0
  24. package/src/shared/strategies/registry.ts +5 -0
  25. package/src/shared/types/index.ts +6 -0
  26. package/src/ui/cli/agent-ops.ts +210 -19
  27. package/src/ui/cli/agents-command.ts +50 -21
  28. package/src/ui/cli/edge-command.ts +16 -3
  29. package/src/ui/cli/health-display.ts +4 -1
  30. package/src/ui/mcp/scope.ts +23 -3
  31. package/src/ui/mcp/server.ts +31 -7
  32. package/src/ui/mcp/tools.ts +327 -20
  33. package/src/ui/mcp/views.ts +450 -0
  34. package/src/ui/server/agent-routes.ts +118 -28
  35. package/src/ui/server/index.ts +77 -6
  36. package/src/ui/server/public/agent-start.js +46 -7
  37. package/src/ui/server/public/agents-page.js +232 -97
  38. package/src/ui/server/public/agents-section.js +3 -5
  39. package/src/ui/server/public/agents.html +12 -28
  40. package/src/ui/server/public/graph-model.js +2 -0
  41. package/src/ui/server/public/header.js +2 -3
  42. package/src/ui/server/public/health-rollup.js +20 -8
  43. package/src/ui/server/public/i18n/es.js +15 -13
  44. package/src/ui/server/public/i18n/ja.js +14 -13
  45. package/src/ui/server/public/index.html +17 -4
  46. package/src/ui/server/public/local-dev.html +28 -17
  47. package/src/ui/server/public/services.html +21 -5
  48. package/src/ui/server/public/settings-page.js +3 -2
  49. package/src/ui/server/public/settings-registry.js +225 -0
  50. package/src/ui/server/public/settings.html +90 -1
  51. package/src/ui/server/public/shell.js +28 -23
  52. package/src/ui/server/public/topbar.js +166 -206
  53. package/src/ui/server/readonly-routes.ts +29 -5
  54. package/src/ui/server/request-guard.ts +1 -1
  55. package/src/ui/server/route-classes.ts +14 -2
  56. package/src/ui/server/session-routes.ts +9 -1
  57. package/src/ui/server/sudo-store.ts +170 -6
  58. package/src/ui/server/workspace-routes.ts +10 -3
@@ -352,7 +352,7 @@ where it is, under `secrets.refs`:
352
352
 
353
353
  | Ref | Feature |
354
354
  | -------------------- | ------------------------------------------------------------ |
355
- | `sudo` | The saved local-dev sudo password (auto-unlock, "Save to …") |
355
+ | `sudo` | The saved admin password, for sudo (auto-load, "Save to …") |
356
356
  | `journeys_agent_key` | The journeys agent's API key, when `CURSOR_API_KEY` is unset |
357
357
 
358
358
  ```yaml
@@ -377,19 +377,37 @@ secrets:
377
377
  and `env` need a `key` (or an env-shaped `field`). There are no defaults:
378
378
  a ref that is not set is **not configured**, and its feature says so
379
379
  ("not configured: set secrets.provider and secrets.refs.sudo in
380
- config/acme.yml") instead of running a CLI. The interactive unlock always
380
+ config/acme.yml") instead of running a CLI. Typing the password always
381
381
  works; only the saved password needs `refs.sudo`.
382
382
  `/api/sudo-password/vault-status` reports `backend`, `label`,
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.
386
+ The admin password is for sudo, and nothing else (LK-0). It is no sign-in
387
+ and no access check: starting, stopping and resuming agents, sessions and
388
+ terminals, and a service whose commands run no sudo, never ask for it.
389
+ Den asks only when a command it runs is about to run sudo without it:
390
+ every service command (up, down, restart, setup, sync) gets a `sudo` shim
391
+ first on its `PATH`. With a password held, the shim gives it to sudo. With
392
+ none, sudo that needs no password (NOPASSWD, a warm cache) runs as it
393
+ would, and anything else never prompts: the shim prints
394
+ `<cli>: needs-sudo: sudo <command>` and fails, and the run's event stream
395
+ carries `{ "type": "needs-sudo", "command": "sudo <command>", "step":
396
+ "<service>" }` (sudo's own "a password is required" counts too, without
397
+ the command). Only then does the page ask, saying "`sudo <command>` needs
398
+ your admin password", with the 🔑 chip in the top bar while it waits. It
399
+ asks the server first (another tab or device may have set it), then, when
400
+ a password is stored, loads it from the vault (a page view alone never
401
+ wakes the vault), and only then shows the password field, always on top,
402
+ even over a dialog. After the password, the action runs again.
403
+
404
+ Settings › Command line › Admin password says whether it is saved ("Saved
405
+ in 1Password", "Saved for this session" or "Not saved"), with **Forget**:
406
+ `POST /api/sudo-password/forget` drops the password the server holds and,
407
+ when this server saved it to the vault or loaded it from there, clears the
408
+ vault's copy. `GET /api/sudo-password/status` answers `set`,
409
+ `passwordless`, `saved` (`vault`, `session` or null) and `vault` (`{
410
+ configured, label }`, read from the config alone).
393
411
 
394
412
  A `1password` distribution with no refs gets one warning, in the web
395
413
  server's startup log and in `<cli> validate`, with the YAML to add.
@@ -1190,8 +1208,8 @@ are the same directory.
1190
1208
  ## Signing in
1191
1209
 
1192
1210
  On localhost nothing asks you to sign in: every page, the agents and the
1193
- terminal work as they are, and the lock (R8) is the only prompt (for
1194
- privileged actions). The edge counts as local too (0.4.3): opening
1211
+ terminal work as they are, and the admin password (R8) is the only prompt,
1212
+ asked only when a command Den runs needs sudo. The edge counts as local too (0.4.3): opening
1195
1213
  `https://acme.acme.localhost` from this machine asks for nothing.
1196
1214
  Signing in is for when the server is reached from another machine: the
1197
1215
  edge for a client the edge says is elsewhere, a LAN address, or a bind
@@ -1448,19 +1466,40 @@ port.
1448
1466
  `--workspace <ws>` and `--actor agent:<id>` (both or neither) scope the server
1449
1467
  to one workspace for one agent:
1450
1468
 
1451
- - **Pinned.** A tool that takes a `workspace` gets `<ws>` when none is given.
1452
- A call naming another workspace is refused (`Refused: <tool>: this MCP
1453
- server is scoped to workspace "<ws>" (agent:<id>; no grants); "<other>" is
1454
- another workspace`; with grants, `grants: <a>, <b>`) before anything reaches the web server. A refused call is
1455
- recorded in the activity trail, read tools included.
1469
+ - **Pinned.** A tool that takes a `workspace` gets `<ws>`. The server
1470
+ registers each workspace tool with "This workspace only" in its
1471
+ description and without the `workspace` parameter (with grants it stays,
1472
+ a name whose description lists `<ws>` and the granted ones; any other
1473
+ name gets the refusal below, not a schema error). A call naming another
1474
+ workspace is refused
1475
+ (`Refused: <tool>: this MCP server is scoped to workspace "<ws>"
1476
+ (agent:<id>; no grants); "<other>" is another workspace`; with grants,
1477
+ `grants: <a>, <b>`) before anything reaches the web server. The refusal
1478
+ says how to get more: a person adds `<other>` to `agents.<id>.grants` in
1479
+ the org config and restarts the agent; an agent can't grant itself one. A
1480
+ refused call is recorded in the activity trail, read tools included.
1481
+ - **Says so.** Every scoped answer (`status`, `services`, `applications`,
1482
+ `graph`, `stacks`, `workspaces`) carries a `scope` block: the workspace
1483
+ shown, how many other workspaces there are and how much it leaves out,
1484
+ for example `hidden: { repos: 6 }`, and a note pointing at `org_summary`.
1485
+ A scoped `graph` is the workspace's own repos and the edges out of them
1486
+ (`map: "workspace"`, with `outside` naming the repos they depend on
1487
+ elsewhere). Unscoped with no `workspace` it is the org-wide map
1488
+ (`map: "org-wide"`: every active workspace's graph merged, `workspaces`
1489
+ naming them); with one, that workspace's (`map: "workspace"`).
1456
1490
  - **Filtered.** `workspaces` lists `<ws>` only. `logs_search` and `logs_tail`
1457
1491
  take only `<ws>`'s services, `manifest_validate` takes only an absolute path
1458
1492
  inside one of its repos (or the content), and `workspace_sync` syncs `<ws>`.
1459
1493
  Tests run against the focused workspace, so `tests_available` and
1460
1494
  `run_tests` are refused unless `<ws>` is the focused one.
1461
1495
  - **Not workspace data**, served as unscoped: `environments`,
1462
- `security_latest`, `release_status` and `edge_status` (the org's and the
1463
- distribution's, not a workspace's).
1496
+ `security_latest`, `release_status`, `edge_status` and `org_summary` (the
1497
+ org's and the distribution's, not a workspace's).
1498
+ - **The org at a glance.** `org_summary` gives every agent every workspace
1499
+ and repo with status counts, the graph, the edge and the environments
1500
+ (see [Tools](#tools)). Detail and actions stay scoped: for workspaces
1501
+ other than `<ws>` it carries names and counts only, never logs, env
1502
+ values, commands, URLs, paths or check output.
1464
1503
  - **Stamped.** Every web API call carries `x-den-mcp-agent: agent:<id>` and
1465
1504
  `x-den-mcp-workspace: <ws>` beside the usual audit headers. The activity
1466
1505
  entry gains `agent` and `workspace`, and the log line reads `· by
@@ -1479,7 +1518,9 @@ agent:<id> <client> <version> (workspace <ws>, mcp pid <pid>, session
1479
1518
  - `<ws>` must exist in this distribution; until it does, every tool says so.
1480
1519
  The id is letters, digits, dots, dashes and underscores (at most 64). A bad
1481
1520
  or half-given scope is a usage error, exit 2. `--list` and `--call` take
1482
- the scope too.
1521
+ the scope too: they list and validate against the pinned tools, as the
1522
+ server registers them, and `--call` names its scope on stderr
1523
+ (`acme mcp --call: scoped to <ws> as agent:<id>`).
1483
1524
 
1484
1525
  **It binds cooperating agents only.** The scope lives in the MCP server's
1485
1526
  process. The web server's HTTP API does not check it yet, so an agent that
@@ -1525,22 +1566,60 @@ guard off.
1525
1566
 
1526
1567
  Read tools (always registered):
1527
1568
 
1528
- | Tool | Web API | What |
1529
- | ------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1530
- | `status` | `/api/health` | Service health; `url` (HTTP check) or `check` (shell, or `container <project>/<service>`); `ok` true up, false down, null not known (`pending: true` when a check has not answered) |
1531
- | `services` | `/api/services` | Services, their mode (managed or dev-managed) and commands |
1532
- | `applications` | `/api/applications` | Org > Application > Repo > Services |
1533
- | `graph` | `/api/graph` | The derived dependency graph and startup order |
1534
- | `workspaces` | `/api/workspaces` | Workspaces, active and focused |
1535
- | `environments` | `/api/environments` | The enabled environment names (never their values) |
1536
- | `logs_search` | `/api/logs/search` | Search services' full log history |
1537
- | `logs_tail` | `/api/service/taillog` | The last lines of a service's log (bounded, no follow) |
1538
- | `tests_available` | `/api/modules`, `/api/test-sources` | A pillar's modules and the repos they come from |
1539
- | `security_latest` | `/api/security/latest` | The newest security scan per mode |
1540
- | `release_status` | `/api/release/runs`, `/api/release/status` | Recent CD runs, and an env's deployed-version table |
1541
- | `manifest_validate` | `/api/manifest/validate` | Validate a manifest by path or content |
1542
- | `stacks` | `/api/stacks` | What `stack ls/ps/health` show: project, routes, health |
1543
- | `edge_status` | `/api/edge` | The edge: up or down, its routes and cert names |
1569
+ | Tool | Web API | What |
1570
+ | ------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1571
+ | `status` | `/api/health` | Service health: `counts`, then each service's `state` (`up`, `down`, `redirect`, `pending`, `unknown`, `not-checked`) and, without a plain verdict, `why`; `url` (HTTP check) or `check` (shell, or `container <project>/<service>`); `ok` true up, false down, null not known |
1572
+ | `services` | `/api/services` | Services, their mode (managed or dev-managed) and commands |
1573
+ | `applications` | `/api/applications` | Org > Application > Repo > Services |
1574
+ | `graph` | `/api/graph` | The derived dependency graph (`map: "org-wide"`, every active workspace's; with `workspace`, that one's and its startup order; scoped, the workspace's own) |
1575
+ | `workspaces` | `/api/workspaces` | Workspaces, active and focused |
1576
+ | `environments` | `/api/environments` | The enabled environment names (never their values) |
1577
+ | `logs_search` | `/api/logs/search` | Search services' full log history |
1578
+ | `logs_tail` | `/api/service/taillog` | The last lines of a service's log (bounded, no follow) |
1579
+ | `tests_available` | `/api/modules`, `/api/test-sources` | A pillar's modules and the repos they come from |
1580
+ | `security_latest` | `/api/security/latest` | The newest security scan per mode |
1581
+ | `release_status` | `/api/release/runs`, `/api/release/status` | Recent CD runs, and an env's deployed-version table |
1582
+ | `manifest_validate` | `/api/manifest/validate` | Validate a manifest by path or content |
1583
+ | `stacks` | `/api/stacks` | What `stack ls/ps/health` show: project, routes, health; an empty list says `why` |
1584
+ | `edge_status` | `/api/edge` | The edge: up or down, its routes and cert names; `https` (TLS strategy, who supplies the cert, whether HTTPS is serving) |
1585
+ | `org_summary` | several (read only) | Every workspace and repo with status counts, the graph, the edge and the environments; scoped, `yours` adds your workspace's services |
1586
+
1587
+ **Health states.** A service with no verdict says why in `why`: no health
1588
+ check in the manifest for the env, remote with no check or `target.url`
1589
+ for its env, not deployed in the env (no commands and no check there), no
1590
+ manifest, or checked but no answer yet. A redirect (`302 → /login`) is
1591
+ `redirect` with its status and target (the query is dropped), not down:
1592
+ something answered. It is never green either, so an unexpected redirect
1593
+ (an ALB answering for a stopped service) is not taken for up; list the code
1594
+ in `target.expect` to count it as up. The web health row and `<cli> status`
1595
+ use the same rule: the web row shows a redirect muted (with `status` and `redirect`
1596
+ in `/api/health`), and `<cli> status` as `redirect 302 → /login`.
1597
+
1598
+ **`org_summary`** reads `/api/workspaces`, `/api/health` (local, every
1599
+ active workspace), `/api/graph`, `/api/edge` and `/api/environments`:
1600
+
1601
+ - `workspaces`: each workspace's name, whether it is active, focused or
1602
+ yours, its state (`running`: every checked service answers; `partial`:
1603
+ some are down; `stopped`: all checked ones are down; `unknown`: nothing
1604
+ checked; `inactive`: not polled), its counts (up, down, redirect,
1605
+ pending, not checked) and its repos, each with its rolled-up state.
1606
+ - `graph`: the org-wide map, every active workspace's repos merged (each
1607
+ workspace's graph from the web server; `workspaces` names them, and
1608
+ `unavailable` any whose graph failed), repo names and edge kinds only (no
1609
+ evidence, paths or URLs).
1610
+ - `edge`: up, state, domains, the route count and `https`.
1611
+ - `environments`: the enabled names, never their values.
1612
+ - scoped, `yours`: your workspace's services as `status` gives them.
1613
+
1614
+ **`edge_status.https`** says which TLS strategy is in use (`mkcert`,
1615
+ `files`, `none`), who supplies the cert (`den`, or `distribution` under
1616
+ `tls: files` and the legacy `issue_cert: false`) and whether HTTPS is
1617
+ serving: a TLS handshake on the edge's published https port (`serving`
1618
+ true or false, null when a proxy in front owns the port), with `detail`.
1619
+ `certIssuedByDen` replaces `certIssued`, which is kept for 0.4.4 with a
1620
+ note in `deprecated`: it said whether Den minted the cert, so it was
1621
+ `false` under `tls: files` while HTTPS worked. `edge status` prints the
1622
+ same `https` line.
1544
1623
 
1545
1624
  Action tools (only those named in `mcp.actions`): `service_up`,
1546
1625
  `service_down`, `service_restart` (through `/api/service/exec`),
@@ -1658,7 +1737,7 @@ server scoped to its workspace.
1658
1737
  <cli> agents stop <id> [-w <ws>]
1659
1738
  <cli> agents resume <id> [-w <ws>]
1660
1739
  <cli> agents personas [--json]
1661
- <cli> agents doctor [--json] # can each runtime run here, and why not
1740
+ <cli> agents doctor [-w <ws>] [--json] # can each runtime run here, and why not
1662
1741
  <cli> sessions attach <ws>:<id> # its terminal; ctrl-] detaches
1663
1742
  ```
1664
1743
 
@@ -1708,9 +1787,10 @@ started. The workspace header counts them ("2 agents").
1708
1787
  - The page and the CLI call the same operations and read the same agent
1709
1788
  records, so an agent started in one is listed in the other. A workspace
1710
1789
  lists its own agents only.
1711
- - Start, Stop and Resume need the lock open (🔓, the same rule as a
1712
- service's Start), are capped by `agents.max_concurrent`, and are refused
1713
- for an agent (agents don't start agents from the page). Each one is in the
1790
+ - Start, Stop and Resume never ask for the admin password (it is for
1791
+ sudo, and no agent action runs sudo), are capped by
1792
+ `agents.max_concurrent`, and are refused for an agent (agents don't
1793
+ start agents from the page). Each one is in the
1714
1794
  server log's activity, with you as its actor. The agent's environment is
1715
1795
  the one below, built from the web server's environment.
1716
1796
  - **Open in Agents →** (the section header) opens the Agents tab for the
@@ -1720,7 +1800,7 @@ The routes: `GET /api/workspaces/<ws>/agents`, `POST
1720
1800
  /api/workspaces/<ws>/agents` (`{ persona, runtime?, model?, id? }`), `POST
1721
1801
  /api/workspaces/<ws>/agents/<id>/stop` and `…/resume`, and `GET
1722
1802
  /api/personas` (personas and runtimes). Errors are the usual
1723
- `{ ok: false, error, code, requestId }`: `locked` (423), `cap` (429),
1803
+ `{ ok: false, error, code, requestId }`: `cap` (429),
1724
1804
  `conflict` (409, e.g. already running, or no resume), `not_found` (404),
1725
1805
  `exited_at_once` (502, with the session's last lines).
1726
1806
 
@@ -1743,12 +1823,10 @@ agent of the workspaces in view, and their terminals in the browser.
1743
1823
  reload or Back reopens them.
1744
1824
  - **Close** (×) detaches, as leaving the page does: the session keeps
1745
1825
  running, and opening it again replays what you missed. **Stop** (an
1746
- agent only) is `agents stop`, behind the lock. **+ Start agent** (or
1826
+ agent only) is `agents stop`. **+ Start agent** (or
1747
1827
  `/agents?start=1`) starts one and opens its pane; under All it asks for
1748
1828
  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.
1829
+ Workspaces page's form does. It never asks for the admin password.
1752
1830
  - ⌘K lists each as "Agents › <ws> / <name>", and "Start agent…".
1753
1831
  - Several browsers and `<cli> sessions attach` can attach to one session
1754
1832
  at once and each sees every byte; the terminal's size is the last
@@ -1813,8 +1891,9 @@ header (agent headers without the token are recorded as an unverified
1813
1891
  claim).
1814
1892
 
1815
1893
  An agent's token reaches only its own session's routes, and never a
1816
- shell-grade route, `/api/web/*`, sign-in or sign-out, or the lock: it can't
1817
- unlock, use the sudo password or start another agent (403
1894
+ shell-grade route, `/api/web/*`, sign-in or sign-out, or the admin
1895
+ password's routes: it can't set the admin password, use it for sudo or
1896
+ start another agent (403
1818
1897
  `agent_forbidden`). A person's credential never reaches a session:
1819
1898
  `DEN_PERSON_TOKEN`, a person token's value and a path to `web.secret` are
1820
1899
  always removed, and `sessions start --keep-env` of one is refused and logged.
@@ -1835,7 +1914,8 @@ it does not catch every spelling: a path relative to the agent's cwd
1835
1914
  (`cat ../.den/web.secret`, say), another program, a script or a copy under
1836
1915
  another name all get past it. The server's token rules (above)
1837
1916
  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
1917
+ session's routes, never a shell-grade route or the admin password's.
1918
+ OS-user isolation
1839
1919
  (a separate user, a container, a sandbox) is not in 0.4.x. `web logout
1840
1920
  --all` revokes every agent's token (above, under Signing in).
1841
1921
 
@@ -1884,7 +1964,8 @@ are errors naming the file or the key.
1884
1964
 
1885
1965
  ### Runtimes
1886
1966
 
1887
- `<cli> doctor` lists the runtime kind. Two are built in:
1967
+ `<cli> doctor` lists the runtime kind. Den knows the runtimes in its
1968
+ **catalog** (below); two are special:
1888
1969
 
1889
1970
  - **`den-claude`** (the default) runs Claude Code's `claude`, found as
1890
1971
  below; `<cli> doctor` reads its version from the install, without running
@@ -1899,14 +1980,64 @@ are errors naming the file or the key.
1899
1980
  - **`generic`** runs the persona's `argv` template. `{model}` is the agent's
1900
1981
  model (an error when the template uses it and there is none);
1901
1982
  `{mcp_config}` is the path of the agent's MCP config, written only when the
1902
- template references it. It declares no resume.
1983
+ template references it. It declares no resume. It is startable only for
1984
+ a persona that has an `argv`.
1985
+
1986
+ **The runtime catalog.** Den ships, as data, every vendor CLI it knows how
1987
+ to find. Each entry was checked against the vendor's own docs or installer:
1988
+
1989
+ | id | Vendor | CLI | Found in (after PATH) | Source |
1990
+ | ------------ | --------- | --------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
1991
+ | `den-claude` | Anthropic | Claude Code, `claude` | `~/.local/bin`, `~/.claude/local`, system dirs | [setup](https://code.claude.com/docs/en/setup) |
1992
+ | `codex` | OpenAI | Codex CLI, `codex` | `$CODEX_INSTALL_DIR`, `~/.local/bin`, system dirs | [openai/codex](https://github.com/openai/codex), [installer](https://chatgpt.com/codex/install.sh) |
1993
+ | `gemini` | Google | Gemini CLI, `gemini` | system dirs | [google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) |
1994
+ | `grok` | xAI | Grok Build, `grok` | `$GROK_BIN_DIR`, `~/.grok/bin`, `~/.local/bin`, `/usr/local/bin` | [Grok Build](https://docs.x.ai/build/overview), [installer](https://x.ai/cli/install.sh) |
1995
+
1996
+ "System dirs" are `/opt/homebrew/bin`, `/usr/local/bin` and npm's global
1997
+ bin. Each one's version is `<bin> --version`. Every vendor here publishes
1998
+ an official CLI, so none is left out; a vendor without one is left out
1999
+ rather than guessed at (community CLIs, such as third-party "grok" CLIs,
2000
+ are not xAI's and are not in the catalog).
2001
+
2002
+ Claude Code runs as `den-claude` (above). Every other installed runtime
2003
+ runs its own interactive CLI (`codex`, `gemini`, `grok`) in the
2004
+ workspace's directory; Den's tools (MCP) and permission modes are coming
2005
+ for them, and lists tag them **Den tools: coming**. A runtime is offered
2006
+ as startable only when it can start here: installed (or, for `generic`,
2007
+ with the persona's `argv`).
2008
+
2009
+ A distribution adds entries with `agents.runtime_catalog` in its config,
2010
+ and a person with the same key in `config/local/user.yml`. Both go
2011
+ through the validator the built-in entries pass; an entry can't reuse a
2012
+ catalog id (set a path instead, below):
2013
+
2014
+ ```yaml
2015
+ agents:
2016
+ runtime_catalog:
2017
+ - id: aider # lowercase, starts with a letter
2018
+ vendor: Aider
2019
+ name: Aider
2020
+ bins: [aider] # executable names, no paths
2021
+ version:
2022
+ args: [--version]
2023
+ identity: # at least one: how a path is known to be it
2024
+ output: "aider" # a pattern its version output matches
2025
+ # package: aider-chat (the npm package it resolves into)
2026
+ # realpath_under: [~/.aider] (where it really lives)
2027
+ dirs: [~/.local/bin, $AIDER_DIR, "{system}"]
2028
+ launch:
2029
+ argv: ["{bin}"] # "{bin}" first: the path found
2030
+ ```
1903
2031
 
1904
2032
  **Where a runtime's CLI is found.** As your terminal would find it, not
1905
2033
  only on the web server's own PATH (a launchd service's PATH leaves out
1906
2034
  `~/.local/bin`, where Claude Code's native installer puts `claude`). The
1907
2035
  first match wins:
1908
2036
 
1909
- 1. **`agents.runtimes.<id>.bin`** in `config/local/user.yml`: the absolute
2037
+ 1. **A workspace's own path**, `<ws>.agents.runtimes.<id>.bin` in
2038
+ `config/local/workspaces.yml`, for that workspace's agents. Checked the
2039
+ same way as the next one.
2040
+ 2. **`agents.runtimes.<id>.bin`** in `config/local/user.yml`: the absolute
1910
2041
  path of the runtime's CLI. Paths are per machine, so it is read from
1911
2042
  user.yml only, never from the distribution's config. A bad value (not
1912
2043
  absolute, missing, not an executable file) makes the runtime
@@ -1920,14 +2051,28 @@ first match wins:
1920
2051
  bin: /Users/me/.local/bin/claude
1921
2052
  ```
1922
2053
 
1923
- 2. **Your login shell's PATH**: the web server asks `$SHELL -lc` once at
2054
+ 3. **Your login shell's PATH**: the web server asks `$SHELL -lc` once at
1924
2055
  start, in the background, under a 3-second timeout. On a timeout or
1925
2056
  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.
2057
+ 4. **The server's own PATH.**
2058
+ 5. **The runtime's install directories** (its catalog entry's; for
2059
+ den-claude: `~/.local/bin`, `~/.claude/local`, `/opt/homebrew/bin`,
2060
+ `/usr/local/bin` and npm's global bin, `$npm_config_prefix/bin` and
2061
+ node's own directory). Den never runs npm to find it.
2062
+ 6. **Your interactive shell's PATH**, only while a catalog runtime is
2063
+ still not found: `$SHELL -ilc` once (stdin closed, 3-second timeout,
2064
+ the answer kept), for a PATH set only in an interactive startup file
2065
+ such as `~/.zshrc`. Its source is `interactive-shell`.
2066
+
2067
+ Paths are this machine's only: Den reads them from `config/local` files,
2068
+ never from the distribution's config. **Settings › Agents › Runtimes**
2069
+ shows each runtime's status, version, the path in use and its source.
2070
+ **Set path…** runs the runtime's version command and refuses a path that
2071
+ isn't that runtime (its output, npm package or install location must name
2072
+ it); **Clear** removes it. The **Paths for** selector switches between
2073
+ every workspace (`user.yml`) and one workspace (`workspaces.yml`). Only a
2074
+ person sets a path, never an agent (`POST /api/agents/runtimes/<id>/path`,
2075
+ audited).
1931
2076
 
1932
2077
  Sessions and agents Den starts get the same directories on their PATH
1933
2078
  (and a configured CLI's own directory), so the runtime's own tools are
@@ -1936,17 +2081,19 @@ diagnosis: `DEN_LOGIN_SHELL` (the shell to ask instead of `$SHELL`; `off`
1936
2081
  for none) and `DEN_RUNTIME_DIRS` (a PATH-style list that replaces every
1937
2082
  runtime's install directories; empty for none).
1938
2083
 
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
2084
+ **`<cli> agents doctor`** prints, for each runtime (installed first), its
2085
+ status, the CLI it runs and where that came from (the workspace's or the
2086
+ user.yml `agents.runtimes` setting, the login shell's PATH, PATH, an
2087
+ install directory, or the interactive shell's PATH), its version (`claude
1942
2088
  --version`, run once under a timeout), the directories it searched (names
1943
2089
  only, never an environment value), and the fix when it's missing. It
1944
2090
  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.
2091
+ when the default runtime is unavailable; `-w <ws>` uses that workspace's
2092
+ paths; `--json` prints what the Start dialog gets. A search list the same
2093
+ as the one above it prints once.
1947
2094
 
1948
2095
  ```
1949
- den-claude (Claude Code) [default]
2096
+ den-claude (Claude Code, Anthropic) [default]
1950
2097
  status ✗ unavailable: claude not found (searched 5 directories)
1951
2098
  searched /usr/bin
1952
2099
  /bin
@@ -1955,9 +2102,12 @@ den-claude (Claude Code) [default]
1955
2102
  then run `<cli> agents doctor` again
1956
2103
  ```
1957
2104
 
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.
2105
+ `GET /api/personas` and `GET /api/agents/runtimes` (each with an optional
2106
+ `?workspace=<ws>`) give each runtime its `vendor`, `denTools`, `version`,
2107
+ `path`, `source` (`workspace`, `setting`, `login-shell`, `PATH`,
2108
+ `well-known` or `interactive-shell`), `searched`, `setting` (the user.yml
2109
+ key) and `workspaceSetting` (the workspaces.yml key), and `userConfig`,
2110
+ the user.yml to edit.
1961
2111
 
1962
2112
  ### Workspace scope and grants
1963
2113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * A runtime from a catalog entry (AG-0.5, catalog.ts): found by AG-0.1's
3
+ * search with the entry's binary names and install directories, and
4
+ * launched as its interactive CLI in the agent's directory (the entry's
5
+ * launch argv, "{bin}" the path found).
6
+ *
7
+ * These don't get Den's tools yet: no MCP config, no permission mode
8
+ * (AG-12 and AG-13 wire them per runtime). Lists tag them "Den tools:
9
+ * coming" (denTools: false). Claude Code is den-claude, which takes its
10
+ * search from the catalog and keeps its own launch.
11
+ *
12
+ * checkRuntimeBin is what "Set path…" runs: the entry's version command,
13
+ * and the entry's identity (its output names it, it resolves into the
14
+ * vendor's npm package, or under the vendor's install directory), so a
15
+ * path that isn't that runtime is refused.
16
+ */
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ import { configuredRuntimeBin } from "../den-config.js";
20
+ import { entryDirs, type CatalogEntry } from "./catalog.js";
21
+ import {
22
+ LaunchError,
23
+ type DetectOptions,
24
+ type LaunchOutput,
25
+ type Runtime,
26
+ type RuntimeDetect,
27
+ } from "./runtime.js";
28
+ import {
29
+ locateRuntime,
30
+ parseVersion,
31
+ systemBinDirs,
32
+ versionOutput,
33
+ type RuntimeFind,
34
+ } from "./runtime-search.js";
35
+
36
+ /** An entry as AG-0.1's search takes it. */
37
+ export function catalogFind(e: CatalogEntry): RuntimeFind {
38
+ return {
39
+ bins: e.bins,
40
+ dirs: (home, env) => entryDirs(e, home, env, systemBinDirs),
41
+ versionArgs: e.version.args,
42
+ };
43
+ }
44
+
45
+ /** Find an entry's CLI: a setting (workspace or global), else the search. */
46
+ export function detectEntry(
47
+ e: CatalogEntry,
48
+ find: RuntimeFind,
49
+ env: NodeJS.ProcessEnv,
50
+ opts: DetectOptions,
51
+ ): RuntimeDetect {
52
+ const setting =
53
+ opts.bin === undefined ? configuredRuntimeBin(e.id) : opts.bin;
54
+ const found = locateRuntime(e.id, find, env, setting);
55
+ if (!found.available || !found.path)
56
+ return {
57
+ available: false,
58
+ why: found.why ?? `${e.bins.join(" or ")} not found`,
59
+ ...(found.source ? { source: found.source } : {}),
60
+ searched: found.searched,
61
+ };
62
+ return {
63
+ available: true,
64
+ path: found.path,
65
+ ...(found.source ? { source: found.source } : {}),
66
+ searched: found.searched,
67
+ };
68
+ }
69
+
70
+ /** The launch argv: the entry's template, "{bin}" the path found. */
71
+ export function launchArgv(e: CatalogEntry, bin: string): string[] {
72
+ return e.launch.argv.map((a) => a.split("{bin}").join(bin));
73
+ }
74
+
75
+ /** A runtime for a catalog entry other than Claude Code (den-claude). */
76
+ export function catalogRuntime(e: CatalogEntry): Runtime {
77
+ const find = catalogFind(e);
78
+ const detect = (
79
+ env: NodeJS.ProcessEnv = process.env,
80
+ opts: DetectOptions = {},
81
+ ) => detectEntry(e, find, env, opts);
82
+ return {
83
+ id: e.id,
84
+ label: e.name,
85
+ vendor: e.vendor,
86
+ catalog: e,
87
+ find,
88
+ denTools: false,
89
+ detect,
90
+ launch(input): LaunchOutput {
91
+ let bin = input.bin;
92
+ if (!bin) {
93
+ const d = detect(input.env);
94
+ if (!d.available || !d.path)
95
+ throw new LaunchError(`${e.id}: ${d.why ?? "not found"}`);
96
+ bin = d.path;
97
+ }
98
+ return {
99
+ argv: launchArgv(e, bin),
100
+ env: {},
101
+ warnings: [
102
+ `${e.name} runs as its own CLI in ${input.worktree}: Den's tools (MCP) and permission modes are coming for it`,
103
+ ],
104
+ };
105
+ },
106
+ approvals: "terminal",
107
+ scrubEnv: [],
108
+ };
109
+ }
110
+
111
+ /** The npm package a file resolves into (the nearest package.json's name). */
112
+ function packageOf(real: string): string | undefined {
113
+ for (let dir = path.dirname(real), i = 0; i < 8; i++) {
114
+ try {
115
+ const pkg = JSON.parse(
116
+ fs.readFileSync(path.join(dir, "package.json"), "utf-8"),
117
+ ) as { name?: unknown };
118
+ if (typeof pkg.name === "string") return pkg.name;
119
+ } catch {
120
+ /* not here */
121
+ }
122
+ const up = path.dirname(dir);
123
+ if (up === dir) break;
124
+ dir = up;
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ export type BinCheck =
130
+ | { ok: true; path: string; version: string }
131
+ | { ok: false; why: string };
132
+
133
+ /**
134
+ * Whether `bin` is entry `e`'s runtime: an absolute path to an executable
135
+ * file whose version command exits 0 and prints a version, and whose
136
+ * output, npm package or real location names it. Runs only that command
137
+ * (stdin closed, under a timeout). Never throws.
138
+ */
139
+ export async function checkRuntimeBin(
140
+ e: CatalogEntry,
141
+ bin: string,
142
+ env: NodeJS.ProcessEnv,
143
+ ): Promise<BinCheck> {
144
+ const p = bin.trim();
145
+ const shown = p.replace(/[^\x20-\x7e]/g, "?").slice(0, 200);
146
+ if (!path.isAbsolute(p))
147
+ return { ok: false, why: `"${shown}" is not an absolute path` };
148
+ let real: string;
149
+ try {
150
+ const st = fs.statSync(p);
151
+ fs.accessSync(p, fs.constants.X_OK);
152
+ if (!st.isFile()) throw new Error("not a file");
153
+ real = fs.realpathSync(p);
154
+ } catch {
155
+ return { ok: false, why: `${shown} is not an executable file` };
156
+ }
157
+ const cmd = `${path.basename(p)} ${e.version.args.join(" ")}`;
158
+ const r = await versionOutput(p, e.version.args, env);
159
+ if (!r.ok) return { ok: false, why: `\`${cmd}\` failed: not ${e.name}` };
160
+ const version = parseVersion(r.out);
161
+ if (!version)
162
+ return { ok: false, why: `\`${cmd}\` printed no version: not ${e.name}` };
163
+ const idn = e.version.identity;
164
+ const home = env.HOME && path.isAbsolute(env.HOME) ? env.HOME : undefined;
165
+ const under = (idn.realpath_under ?? []).flatMap((d) =>
166
+ d.startsWith("~/") ? (home ? [path.join(home, d.slice(2))] : []) : [d],
167
+ );
168
+ const named =
169
+ (idn.output !== undefined && new RegExp(idn.output, "i").test(r.out)) ||
170
+ (idn.package !== undefined && packageOf(real) === idn.package) ||
171
+ under.some((d) => real === d || real.startsWith(d + path.sep));
172
+ if (!named)
173
+ return {
174
+ ok: false,
175
+ why: `${shown} is not ${e.name}: its version output, package and location don't name it`,
176
+ };
177
+ return { ok: true, path: p, version };
178
+ }