@monster-orange/den 0.4.7 → 0.4.8
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 +94 -0
- package/README.md +7 -7
- package/docs/distributions.md +120 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/src/shared/agents/den-claude.ts +19 -0
- package/src/shared/agents/personas.ts +11 -1
- package/src/shared/agents/records.ts +114 -0
- package/src/shared/agents/runtime.ts +12 -0
- package/src/shared/den-config.ts +30 -0
- package/src/shared/plugins/manifest.ts +3 -0
- package/src/shared/usage/paths.ts +16 -0
- package/src/shared/usage/report.ts +283 -0
- package/src/shared/usage/sources.ts +127 -0
- package/src/shared/usage/transcript.ts +262 -0
- package/src/ui/cli/agent-ops.ts +488 -50
- package/src/ui/cli/agents-command.ts +120 -14
- package/src/ui/cli/index.ts +13 -1
- package/src/ui/cli/usage-command.ts +256 -0
- package/src/ui/mcp/api.ts +10 -1
- package/src/ui/mcp/server.ts +34 -0
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-resume-grant.ts +53 -0
- package/src/ui/server/agent-routes.ts +230 -9
- package/src/ui/server/audit.ts +68 -1
- package/src/ui/server/index.ts +16 -5
- package/src/ui/server/public/agents-page.js +333 -11
- package/src/ui/server/public/agents-section.js +17 -3
- package/src/ui/server/public/agents.html +44 -0
- package/src/ui/server/public/i18n/es.js +28 -0
- package/src/ui/server/public/i18n/ja.js +25 -0
- package/src/ui/server/public/shell.js +66 -0
- package/src/ui/server/request-guard.ts +21 -1
- package/src/ui/server/route-classes.ts +23 -0
- package/src/ui/server/session-routes.ts +52 -4
- package/src/ui/server/sudo-store.ts +36 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,100 @@ 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.8] - 2026-11-07
|
|
8
|
+
|
|
9
|
+
Stopped agents stay in the list, and come back where they were (AG-14, the
|
|
10
|
+
stopped-agents slice). Den also counts what each agent used and what it
|
|
11
|
+
got done, read on this machine only (den#6, the agent-usage issue).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Stopped agents stay listed.** They sit below the running ones, each
|
|
16
|
+
with why it stopped: stopped by you, exited (with its code), or
|
|
17
|
+
interrupted by a restart. Each row has Resume, Start fresh and Remove.
|
|
18
|
+
- **Resume is the same conversation.** Den runs the runtime's own resume
|
|
19
|
+
with the conversation id it saved (Claude Code: `--resume <id>`). The
|
|
20
|
+
agent comes back in its own folder, with its workspace and grants. A
|
|
21
|
+
runtime that can't resume (codex, gemini, grok), or an agent with no
|
|
22
|
+
saved id, offers Start fresh only and says why.
|
|
23
|
+
- **Remove in one click, with Undo.** The toast offers Undo for 10 seconds.
|
|
24
|
+
**Clear stopped** removes every stopped agent in view. A running agent
|
|
25
|
+
is never removed.
|
|
26
|
+
- **After a restart, one line.** "2 agents were interrupted when Acme
|
|
27
|
+
restarted · Resume all · Review". Standing personas, and personas with
|
|
28
|
+
`resume_on_restart: true`, resume on their own.
|
|
29
|
+
- **The CLI:** `agents list --stopped`, `agents resume --all-interrupted`,
|
|
30
|
+
`agents rm <id>` and `agents rm --stopped`.
|
|
31
|
+
- **`agent_resume`, an MCP action for orchestrators.** Off unless
|
|
32
|
+
`mcp.actions` names it. It resumes agents of its own workspace or a
|
|
33
|
+
granted one, never itself. The web server checks the same rule.
|
|
34
|
+
- **`usage agents` and `usage report`.** Each agent's tokens and outcome
|
|
35
|
+
(pull request opened or merged, tests green, wall-clock time) are read
|
|
36
|
+
from its transcripts and saved in its record, matched by the
|
|
37
|
+
conversation id. `usage report` writes a weekly summary, compared with
|
|
38
|
+
the last. Transcripts stay on the machine, and only counts are kept.
|
|
39
|
+
- **The `usage` plugin kind** (server tier) adds a transcript reader for
|
|
40
|
+
another runtime. Den ships one for Claude Code.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **The audit trail survives a restart.** The last 200 actions are kept in
|
|
45
|
+
`<state dir>/audit.jsonl` (0600) and read back at start.
|
|
46
|
+
- **An agent after a restart says Interrupted**, not Exited. An agent that
|
|
47
|
+
exited says Exited (code n), even after the session host forgets it.
|
|
48
|
+
- **An agent record notes its grants**, its end and exit code, and its
|
|
49
|
+
usage and outcome.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **A resume never loses the conversation.** Stopping an agent without
|
|
54
|
+
typing, then resuming it, could fail for good: Den started a new
|
|
55
|
+
conversation under the same id, and Claude Code refused it as "already
|
|
56
|
+
in use". Now Den decides before it launches. For Claude Code it looks
|
|
57
|
+
for the conversation's transcript: if it is there, or someone typed in
|
|
58
|
+
the last session, it resumes with `--resume <id>`; if not, it starts
|
|
59
|
+
fresh under the same id, in the same folder and scope, and says "nothing
|
|
60
|
+
to resume yet; started fresh". Every launch also marks the record
|
|
61
|
+
`launched: true`, for runtimes Den can't check this way. If Claude Code
|
|
62
|
+
still says there is no conversation (Den now reads past its trailing
|
|
63
|
+
terminal codes), Den starts fresh once. If a
|
|
64
|
+
fresh start says the id is in use, Den resumes once instead. If both
|
|
65
|
+
fail, the error names the id and Start fresh (the route answers 502
|
|
66
|
+
`resume_failed`).
|
|
67
|
+
- **No "+ Start agent" for a workspace that doesn't exist** (`?ws=` with
|
|
68
|
+
an unknown name).
|
|
69
|
+
- **`agents stop` waits for the agent to end.** It returns once the
|
|
70
|
+
agent's session has ended, so `agents stop X && agents resume X` works.
|
|
71
|
+
It waits up to 5 seconds. Still running then, it prints
|
|
72
|
+
`X is still stopping` and exits 1. `--no-wait` returns at once, as
|
|
73
|
+
before. The stop route answers 200 with `stopped: true` once the session
|
|
74
|
+
has ended, or 202 with `stillStopping: true`.
|
|
75
|
+
- **The Agents page never shows All in place of its `?ws=`.** A workspace
|
|
76
|
+
that isn't active gets one line, "Workspace west isn't active · Switch
|
|
77
|
+
to west", and the list shows that workspace alone. Clear stopped acts on
|
|
78
|
+
exactly the workspaces listed. A name no workspace has gets the same
|
|
79
|
+
line and an empty list.
|
|
80
|
+
- **Den's sudo is `/usr/bin/sudo`.** A `sudo` placed earlier on `PATH` no
|
|
81
|
+
longer gets the admin password. Den looks on `PATH` only when there is no
|
|
82
|
+
`/usr/bin/sudo`. `DEN_SYSTEM_SUDO` names another absolute path.
|
|
83
|
+
- **`mcp` refuses an argument it doesn't take.** A typo such as
|
|
84
|
+
`--worksapce` exits 2 with the usage, instead of starting an unscoped
|
|
85
|
+
server.
|
|
86
|
+
|
|
87
|
+
### Metrics (before → after)
|
|
88
|
+
|
|
89
|
+
- Share of agent sessions with attributed tokens and outcome: ~0 → ≥90%.
|
|
90
|
+
On the test fixture (11 agents): 0% → 91%. The one left out runs on
|
|
91
|
+
codex, which no usage reader covers yet.
|
|
92
|
+
|
|
93
|
+
### Migrations
|
|
94
|
+
|
|
95
|
+
- None needed. To remove stopped agents on their own, set
|
|
96
|
+
`agents.remove_stopped_after` in `config/local/user.yml` to `1d` or `7d`
|
|
97
|
+
(the default is `never`). Interrupted agents are never removed this way.
|
|
98
|
+
To let an orchestrator resume agents, add `agent_resume` to
|
|
99
|
+
`mcp.actions`.
|
|
100
|
+
|
|
7
101
|
## [0.4.7] - 2026-11-07
|
|
8
102
|
|
|
9
103
|
Agents read slices, wait in the background and hand back plainly (den#5,
|
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<picture>
|
|
13
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.
|
|
14
|
-
<img src="docs/images/0.4.
|
|
13
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.8/hero-dark.png">
|
|
14
|
+
<img src="docs/images/0.4.8/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
15
|
</picture>
|
|
16
16
|
|
|
17
17
|
## Set up by your agent
|
|
@@ -38,7 +38,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
|
|
|
38
38
|
yours. Den connects them and turns on good practice from the start.
|
|
39
39
|
|
|
40
40
|
<p align="center">
|
|
41
|
-
<img src="docs/images/0.4.
|
|
41
|
+
<img src="docs/images/0.4.8/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">
|
|
42
42
|
</p>
|
|
43
43
|
|
|
44
44
|
## How it helps
|
|
@@ -68,10 +68,10 @@ yours. Den connects them and turns on good practice from the start.
|
|
|
68
68
|
lives in your own repo.
|
|
69
69
|
|
|
70
70
|
<p align="center">
|
|
71
|
-
<img src="docs/images/0.4.
|
|
72
|
-
<img src="docs/images/0.4.
|
|
73
|
-
<img src="docs/images/0.4.
|
|
74
|
-
<img src="docs/images/0.4.
|
|
71
|
+
<img src="docs/images/0.4.8/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">
|
|
72
|
+
<img src="docs/images/0.4.8/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
|
|
73
|
+
<img src="docs/images/0.4.8/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
|
|
74
|
+
<img src="docs/images/0.4.8/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">
|
|
75
75
|
</p>
|
|
76
76
|
|
|
77
77
|
## Get started
|
package/docs/distributions.md
CHANGED
|
@@ -497,6 +497,11 @@ in the distribution's config always wins over the one in user.yml.
|
|
|
497
497
|
`backendAvailable`, `reason` and `hasStored` (`opAvailable` is a
|
|
498
498
|
deprecated alias of `backendAvailable`, kept for one minor).
|
|
499
499
|
|
|
500
|
+
**Den's sudo is `/usr/bin/sudo`.** Den checks and caches the admin
|
|
501
|
+
password with `/usr/bin/sudo` only, never a `sudo` found earlier on `PATH`.
|
|
502
|
+
Only on a machine with no `/usr/bin/sudo` does it look on `PATH`.
|
|
503
|
+
`DEN_SYSTEM_SUDO=<absolute path>` names another system sudo.
|
|
504
|
+
|
|
500
505
|
The admin password is for sudo, and nothing else (LK-0). It is no sign-in
|
|
501
506
|
and no access check: starting, stopping and resuming agents, sessions and
|
|
502
507
|
terminals, and a service whose commands run no sudo, never ask for it.
|
|
@@ -1836,6 +1841,9 @@ the server: only registered tools (so actions only as named in
|
|
|
1836
1841
|
the tool's text to stdout and exits 1 when the tool reports an error (a
|
|
1837
1842
|
refusal, a failed action, the web server down).
|
|
1838
1843
|
|
|
1844
|
+
`mcp` takes only the arguments its usage lists. Any other (a typo such as
|
|
1845
|
+
`--worksapce`) exits 2 with the usage, before anything starts.
|
|
1846
|
+
|
|
1839
1847
|
### Config
|
|
1840
1848
|
|
|
1841
1849
|
Without an `mcp:` block in the org config (`config/<name>.yml`) only the read
|
|
@@ -1918,6 +1926,12 @@ Action tools (only those named in `mcp.actions`): `service_up`,
|
|
|
1918
1926
|
`workspace_sync` (`/api/workspace/sync`) and `run_tests` (`/api/run`). There
|
|
1919
1927
|
is no deploy tool.
|
|
1920
1928
|
|
|
1929
|
+
`agent_resume` (AG-14.4) resumes a stopped or interrupted agent, for an
|
|
1930
|
+
orchestrator. It is off unless `mcp.actions` names it. A scoped server
|
|
1931
|
+
(`--workspace`) resumes agents of its own workspace or a granted one, never
|
|
1932
|
+
itself. The web server checks the same rule, and the resume route is the
|
|
1933
|
+
only agent route an agent's token reaches.
|
|
1934
|
+
|
|
1921
1935
|
### Guards
|
|
1922
1936
|
|
|
1923
1937
|
- **Lifecycle only, local only.** The service tools run a manifest's `up`,
|
|
@@ -2103,7 +2117,14 @@ The routes: `GET /api/workspaces/<ws>/agents`, `POST
|
|
|
2103
2117
|
agent of the workspaces in view, and their terminals in the browser.
|
|
2104
2118
|
|
|
2105
2119
|
- **The list** is grouped by workspace and follows the scope bar (or the
|
|
2106
|
-
page's `?ws=<ws>`, which "Open in Agents →" sets).
|
|
2120
|
+
page's `?ws=<ws>`, which "Open in Agents →" sets). A `?ws=` is never
|
|
2121
|
+
replaced by All. A workspace that isn't active gets one line, "Workspace
|
|
2122
|
+
west isn't active · Switch to west", and the list shows it alone.
|
|
2123
|
+
Switch makes it active and views it, as the top bar's Workspace menu
|
|
2124
|
+
does. A name no workspace has gets the same line, an empty list, and no
|
|
2125
|
+
"+ Start agent".
|
|
2126
|
+
`GET /api/sessions?ws=<ws>` lists that workspace even when it isn't
|
|
2127
|
+
active, and says `requested: { workspace, active, known }`. Each row has its
|
|
2107
2128
|
state, persona (a `sessions start` session is labelled "session"),
|
|
2108
2129
|
runtime and directory, and "waiting" when it waits on you (once the
|
|
2109
2130
|
session host can tell, AG-6a; not yet). An agent whose
|
|
@@ -2139,6 +2160,84 @@ text frame `{"type":"resize","cols":c,"rows":r}` resizes, and the server
|
|
|
2139
2160
|
sends `{"type":"session",…}` first and `{"type":"exit","exitCode":n}` when
|
|
2140
2161
|
the session ends.
|
|
2141
2162
|
|
|
2163
|
+
### Stopped agents: Resume, Start fresh, Remove
|
|
2164
|
+
|
|
2165
|
+
An agent that stops stays in the list, below the running ones (AG-14, the
|
|
2166
|
+
stopped-agents slice). Its row says why it stopped: "Stopped by you",
|
|
2167
|
+
"Exited (code n)", or "Interrupted" when Den or the machine restarted under
|
|
2168
|
+
it. Each row has three buttons:
|
|
2169
|
+
|
|
2170
|
+
- **Resume** continues the same conversation. Den runs the runtime's own
|
|
2171
|
+
resume with the conversation id it saved (Claude Code: `--resume <id>`).
|
|
2172
|
+
The agent comes back in the folder it had, with its workspace and its
|
|
2173
|
+
grants. A grant you took out of the config since is gone; one you added
|
|
2174
|
+
is not taken. Resume is never a new conversation fed the old prompt.
|
|
2175
|
+
Den decides before it launches. For Claude Code it looks for the
|
|
2176
|
+
conversation's transcript (`<id>.jsonl` under
|
|
2177
|
+
`$CLAUDE_CONFIG_DIR/projects`, else `~/.claude/projects`): if it is
|
|
2178
|
+
there, or someone typed in the last session, the resume is
|
|
2179
|
+
`--resume <id>`. If not, nobody ever typed, so Den starts fresh under the
|
|
2180
|
+
same id, folder and scope, and says "nothing to resume yet; started
|
|
2181
|
+
fresh" (the route adds `fresh: true`). For a runtime Den can't check,
|
|
2182
|
+
the record decides: every launch marks it `launched: true`, and a
|
|
2183
|
+
launched record resumes. As a backstop, if the runtime still says there
|
|
2184
|
+
is no conversation, Den starts fresh once and says so.
|
|
2185
|
+
If a fresh start says the id is already in use, Den resumes once
|
|
2186
|
+
instead. If both fail, the CLI exits 1 and the route answers 502 with
|
|
2187
|
+
code `resume_failed`, naming the id and Start fresh.
|
|
2188
|
+
- **Start fresh** starts a new conversation: same persona, workspace and id.
|
|
2189
|
+
- **Remove** takes the row away in one click. The toast offers **Undo** for
|
|
2190
|
+
10 seconds. Den keeps the removed record for an hour, then forgets it. It
|
|
2191
|
+
never touches the runtime's own history.
|
|
2192
|
+
|
|
2193
|
+
**Stop waits for the agent to end.** `agents stop <id>` returns once the
|
|
2194
|
+
session has ended, so a resume right after it works. It waits up to 5
|
|
2195
|
+
seconds. If the session is still running then, it prints `<id> is still
|
|
2196
|
+
stopping` and exits 1. `--no-wait` returns once TERM is sent. The route
|
|
2197
|
+
`POST /api/workspaces/<ws>/agents/<id>/stop` answers the same way: 200
|
|
2198
|
+
with `{ ok, agent, wasRunning, stopped: true }` once the session has
|
|
2199
|
+
ended, or 202 with `stopped: false, stillStopping: true`.
|
|
2200
|
+
|
|
2201
|
+
A runtime that can't resume (codex, gemini, grok), or an agent with no
|
|
2202
|
+
saved conversation id, shows only Start fresh, and says why. **Clear
|
|
2203
|
+
stopped**, beside + Start agent, removes every stopped agent in view. A
|
|
2204
|
+
running agent is never removed: stop it first. None of this asks for the
|
|
2205
|
+
admin password.
|
|
2206
|
+
|
|
2207
|
+
**After a restart**, the agents it cut off show Interrupted, and one line
|
|
2208
|
+
sits above the list: "2 agents were interrupted when Acme restarted ·
|
|
2209
|
+
Resume all · Review". Resume all resumes each one that can. Review marks
|
|
2210
|
+
them and moves to the first. Agents whose persona is `standing: true`, or
|
|
2211
|
+
has `resume_on_restart: true`, resume on their own when the web server
|
|
2212
|
+
starts.
|
|
2213
|
+
|
|
2214
|
+
**Removed on their own.** Stopped agents can go after a while:
|
|
2215
|
+
|
|
2216
|
+
```yaml
|
|
2217
|
+
# config/local/user.yml
|
|
2218
|
+
agents:
|
|
2219
|
+
remove_stopped_after: 7d # never (the default), 1d or 7d
|
|
2220
|
+
```
|
|
2221
|
+
|
|
2222
|
+
An interrupted agent is never removed this way. Any other value fails,
|
|
2223
|
+
naming the key.
|
|
2224
|
+
|
|
2225
|
+
The CLI does the same:
|
|
2226
|
+
|
|
2227
|
+
```sh
|
|
2228
|
+
./bin/acme agents list --stopped # only the stopped ones
|
|
2229
|
+
./bin/acme agents resume planner # the same conversation
|
|
2230
|
+
./bin/acme agents resume --all-interrupted # every interrupted one that can
|
|
2231
|
+
./bin/acme agents rm planner # one stopped agent
|
|
2232
|
+
./bin/acme agents rm --stopped [-w north] # every stopped one
|
|
2233
|
+
```
|
|
2234
|
+
|
|
2235
|
+
The routes (a person's only, audited): `POST
|
|
2236
|
+
/api/workspaces/<ws>/agents/<id>/resume`, `…/fresh` and `…/remove`, and
|
|
2237
|
+
`POST /api/agents/clear-stopped` (`{ workspaces }`), `/api/agents/restore`
|
|
2238
|
+
(`{ agents }`, the Undo) and `/api/agents/resume-interrupted` (`{
|
|
2239
|
+
workspaces }`).
|
|
2240
|
+
|
|
2142
2241
|
### An agent's environment
|
|
2143
2242
|
|
|
2144
2243
|
An agent's environment starts empty and gets, in order:
|
|
@@ -2547,6 +2646,26 @@ Only the boolean `true` counts. With it, and a Sentry DSN in
|
|
|
2547
2646
|
also sent, best effort. A DSN on its own never sends. `CI=true` and
|
|
2548
2647
|
`DEV_METRICS_DISABLED=1` stop sending even when opted in.
|
|
2549
2648
|
|
|
2649
|
+
## Agent usage: tokens and outcomes, read locally
|
|
2650
|
+
|
|
2651
|
+
`<cli> usage agents [-w <ws>] [--json]` reads each agent's transcripts on
|
|
2652
|
+
this machine and saves its tokens and outcome in its record: the pull
|
|
2653
|
+
request it opened, whether it merged, whether its last test run passed,
|
|
2654
|
+
and its wall-clock time. It matches transcripts to agents by the
|
|
2655
|
+
conversation id Den saved at start. It prints the share of agents with
|
|
2656
|
+
both. Transcripts never leave the machine, and their text is never kept:
|
|
2657
|
+
only counts.
|
|
2658
|
+
|
|
2659
|
+
`<cli> usage report [--days 7] [--json]` writes a weekly summary to
|
|
2660
|
+
`<state dir>/usage/<date>.json` and `.md`, compared with the last one:
|
|
2661
|
+
calls, cache re-reads, polls, sleeps, compactions, corrections and the
|
|
2662
|
+
attributed share.
|
|
2663
|
+
|
|
2664
|
+
Den reads Claude Code's transcripts (`$CLAUDE_CONFIG_DIR/projects`, else
|
|
2665
|
+
`~/.claude/projects`). A plugin can add a reader for another runtime with
|
|
2666
|
+
the `usage` kind (server tier). A runtime with no reader shows "no usage
|
|
2667
|
+
source reads <runtime>".
|
|
2668
|
+
|
|
2550
2669
|
## Pinning and upgrading
|
|
2551
2670
|
|
|
2552
2671
|
`package.json` pins an exact Den version (`create` writes the version that
|
package/package.json
CHANGED
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"pattern": "^(?!/)(?!.*\\.\\.)[A-Za-z0-9._/-]+$"
|
|
71
71
|
},
|
|
72
72
|
"kind": {
|
|
73
|
-
"description": "files tier: theme, catalog, persona-pack. browser tier: page. server tier: mcp-tools, work-items, runtime, session-backend, notifier, secrets, identity, health-source.",
|
|
73
|
+
"description": "files tier: theme, catalog, persona-pack. browser tier: page. server tier: mcp-tools, work-items, runtime, session-backend, notifier, secrets, identity, health-source, usage.",
|
|
74
74
|
"enum": [
|
|
75
75
|
"theme",
|
|
76
76
|
"catalog",
|
|
@@ -83,7 +83,8 @@
|
|
|
83
83
|
"notifier",
|
|
84
84
|
"secrets",
|
|
85
85
|
"identity",
|
|
86
|
-
"health-source"
|
|
86
|
+
"health-source",
|
|
87
|
+
"usage"
|
|
87
88
|
]
|
|
88
89
|
},
|
|
89
90
|
"provide": {
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
import { builtinEntry } from "./catalog.js";
|
|
60
60
|
import { catalogFind } from "./catalog-runtime.js";
|
|
61
61
|
import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
|
|
62
|
+
import { claudeProjectsDir } from "../usage/paths.js";
|
|
62
63
|
|
|
63
64
|
export const DEN_CLAUDE_ID = "den-claude";
|
|
64
65
|
const BIN = "claude";
|
|
@@ -317,6 +318,24 @@ export const denClaude: Runtime = {
|
|
|
317
318
|
warnings: c.warnings,
|
|
318
319
|
};
|
|
319
320
|
},
|
|
321
|
+
// Its transcript, <token>.jsonl in any project under its projects dir.
|
|
322
|
+
conversationExists(token, env = process.env) {
|
|
323
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(token)) return false;
|
|
324
|
+
const root = claudeProjectsDir(env);
|
|
325
|
+
let projects: string[] = [];
|
|
326
|
+
try {
|
|
327
|
+
projects = fs.readdirSync(root);
|
|
328
|
+
} catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
return projects.some((p) =>
|
|
332
|
+
fs.existsSync(path.join(root, p, `${token}.jsonl`)),
|
|
333
|
+
);
|
|
334
|
+
},
|
|
335
|
+
launchFailures: {
|
|
336
|
+
noConversation: /no conversation found/i,
|
|
337
|
+
idInUse: /already in use/i,
|
|
338
|
+
},
|
|
320
339
|
approvals: "terminal",
|
|
321
340
|
envPassthrough: ["CLAUDE_CONFIG_DIR"],
|
|
322
341
|
// Claude Code's session plumbing: the session id, its messaging socket and
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* tools "Read, Grep" or a list: hints, never a restriction Den applies
|
|
14
14
|
*
|
|
15
15
|
* Den's own keys sit beside them: `runtime` (default den-claude),
|
|
16
|
-
* `standing` (starts with its workspace, §5
|
|
16
|
+
* `standing` (starts with its workspace, §5; resumes after a restart),
|
|
17
|
+
* `resume_on_restart` (AG-14.3: resumes after a restart too), `context` (what to read
|
|
17
18
|
* first), `argv` (the generic runtime's template), and `permission_mode`
|
|
18
19
|
* (den-claude's Claude Code permission mode; it checks the value), and
|
|
19
20
|
* `den_hooks` / `den_instructions` (true or false: this persona's
|
|
@@ -45,6 +46,11 @@ export interface Persona {
|
|
|
45
46
|
model?: string;
|
|
46
47
|
/** Started with its workspace (planner, orchestrator). */
|
|
47
48
|
standing: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* `resume_on_restart: true` (AG-14.3): resumed on its own when Den or
|
|
51
|
+
* the machine restarted. Standing personas always are.
|
|
52
|
+
*/
|
|
53
|
+
resumeOnRestart?: boolean;
|
|
48
54
|
/** Tool hints. */
|
|
49
55
|
tools: string[];
|
|
50
56
|
/** What to read first (specs, the design system, workspace config). */
|
|
@@ -135,6 +141,9 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
135
141
|
const standing = f.standing ?? false;
|
|
136
142
|
if (typeof standing !== "boolean")
|
|
137
143
|
throw new Error(`${file}: standing must be true or false`);
|
|
144
|
+
const resumeOnRestart = f.resume_on_restart ?? false;
|
|
145
|
+
if (typeof resumeOnRestart !== "boolean")
|
|
146
|
+
throw new Error(`${file}: resume_on_restart must be true or false`);
|
|
138
147
|
let argv: string[] | undefined;
|
|
139
148
|
if (f.argv !== undefined && f.argv !== null) {
|
|
140
149
|
if (
|
|
@@ -166,6 +175,7 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
166
175
|
...(runtime ? { runtime } : {}),
|
|
167
176
|
...(model && model !== "inherit" ? { model } : {}),
|
|
168
177
|
standing,
|
|
178
|
+
...(resumeOnRestart ? { resumeOnRestart } : {}),
|
|
169
179
|
tools: stringList(f, "tools", file),
|
|
170
180
|
context: stringList(f, "context", file),
|
|
171
181
|
...(argv ? { argv } : {}),
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* A record never holds the env or the argv: the role prompt, tokens and
|
|
10
10
|
* anything else a launch passes stay out of it. An agent id is unique in a
|
|
11
11
|
* distribution.
|
|
12
|
+
*
|
|
13
|
+
* AG-14: a removed agent's record moves to removed-agents/<id>.json, so
|
|
14
|
+
* Undo can bring it back; restoreRecord does, and purgeRemoved forgets
|
|
15
|
+
* the ones older than the undo window. The runtime's own conversation
|
|
16
|
+
* history is never touched.
|
|
12
17
|
*/
|
|
13
18
|
import * as fs from "fs";
|
|
14
19
|
import * as path from "path";
|
|
@@ -34,6 +39,54 @@ export interface AgentRecord {
|
|
|
34
39
|
resumedAt?: string;
|
|
35
40
|
/** The runtime's handle for resume (den-claude: the conversation id). */
|
|
36
41
|
resumeToken?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The runtime was launched on resumeToken at least once (0.4.8). A
|
|
44
|
+
* resume then always tries the runtime's own resume first, never a new
|
|
45
|
+
* conversation under the same id (which the runtime refuses once the
|
|
46
|
+
* conversation exists).
|
|
47
|
+
*/
|
|
48
|
+
launched?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The workspaces beyond its own its MCP served when it started
|
|
51
|
+
* (agents.<id>.grants then). A resume keeps them, never more than the
|
|
52
|
+
* config still allows (AG-14).
|
|
53
|
+
*/
|
|
54
|
+
grants?: string[];
|
|
55
|
+
/** When its session was seen to end on its own (AG-14: "Exited"). */
|
|
56
|
+
endedAt?: string;
|
|
57
|
+
/** That session's exit code, when the host said it. */
|
|
58
|
+
exitCode?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Set when its session was gone after Den or the machine restarted
|
|
61
|
+
* (AG-14.3: "Interrupted"); cleared by a resume or a start.
|
|
62
|
+
*/
|
|
63
|
+
interruptedAt?: string;
|
|
64
|
+
/** What its run came to (den#6), filled in by `<cli> usage`. */
|
|
65
|
+
outcome?: AgentOutcome;
|
|
66
|
+
/** Its tokens, from the runtime's own transcript (den#6). */
|
|
67
|
+
usage?: AgentUsage;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What an agent's run came to (den#6). Read locally; never sent. */
|
|
71
|
+
export interface AgentOutcome {
|
|
72
|
+
/** The pull request it opened, when it opened one. */
|
|
73
|
+
prOpened?: string;
|
|
74
|
+
/** That pull request merged in the run. */
|
|
75
|
+
prMerged?: boolean;
|
|
76
|
+
/** The last test run it made passed (true) or failed (false). */
|
|
77
|
+
testsGreen?: boolean;
|
|
78
|
+
/** From its start to its last activity, in milliseconds. */
|
|
79
|
+
wallClockMs?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** An agent's token counts (den#6), summed over its transcripts. */
|
|
83
|
+
export interface AgentUsage {
|
|
84
|
+
calls: number;
|
|
85
|
+
input: number;
|
|
86
|
+
output: number;
|
|
87
|
+
cacheRead: number;
|
|
88
|
+
cacheWrite: number;
|
|
89
|
+
compactions: number;
|
|
37
90
|
}
|
|
38
91
|
|
|
39
92
|
/** An agent id: the same rules as a workspace or session name. */
|
|
@@ -85,6 +138,67 @@ export function readRecord(stateDir: string, id: string): AgentRecord | null {
|
|
|
85
138
|
}
|
|
86
139
|
}
|
|
87
140
|
|
|
141
|
+
/** Where removed records wait for Undo (AG-14.1b). */
|
|
142
|
+
export function removedDir(stateDir: string): string {
|
|
143
|
+
return path.join(stateDir, "removed-agents");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** How long a removed record can be brought back. */
|
|
147
|
+
export const UNDO_KEEP_MS = 60 * 60 * 1000;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Forget agent `id` (its record moves aside for Undo). True when there
|
|
151
|
+
* was one. Its files and the runtime's history stay.
|
|
152
|
+
*/
|
|
153
|
+
export function removeRecord(stateDir: string, id: string): boolean {
|
|
154
|
+
const from = recordFile(stateDir, id);
|
|
155
|
+
if (!fs.existsSync(from)) return false;
|
|
156
|
+
ensureDir(removedDir(stateDir));
|
|
157
|
+
const to = path.join(removedDir(stateDir), `${id}.json`);
|
|
158
|
+
fs.renameSync(from, to);
|
|
159
|
+
// Its mtime is when it was removed: purgeRemoved goes by it.
|
|
160
|
+
const now = new Date();
|
|
161
|
+
fs.utimesSync(to, now, now);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Bring a removed record back (Undo). False when there is none to bring
|
|
167
|
+
* back, or an agent of that id exists again.
|
|
168
|
+
*/
|
|
169
|
+
export function restoreRecord(stateDir: string, id: string): boolean {
|
|
170
|
+
if (!AGENT_ID_RE.test(id)) return false;
|
|
171
|
+
const from = path.join(removedDir(stateDir), `${id}.json`);
|
|
172
|
+
const to = recordFile(stateDir, id);
|
|
173
|
+
if (!fs.existsSync(from) || fs.existsSync(to)) return false;
|
|
174
|
+
ensureDir(agentsDir(stateDir));
|
|
175
|
+
fs.renameSync(from, to);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Forget removed records older than `keepMs` for good. */
|
|
180
|
+
export function purgeRemoved(
|
|
181
|
+
stateDir: string,
|
|
182
|
+
keepMs = UNDO_KEEP_MS,
|
|
183
|
+
now = Date.now(),
|
|
184
|
+
): void {
|
|
185
|
+
let names: string[];
|
|
186
|
+
try {
|
|
187
|
+
names = fs.readdirSync(removedDir(stateDir));
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
for (const n of names) {
|
|
192
|
+
if (!n.endsWith(".json") || !AGENT_ID_RE.test(n.slice(0, -5))) continue;
|
|
193
|
+
const f = path.join(removedDir(stateDir), n);
|
|
194
|
+
try {
|
|
195
|
+
if (now - fs.statSync(f).mtimeMs > keepMs) fs.unlinkSync(f);
|
|
196
|
+
} catch {
|
|
197
|
+
/* gone already */
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
88
202
|
export function listRecords(stateDir: string): AgentRecord[] {
|
|
89
203
|
let names: string[];
|
|
90
204
|
try {
|
|
@@ -130,6 +130,18 @@ export interface Runtime {
|
|
|
130
130
|
launch(input: LaunchInput): LaunchOutput;
|
|
131
131
|
/** A new run that continues the last one; null when there is nothing to resume. */
|
|
132
132
|
resume?(input: ResumeInput): Omit<LaunchOutput, "resumeToken"> | null;
|
|
133
|
+
/**
|
|
134
|
+
* Whether the runtime has a conversation for `token` on this machine
|
|
135
|
+
* (den-claude: its transcript file). Only ever a fast path: false may
|
|
136
|
+
* just mean it isn't written yet.
|
|
137
|
+
*/
|
|
138
|
+
conversationExists?(token: string, env?: NodeJS.ProcessEnv): boolean;
|
|
139
|
+
/**
|
|
140
|
+
* What the runtime prints when it exits at once (0.4.8): `noConversation`
|
|
141
|
+
* when a resume finds nothing to continue, `idInUse` when a new
|
|
142
|
+
* conversation's id is taken. Den then tries the other way, once.
|
|
143
|
+
*/
|
|
144
|
+
launchFailures?: { noConversation?: RegExp; idInUse?: RegExp };
|
|
133
145
|
/** Where permission prompts surface (Q-AG-2). */
|
|
134
146
|
approvals?: "terminal" | "hook";
|
|
135
147
|
/**
|
package/src/shared/den-config.ts
CHANGED
|
@@ -391,6 +391,11 @@ export interface UserConfig {
|
|
|
391
391
|
runtimes?: Record<string, { bin?: unknown } | null>;
|
|
392
392
|
/** More runtime catalog entries (AG-0.5, agents/catalog.ts). */
|
|
393
393
|
runtime_catalog?: unknown;
|
|
394
|
+
/**
|
|
395
|
+
* AG-14.1b: forget stopped and exited agents after this long: never
|
|
396
|
+
* (the default), 1d or 7d. Interrupted agents are never forgotten.
|
|
397
|
+
*/
|
|
398
|
+
remove_stopped_after?: unknown;
|
|
394
399
|
};
|
|
395
400
|
/**
|
|
396
401
|
* The web server's per-machine keys (src/shared/web-session.ts
|
|
@@ -465,6 +470,29 @@ export function configuredRuntimeCatalog(
|
|
|
465
470
|
);
|
|
466
471
|
}
|
|
467
472
|
|
|
473
|
+
/** The values `agents.remove_stopped_after` takes, in days (AG-14.1b). */
|
|
474
|
+
export const REMOVE_STOPPED_AFTER: Readonly<Record<string, number>> = {
|
|
475
|
+
never: 0,
|
|
476
|
+
"1d": 1,
|
|
477
|
+
"7d": 7,
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* user.yml's `agents.remove_stopped_after`, in days (0: never, the
|
|
482
|
+
* default). Throws naming the key when it is none of never, 1d, 7d.
|
|
483
|
+
*/
|
|
484
|
+
export function configuredRemoveStoppedAfter(
|
|
485
|
+
read: () => UserConfig = readUserConfig,
|
|
486
|
+
): number {
|
|
487
|
+
const v = read().agents?.remove_stopped_after;
|
|
488
|
+
if (v === undefined || v === null) return 0;
|
|
489
|
+
if (typeof v === "string" && Object.hasOwn(REMOVE_STOPPED_AFTER, v))
|
|
490
|
+
return REMOVE_STOPPED_AFTER[v];
|
|
491
|
+
throw new Error(
|
|
492
|
+
`agents.remove_stopped_after (${path.basename(USER_CONFIG_PATH)}): must be never, 1d or 7d (got ${JSON.stringify(v)})`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
468
496
|
/**
|
|
469
497
|
* Set (a path) or clear (null) `agents.runtimes.<id>.bin` in user.yml,
|
|
470
498
|
* keeping everything else there. The caller checks the path first.
|
|
@@ -773,6 +801,8 @@ export const MCP_ACTIONS = [
|
|
|
773
801
|
"service_restart",
|
|
774
802
|
"workspace_sync",
|
|
775
803
|
"run_tests",
|
|
804
|
+
// AG-14.4: resume another agent of its workspace (or a granted one).
|
|
805
|
+
"agent_resume",
|
|
776
806
|
] as const;
|
|
777
807
|
export type McpAction = (typeof MCP_ACTIONS)[number];
|
|
778
808
|
|
|
@@ -58,6 +58,9 @@ export const KIND_TIERS: Readonly<Record<string, PluginTier>> = {
|
|
|
58
58
|
secrets: "server",
|
|
59
59
|
identity: "server",
|
|
60
60
|
"health-source": "server",
|
|
61
|
+
// den#6: reads a runtime's own transcripts on this machine, for tokens
|
|
62
|
+
// and outcomes per agent; never sends them anywhere.
|
|
63
|
+
usage: "server",
|
|
61
64
|
};
|
|
62
65
|
|
|
63
66
|
export interface PluginProvide {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Claude Code keeps its transcripts. No registration here, so the
|
|
3
|
+
* den-claude runtime can ask (conversationExists) without loading the
|
|
4
|
+
* usage sources (sources.ts registers one when it loads).
|
|
5
|
+
*/
|
|
6
|
+
import * as os from "os";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
|
|
9
|
+
/** Claude Code's transcripts: $CLAUDE_CONFIG_DIR/projects, else ~/.claude/projects. */
|
|
10
|
+
export function claudeProjectsDir(
|
|
11
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
12
|
+
): string {
|
|
13
|
+
const base =
|
|
14
|
+
env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), ".claude");
|
|
15
|
+
return path.join(base, "projects");
|
|
16
|
+
}
|