@monster-orange/den 0.4.7 → 0.4.9

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 (44) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +243 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/src/shared/agents/den-claude.ts +57 -3
  7. package/src/shared/agents/guardrails/hooks.json +23 -0
  8. package/src/shared/agents/guardrails/instructions.md +23 -0
  9. package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
  10. package/src/shared/agents/guardrails/stop-hook.ts +48 -2
  11. package/src/shared/agents/guardrails.ts +38 -7
  12. package/src/shared/agents/handoff.ts +118 -0
  13. package/src/shared/agents/personas.ts +11 -1
  14. package/src/shared/agents/records.ts +122 -1
  15. package/src/shared/agents/rotation.ts +362 -0
  16. package/src/shared/agents/runtime.ts +27 -0
  17. package/src/shared/den-config.ts +72 -1
  18. package/src/shared/git/gh-pr-state.ts +32 -0
  19. package/src/shared/plugins/manifest.ts +3 -0
  20. package/src/shared/usage/paths.ts +16 -0
  21. package/src/shared/usage/report.ts +377 -0
  22. package/src/shared/usage/sources.ts +127 -0
  23. package/src/shared/usage/transcript.ts +319 -0
  24. package/src/ui/cli/agent-ops.ts +678 -51
  25. package/src/ui/cli/agents-command.ts +192 -22
  26. package/src/ui/cli/index.ts +13 -1
  27. package/src/ui/cli/usage-command.ts +323 -0
  28. package/src/ui/mcp/api.ts +10 -1
  29. package/src/ui/mcp/server.ts +34 -0
  30. package/src/ui/mcp/tools.ts +50 -0
  31. package/src/ui/server/agent-resume-grant.ts +53 -0
  32. package/src/ui/server/agent-routes.ts +247 -9
  33. package/src/ui/server/audit.ts +68 -1
  34. package/src/ui/server/index.ts +16 -5
  35. package/src/ui/server/public/agents-page.js +400 -13
  36. package/src/ui/server/public/agents-section.js +17 -3
  37. package/src/ui/server/public/agents.html +44 -0
  38. package/src/ui/server/public/i18n/es.js +40 -0
  39. package/src/ui/server/public/i18n/ja.js +36 -0
  40. package/src/ui/server/public/shell.js +79 -0
  41. package/src/ui/server/request-guard.ts +21 -1
  42. package/src/ui/server/route-classes.ts +29 -0
  43. package/src/ui/server/session-routes.ts +60 -5
  44. package/src/ui/server/sudo-store.ts +36 -7
package/CHANGELOG.md CHANGED
@@ -4,6 +4,180 @@ 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.9] - 2026-11-07
8
+
9
+ A session ends when its pull request merges, or after 3 compactions. The
10
+ role then relaunches from a handoff, in a fresh conversation (den#4, the
11
+ session-rotation issue). Sessions that never end re-read huge contexts on
12
+ every call. This keeps each session to one objective.
13
+
14
+ ### Added
15
+
16
+ - **Den counts compactions.** A `SessionStart` hook (source `compact`),
17
+ alongside Den's other hooks for default-pack Claude Code agents, counts
18
+ each compaction of the agent's conversation once it happened. A
19
+ `/compact` that does nothing is not counted. The count is in
20
+ `<state dir>/agent-files/<id>/rotation.json`, and starts at 0 for each
21
+ new conversation.
22
+ - **Two triggers.** A rotation is due after `agents.rotate.after_compactions`
23
+ compactions (default 3), or once the agent's pull request merged (the
24
+ `prMerged` outcome from `usage agents`). A merge counts only when the
25
+ agent ran `gh pr merge` itself, it exited 0, and `gh pr view` then says
26
+ MERGED. `echo gh pr merge 1` or `gh pr merge --help` never counts.
27
+ - **The agent is told.** The compaction summary keeps the instruction, a
28
+ `SessionStart` hook adds it after the compaction, and the `Stop` hook
29
+ blocks once until the handoff is written. The shared instructions say
30
+ what to do: write `handoff.md` in the agent's files folder, then end the
31
+ turn.
32
+ - **The handoff check.** A handoff has five parts: Objective, State, Open
33
+ items, How to QA, Next step. Each must be there and filled in, and every
34
+ ID explained. `<cli> agents handoff-check <id|file|->` runs the check (on an
35
+ agent's own handoff.md, by its id) and names what is missing.
36
+ - **Relaunch from handoff.** A fresh conversation (a new conversation id),
37
+ with the same persona, workspace, folder and grants. Its first prompt is
38
+ the handoff. The handoff is then kept in `handoffs/`, and the record
39
+ notes the rotation. `<cli> agents relaunch <id>`, and
40
+ `POST /api/workspaces/<ws>/agents/<id>/relaunch`.
41
+ - **The Agents page.** A rotated agent reads "Ready to relaunch (3
42
+ compactions)" or "Ready to relaunch (PR merged)". Its buttons are
43
+ Relaunch from handoff (the primary one), Start fresh and Remove. With no
44
+ handoff, or one that fails the check, the row says so and keeps Resume
45
+ and Start fresh. A running agent shows "Handoff due" or "Ready to
46
+ relaunch" as a tag.
47
+ - **`agents.rotate` in the org config**: `after_compactions` (1 to 100,
48
+ default 3) and `standing` (default true). With `standing: false`,
49
+ standing personas such as the planner never rotate.
50
+ - **Usage shows rotation.** `usage agents` adds each agent's compactions
51
+ and rotations. `usage report` adds `long_session_context_k`,
52
+ `never_ending_reread_pct` and `rotations`.
53
+
54
+ ### Changed
55
+
56
+ - **Resume refuses a rotated conversation.** Once a rotation is due and the
57
+ handoff passes, `agents resume <id>` exits 1 with
58
+ `Relaunch from handoff: <cli> agents relaunch <id>`. The resume route
59
+ answers 409 with code `rotated`. Resume all lists it as not resumed.
60
+ - **A standing persona that rotated relaunches on its own** after a
61
+ restart, as it resumed on its own before.
62
+ - **The `Stop` hook and the new hooks take the agent's files folder** on
63
+ their command line (`--files <dir> --after <n>`).
64
+
65
+ ### Metrics (before → after)
66
+
67
+ - Peak tokens per call in a long session: 365–510k → <150k.
68
+ - Share of context re-reads from never-ending sessions: 59% → <15%.
69
+ - How it is measured: `usage report` on this machine.
70
+ `long_session_context_k` is the highest average tokens per call of any
71
+ session. `never_ending_reread_pct` is the share of cache re-reads from
72
+ sessions that compacted more than `agents.rotate.after_compactions`
73
+ times. Compare a week's report before and after.
74
+ - On the test fixture (15 sessions with 16 compactions each, and 127 that
75
+ end within 3): 399k → 139k per call, and 59% → 0% of re-reads. The same
76
+ calls re-read fewer tokens.
77
+
78
+ ### Migrations
79
+
80
+ - None needed. Rotation is on for Den's default pack, with 3 compactions.
81
+ To change the number, set `agents.rotate.after_compactions`. To keep
82
+ standing personas from rotating, set `agents.rotate.standing: false`.
83
+ `rotate` is now a reserved key under `agents:`, so no agent can have the
84
+ id `rotate`. Agents started before 0.4.9 start counting at their next
85
+ start or resume.
86
+
87
+ ## [0.4.8] - 2026-11-07
88
+
89
+ Stopped agents stay in the list, and come back where they were (AG-14, the
90
+ stopped-agents slice). Den also counts what each agent used and what it
91
+ got done, read on this machine only (den#6, the agent-usage issue).
92
+
93
+ ### Added
94
+
95
+ - **Stopped agents stay listed.** They sit below the running ones, each
96
+ with why it stopped: stopped by you, exited (with its code), or
97
+ interrupted by a restart. Each row has Resume, Start fresh and Remove.
98
+ - **Resume is the same conversation.** Den runs the runtime's own resume
99
+ with the conversation id it saved (Claude Code: `--resume <id>`). The
100
+ agent comes back in its own folder, with its workspace and grants. A
101
+ runtime that can't resume (codex, gemini, grok), or an agent with no
102
+ saved id, offers Start fresh only and says why.
103
+ - **Remove in one click, with Undo.** The toast offers Undo for 10 seconds.
104
+ **Clear stopped** removes every stopped agent in view. A running agent
105
+ is never removed.
106
+ - **After a restart, one line.** "2 agents were interrupted when Acme
107
+ restarted · Resume all · Review". Standing personas, and personas with
108
+ `resume_on_restart: true`, resume on their own.
109
+ - **The CLI:** `agents list --stopped`, `agents resume --all-interrupted`,
110
+ `agents rm <id>` and `agents rm --stopped`.
111
+ - **`agent_resume`, an MCP action for orchestrators.** Off unless
112
+ `mcp.actions` names it. It resumes agents of its own workspace or a
113
+ granted one, never itself. The web server checks the same rule.
114
+ - **`usage agents` and `usage report`.** Each agent's tokens and outcome
115
+ (pull request opened or merged, tests green, wall-clock time) are read
116
+ from its transcripts and saved in its record, matched by the
117
+ conversation id. `usage report` writes a weekly summary, compared with
118
+ the last. Transcripts stay on the machine, and only counts are kept.
119
+ - **The `usage` plugin kind** (server tier) adds a transcript reader for
120
+ another runtime. Den ships one for Claude Code.
121
+
122
+ ### Changed
123
+
124
+ - **The audit trail survives a restart.** The last 200 actions are kept in
125
+ `<state dir>/audit.jsonl` (0600) and read back at start.
126
+ - **An agent after a restart says Interrupted**, not Exited. An agent that
127
+ exited says Exited (code n), even after the session host forgets it.
128
+ - **An agent record notes its grants**, its end and exit code, and its
129
+ usage and outcome.
130
+
131
+ ### Fixed
132
+
133
+ - **A resume never loses the conversation.** Stopping an agent without
134
+ typing, then resuming it, could fail for good: Den started a new
135
+ conversation under the same id, and Claude Code refused it as "already
136
+ in use". Now Den decides before it launches. For Claude Code it looks
137
+ for the conversation's transcript: if it is there, or someone typed in
138
+ the last session, it resumes with `--resume <id>`; if not, it starts
139
+ fresh under the same id, in the same folder and scope, and says "nothing
140
+ to resume yet; started fresh". Every launch also marks the record
141
+ `launched: true`, for runtimes Den can't check this way. If Claude Code
142
+ still says there is no conversation (Den now reads past its trailing
143
+ terminal codes), Den starts fresh once. If a
144
+ fresh start says the id is in use, Den resumes once instead. If both
145
+ fail, the error names the id and Start fresh (the route answers 502
146
+ `resume_failed`).
147
+ - **No "+ Start agent" for a workspace that doesn't exist** (`?ws=` with
148
+ an unknown name).
149
+ - **`agents stop` waits for the agent to end.** It returns once the
150
+ agent's session has ended, so `agents stop X && agents resume X` works.
151
+ It waits up to 5 seconds. Still running then, it prints
152
+ `X is still stopping` and exits 1. `--no-wait` returns at once, as
153
+ before. The stop route answers 200 with `stopped: true` once the session
154
+ has ended, or 202 with `stillStopping: true`.
155
+ - **The Agents page never shows All in place of its `?ws=`.** A workspace
156
+ that isn't active gets one line, "Workspace west isn't active · Switch
157
+ to west", and the list shows that workspace alone. Clear stopped acts on
158
+ exactly the workspaces listed. A name no workspace has gets the same
159
+ line and an empty list.
160
+ - **Den's sudo is `/usr/bin/sudo`.** A `sudo` placed earlier on `PATH` no
161
+ longer gets the admin password. Den looks on `PATH` only when there is no
162
+ `/usr/bin/sudo`. `DEN_SYSTEM_SUDO` names another absolute path.
163
+ - **`mcp` refuses an argument it doesn't take.** A typo such as
164
+ `--worksapce` exits 2 with the usage, instead of starting an unscoped
165
+ server.
166
+
167
+ ### Metrics (before → after)
168
+
169
+ - Share of agent sessions with attributed tokens and outcome: ~0 → ≥90%.
170
+ On the test fixture (11 agents): 0% → 91%. The one left out runs on
171
+ codex, which no usage reader covers yet.
172
+
173
+ ### Migrations
174
+
175
+ - None needed. To remove stopped agents on their own, set
176
+ `agents.remove_stopped_after` in `config/local/user.yml` to `1d` or `7d`
177
+ (the default is `never`). Interrupted agents are never removed this way.
178
+ To let an orchestrator resume agents, add `agent_resume` to
179
+ `mcp.actions`.
180
+
7
181
  ## [0.4.7] - 2026-11-07
8
182
 
9
183
  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.7/hero-dark.png">
14
- <img src="docs/images/0.4.7/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">
13
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.9/hero-dark.png">
14
+ <img src="docs/images/0.4.9/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.7/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">
41
+ <img src="docs/images/0.4.9/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.7/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.7/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.7/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.7/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">
71
+ <img src="docs/images/0.4.9/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.9/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.9/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.9/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
@@ -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). Each row has its
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:
@@ -2302,6 +2401,11 @@ Den never writes your `~/.claude`; your own hooks still run.
2302
2401
  going (Claude Code's `decision: "block"`). It never blocks twice in a
2303
2402
  row (`stop_hook_active`), so it can't loop. Den adds no `SubagentStop` hook: a subagent reports to its parent
2304
2403
  agent, not to a person.
2404
+ - `PreCompact`, and `SessionStart` after a compaction or on resume
2405
+ (`rotation-hook.ts`), count the conversation's compactions (on
2406
+ `SessionStart` after a compaction only) and ask for a handoff once a
2407
+ rotation is due. See Session rotation, below. While one is
2408
+ due, the `Stop` hook also blocks once until `handoff.md` passes.
2305
2409
 
2306
2410
  **The hand-back template**, which the check enforces:
2307
2411
 
@@ -2355,6 +2459,108 @@ A persona of yours that replaces a default one (same id) takes your
2355
2459
  pack's setting, not Den's pack's. Anything but `true` or `false`, or an
2356
2460
  unknown key, is an error naming the key.
2357
2461
 
2462
+ ### Session rotation: one session per objective
2463
+
2464
+ A session that never ends re-reads its whole context on every call. So a
2465
+ session ends when its pull request merges, or after a few compactions. Its
2466
+ role then relaunches in a fresh conversation, from a handoff the session
2467
+ wrote. This is rule R2 (one session per objective) of the agent-loop
2468
+ report. It works on den-claude for personas with Den's hooks on.
2469
+
2470
+ **How it works.**
2471
+
2472
+ 1. Den's hooks count compactions. A `SessionStart` hook with source
2473
+ `compact` (`guardrails/rotation-hook.ts`) adds one to
2474
+ `<stateDir>/agent-files/<id>/rotation.json` after every compaction,
2475
+ manual or automatic. It fires only once a compaction happened.
2476
+ `PreCompact` does not count: it also runs for a `/compact` that does
2477
+ nothing ("Not enough messages to compact"). The count belongs to one
2478
+ conversation, and starts at 0 for a new one.
2479
+ 2. A trigger fires. Either the count reaches
2480
+ `agents.rotate.after_compactions` (default 3), or the agent's pull
2481
+ request merged (the `prMerged` outcome that `<cli> usage agents` saves).
2482
+ A merge is never taken from text. The agent must have run `gh pr merge`
2483
+ as a command (after any `cd` or `VAR=value` prefix; not `--help`, not
2484
+ `echo gh pr merge`), and it must have exited 0. Then `usage` asks
2485
+ `gh pr view <ref> --json state` in the agent's folder, and only
2486
+ `MERGED` counts. With no gh, or on any error, it is not marked. That
2487
+ query is the one thing `usage` asks outside this machine; it says so
2488
+ when it does.
2489
+ 3. Den tells the agent. The `PreCompact` hook's output asks the compaction
2490
+ summary to keep the instruction. A `SessionStart` hook (after a
2491
+ compaction, or on resume) adds it to the context. The `Stop` hook blocks
2492
+ the turn once while the handoff is missing or fails the check. The
2493
+ instruction: write `handoff.md` in the agent's files folder, then end
2494
+ the turn.
2495
+ 4. Den relaunches the role. **Relaunch from handoff** starts a fresh
2496
+ conversation (a new conversation id) with the same persona, workspace,
2497
+ folder and grants. Its first prompt is the handoff. The handoff moves to
2498
+ `agent-files/<id>/handoffs/`, and the record notes the rotation.
2499
+
2500
+ **The handoff** has five parts, each a heading, each filled in. Write it
2501
+ plainly, and explain every ID, as in a hand-back. It must stay under 20,000
2502
+ characters, since it becomes a first prompt.
2503
+
2504
+ ```markdown
2505
+ ## Objective
2506
+
2507
+ <what the work is for>
2508
+
2509
+ ## State
2510
+
2511
+ <what is done, and where it is: branch, pull request, files>
2512
+
2513
+ ## Open items
2514
+
2515
+ <what is left; or none>
2516
+
2517
+ ## How to QA
2518
+
2519
+ <the steps to see it working>
2520
+
2521
+ ## Next step
2522
+
2523
+ <the one thing to do first>
2524
+ ```
2525
+
2526
+ `<cli> agents handoff-check <id|file|-> [--json]` runs the check, on an
2527
+ agent's `handoff.md` (by its id), a file, or stdin: exit 0 when
2528
+ it passes, 1 with each missing or empty part and each unexplained ID. A
2529
+ part can also be a line such as `Next step: open the PR`.
2530
+
2531
+ **Resume after a rotation.** Once a rotation is due and `handoff.md`
2532
+ passes, Den declines a plain Resume of that conversation:
2533
+
2534
+ - The Agents page shows the row as "Ready to relaunch (3 compactions)" or
2535
+ "Ready to relaunch (PR merged)". Its buttons are Relaunch from handoff
2536
+ (the primary one), Start fresh and Remove.
2537
+ - `<cli> agents resume <id>` refuses (exit 1) and prints
2538
+ `Relaunch from handoff: <cli> agents relaunch <id>`. The resume route
2539
+ answers 409 with code `rotated`.
2540
+ - `<cli> agents relaunch <id> [-w <ws>]` and
2541
+ `POST /api/workspaces/<ws>/agents/<id>/relaunch` relaunch it. A running
2542
+ rotated agent is stopped first.
2543
+ - With `handoff.md` missing or failing the check, the row says so, and
2544
+ Resume and Start fresh stay. Resume the agent to write or fix it.
2545
+ Relaunch says what is wrong (409 `no_handoff`) and names Start fresh.
2546
+ - After a restart, a standing persona that rotated relaunches on its own,
2547
+ as a standing persona resumes on its own.
2548
+
2549
+ **Settings**, in the org config:
2550
+
2551
+ ```yaml
2552
+ agents:
2553
+ rotate:
2554
+ after_compactions: 3 # 1 to 100; the default is 3
2555
+ standing: true # standing personas rotate too (the default)
2556
+ ```
2557
+
2558
+ `standing` is for standing personas, such as the planner and the
2559
+ orchestrator. With `true` (the default), they rotate like the rest: only
2560
+ their context resets, and they keep their id and workspace. With `false`,
2561
+ they never rotate. A setting takes effect at the agent's next start or
2562
+ relaunch. An unknown key, or a bad value, is an error naming the key.
2563
+
2358
2564
  ### Runtimes
2359
2565
 
2360
2566
  `<cli> doctor` lists the runtime kind. Den knows the runtimes in its
@@ -2547,6 +2753,42 @@ Only the boolean `true` counts. With it, and a Sentry DSN in
2547
2753
  also sent, best effort. A DSN on its own never sends. `CI=true` and
2548
2754
  `DEV_METRICS_DISABLED=1` stop sending even when opted in.
2549
2755
 
2756
+ ## Agent usage: tokens and outcomes, read locally
2757
+
2758
+ `<cli> usage agents [-w <ws>] [--json]` reads each agent's transcripts on
2759
+ this machine and saves its tokens and outcome in its record: the pull
2760
+ request it opened, whether it merged, whether its last test run passed,
2761
+ and its wall-clock time. It matches transcripts to agents by the
2762
+ conversation id Den saved at start. It prints the share of agents with
2763
+ both. Transcripts never leave the machine, and their text is never kept:
2764
+ only counts. "Whether it merged" is checked: when an agent ran
2765
+ `gh pr merge`, `usage` asks `gh pr view <ref> --json state` to confirm it.
2766
+ That query, the pull request's number or URL, is the only thing it asks
2767
+ outside, and the output says when it asked.
2768
+
2769
+ `<cli> usage report [--days 7] [--json]` writes a weekly summary to
2770
+ `<state dir>/usage/<date>.json` and `.md`, compared with the last one:
2771
+ calls, cache re-reads, polls, sleeps, compactions, corrections and the
2772
+ attributed share.
2773
+
2774
+ For session rotation, `usage agents` also shows each agent's compactions
2775
+ (in its current conversation) and rotations (how many times Den relaunched
2776
+ it from a handoff). A merged pull request makes the agent's rotation due.
2777
+ `usage report` adds three numbers:
2778
+
2779
+ - `long_session_context_k`: the highest average tokens per call of any
2780
+ session in the window, in thousands. A call's tokens are its input,
2781
+ cache reads and cache writes.
2782
+ - `never_ending_reread_pct`: the share of cache re-reads that came from
2783
+ never-ending sessions. A never-ending session compacted more often than
2784
+ `agents.rotate.after_compactions`.
2785
+ - `rotations`: relaunches from a handoff, over the agents listed.
2786
+
2787
+ Den reads Claude Code's transcripts (`$CLAUDE_CONFIG_DIR/projects`, else
2788
+ `~/.claude/projects`). A plugin can add a reader for another runtime with
2789
+ the `usage` kind (server tier). A runtime with no reader shows "no usage
2790
+ source reads <runtime>".
2791
+
2550
2792
  ## Pinning and upgrading
2551
2793
 
2552
2794
  `package.json` pins an exact Den version (`create` writes the version that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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": {
@@ -21,6 +21,11 @@
21
21
  * alongside any hooks the user has; Den never writes ~/.claude.
22
22
  * - Each launch picks a fresh conversation id (--session-id); resume
23
23
  * continues it with --resume <id>, in the same cwd, with the same MCP.
24
+ * A relaunch from handoff (den#4) is a launch whose first prompt, the
25
+ * last argument, is the handoff.
26
+ * - Session rotation (den#4): the hooks include PreCompact and
27
+ * SessionStart (rotation-hook.ts) with the agent's files dir and its
28
+ * limit (LaunchInput rotateAfter), so its compactions are counted there.
24
29
  * - Permission prompts stay in the terminal (approvals: "terminal"):
25
30
  * --permission-mode default, unless the persona's `permission_mode` sets
26
31
  * one of Claude Code's modes; bypassPermissions warns at every start. The
@@ -45,7 +50,8 @@ import * as fs from "fs";
45
50
  import * as path from "path";
46
51
  import { configuredRuntimeBin } from "../den-config.js";
47
52
  import { rolePrompt } from "./personas.js";
48
- import { guardrailHooks } from "./guardrails.js";
53
+ import { GUARDRAILS_DIR, guardrailHooks, hookCommands } from "./guardrails.js";
54
+ import { DEFAULT_ROTATE } from "./rotation.js";
49
55
  import {
50
56
  LaunchError,
51
57
  mcpConfigJson,
@@ -59,6 +65,7 @@ import {
59
65
  import { builtinEntry } from "./catalog.js";
60
66
  import { catalogFind } from "./catalog-runtime.js";
61
67
  import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
68
+ import { claudeProjectsDir } from "../usage/paths.js";
62
69
 
63
70
  export const DEN_CLAUDE_ID = "den-claude";
64
71
  const BIN = "claude";
@@ -226,7 +233,16 @@ function common(input: LaunchInput): {
226
233
  } {
227
234
  const file = mcpConfigPath(input);
228
235
  const mode = permissionMode(input);
229
- const hooks = input.persona.guardrails?.hooks ? guardrailHooks() : null;
236
+ // den#4: the rotation hooks count this agent's compactions in its files dir.
237
+ const hooks = input.persona.guardrails?.hooks
238
+ ? guardrailHooks(
239
+ GUARDRAILS_DIR,
240
+ hookCommands(GUARDRAILS_DIR, process.execPath, {
241
+ files: input.filesDir,
242
+ after: input.rotateAfter ?? DEFAULT_ROTATE.afterCompactions,
243
+ }),
244
+ )
245
+ : null;
230
246
  const settings = input.stateDir || hooks ? settingsPath(input) : null;
231
247
  return {
232
248
  argv: [
@@ -286,20 +302,40 @@ function binFor(input: LaunchInput): string {
286
302
  return d.path;
287
303
  }
288
304
 
305
+ /**
306
+ * The first prompt as claude's last argument, or nothing. An agent wrote
307
+ * the text (its handoff), so it is never allowed to start with "-": that
308
+ * would be read as a flag. One that does gets a word in front.
309
+ */
310
+ export function firstPromptArg(text: string | undefined): string[] {
311
+ const t = text?.trim();
312
+ if (!t) return [];
313
+ return [t.startsWith("-") ? `Prompt: ${t}` : t];
314
+ }
315
+
289
316
  export const denClaude: Runtime = {
290
317
  id: DEN_CLAUDE_ID,
291
318
  label: DEN_CLAUDE_CATALOG.name,
292
319
  vendor: DEN_CLAUDE_CATALOG.vendor,
293
320
  catalog: DEN_CLAUDE_CATALOG,
294
321
  denTools: true,
322
+ firstPrompt: true,
295
323
  find: DEN_CLAUDE_FIND,
296
324
  detect,
297
325
  launch(input): LaunchOutput {
298
326
  const bin = binFor(input);
299
327
  const id = input.resumeToken ?? randomUUID();
300
328
  const c = common(input);
329
+ // A relaunch from handoff (den#4): the handoff is the first prompt,
330
+ // the last argument (firstPromptArg: never read as a flag).
301
331
  return {
302
- argv: [bin, "--session-id", id, ...c.argv],
332
+ argv: [
333
+ bin,
334
+ "--session-id",
335
+ id,
336
+ ...c.argv,
337
+ ...firstPromptArg(input.firstPrompt),
338
+ ],
303
339
  env: {},
304
340
  files: c.files,
305
341
  resumeToken: id,
@@ -317,6 +353,24 @@ export const denClaude: Runtime = {
317
353
  warnings: c.warnings,
318
354
  };
319
355
  },
356
+ // Its transcript, <token>.jsonl in any project under its projects dir.
357
+ conversationExists(token, env = process.env) {
358
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(token)) return false;
359
+ const root = claudeProjectsDir(env);
360
+ let projects: string[] = [];
361
+ try {
362
+ projects = fs.readdirSync(root);
363
+ } catch {
364
+ return false;
365
+ }
366
+ return projects.some((p) =>
367
+ fs.existsSync(path.join(root, p, `${token}.jsonl`)),
368
+ );
369
+ },
370
+ launchFailures: {
371
+ noConversation: /no conversation found/i,
372
+ idInUse: /already in use/i,
373
+ },
320
374
  approvals: "terminal",
321
375
  envPassthrough: ["CLAUDE_CONFIG_DIR"],
322
376
  // Claude Code's session plumbing: the session id, its messaging socket and