@monster-orange/den 0.4.15 → 0.4.17

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 (37) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +137 -11
  4. package/package.json +1 -1
  5. package/src/shared/agents/conversations.ts +122 -4
  6. package/src/shared/agents/den-claude.ts +13 -1
  7. package/src/shared/agents/records.ts +19 -0
  8. package/src/shared/agents/runtime.ts +5 -0
  9. package/src/shared/github-app.ts +7 -0
  10. package/src/ui/cli/agent-ops.ts +304 -1
  11. package/src/ui/cli/agents-command.ts +71 -7
  12. package/src/ui/server/agent-routes.ts +92 -2
  13. package/src/ui/server/check-pool.ts +72 -0
  14. package/src/ui/server/github-routes.ts +1 -1
  15. package/src/ui/server/index.ts +127 -15
  16. package/src/ui/server/public/agents-page.js +358 -38
  17. package/src/ui/server/public/agents.html +10 -0
  18. package/src/ui/server/public/environments.html +223 -140
  19. package/src/ui/server/public/error-page.js +70 -15
  20. package/src/ui/server/public/error.html +18 -0
  21. package/src/ui/server/public/graph.css +18 -0
  22. package/src/ui/server/public/graph.js +23 -6
  23. package/src/ui/server/public/i18n/es.js +47 -5
  24. package/src/ui/server/public/i18n/ja.js +47 -5
  25. package/src/ui/server/public/index.html +6 -0
  26. package/src/ui/server/public/local-dev.html +188 -25
  27. package/src/ui/server/public/panel.js +1 -1
  28. package/src/ui/server/public/persona-ui.css +11 -0
  29. package/src/ui/server/public/release.html +19 -1
  30. package/src/ui/server/public/shell.js +187 -12
  31. package/src/ui/server/public/topbar.js +34 -1
  32. package/src/ui/server/public/view-scope.js +61 -0
  33. package/src/ui/server/public/workspace-picker.js +2 -0
  34. package/src/ui/server/route-classes.ts +8 -0
  35. package/src/ui/server/run-history.ts +181 -0
  36. package/src/ui/server/session-routes.ts +97 -17
  37. package/src/ui/server/ui-assets.ts +2 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,131 @@ 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.17] - 2026-11-08
8
+
9
+ ### Fixed
10
+
11
+ - **An agent's workspace is a link.** The agent pane's header and the
12
+ workspace's heading in the Agents list link to the Workspaces page
13
+ focused on it (`?ws=`). ⌘K has "Go to workspace <ws>" for every
14
+ workspace, the front agent's first on the Agents page, found by the
15
+ workspace's name or any of its agents'.
16
+ - **"Last test run" is the server's.** The Workspaces page's "Last run"
17
+ column is now "Last test run", read from the server's run history
18
+ (`<state>/run-history.jsonl`, `GET /api/runs/last`). It shows runs from
19
+ every browser and survives a restart. The service's own state ("up 2h")
20
+ is shown apart from it.
21
+ - **One org rule.** Environments and Releases have no org picker of their
22
+ own. Both follow the top bar's View and say so with the same note. A
23
+ page shows its own org picker only when it filters by org.
24
+ - **Environments never waits on a slow host.** Every row shows at once,
25
+ pulsing "checking", and each result streams into its row. The server
26
+ runs at most 12 checks at once, each capped at 10 s; one that runs out
27
+ of time says "timed out after 10 s". Re-check works while checks run.
28
+ - **An agent in an inactive workspace opens its terminal.** The terminal
29
+ route checks the person and the agent, not the workspace's active
30
+ flag. The pane says "Sign in" only when you're signed out; any other
31
+ refusal gives its real reason.
32
+ - **Menus are never clipped.** Every ⋯ menu in the shell (Workspaces
33
+ rows and the workspace menu, the top bar's menus) now opens in the
34
+ browser's top layer through one shared helper, so a scrolling table or
35
+ card no longer cuts it off. It stays beside its button, flips up near
36
+ the bottom of the window, and closes on an outside click as before.
37
+ - **The Tests module picker is never clipped.** It uses the same
38
+ top-layer menu.
39
+ - **Visit ↗ opens a service.** Workspaces (beside Start, Stop, Restart
40
+ and Logs, and as a link on the service's name), Services and Graph
41
+ cards open the service's edge URL when it is routed, else its local
42
+ URL, in a new tab. A service with no URL has no Visit. Below 1600 px
43
+ wide the Workspaces button shows only "↗". The detail panel's "Open ↗"
44
+ is now "Visit ↗".
45
+ - **Favourite a service from Workspaces and Graph.** Workspaces rows and
46
+ Graph cards (top-right) have the ☆ star. It uses the same list as
47
+ Services, so a favourite set on one page shows on all of them.
48
+ - **Change persona on an agent that was never prompted** said "same
49
+ conversation", though there was nothing to resume: it starts fresh under
50
+ the same conversation id, as Resume does. The CLI, the API (`note`, with
51
+ `fresh: true`), the toast and the pane now say "nothing to resume yet;
52
+ started fresh (same conversation id, folder and scope)".
53
+ - **Last test run counts a repo's own suites.** A run of the suites pillar
54
+ (a repo's `tests:`, such as `unit`) and a repo's performance modules
55
+ now name the repos they test, so a repo with only `tests: unit` fills
56
+ its Last test run. Den's own integration tests name none.
57
+ - **The Workspaces table keeps its height in a short window.** Under about
58
+ 620 px tall it shrank to nothing and its rows and ⋯ were out of reach.
59
+ It keeps at least 200 px and scrolls.
60
+
61
+ ### Migrations
62
+
63
+ None.
64
+
65
+ ## [0.4.16] - 2026-11-08
66
+
67
+ ### Added
68
+
69
+ - **Change an agent's persona (AG-18).** **Change persona…** in the
70
+ agent pane's ⋯ menu (running or stopped), and `<cli> agents persona
71
+ <id> <persona> [--model <m>] [--now | --after-turn]`. Den stops the
72
+ session and resumes the same conversation with the new persona's role
73
+ prompt, permission mode and allowed commands; the history, id, name,
74
+ workspace, folder, tab and the model it runs stay. The dialog marks the
75
+ current persona and lists what changes. Mid-turn it never stops a turn
76
+ unasked: "It's working. Change after this turn, or stop it now?" (409
77
+ `busy`); After this turn waits for the turn to end, Stop it now stops
78
+ it. The wait lives in the server's memory: if Den restarts first, the
79
+ change is dropped, the record no longer marks it pending, and the audit
80
+ trail says "persona change cancelled: Den restarted". A person's only: an agent's token is refused (403), and there is no
81
+ MCP tool. Only a runtime that can resume offers it. The record keeps
82
+ `personaChanges`, and the audit trail records each change. Route `POST
83
+ /api/workspaces/<ws>/agents/<id>/persona`.
84
+
85
+ ### Changed
86
+
87
+ - **Bring in keeps the conversation's model, and says what the persona
88
+ changes (AG-17.1c).** It used to switch the conversation to the
89
+ persona's model, and drop MCP servers the conversation had outside Den,
90
+ without a word. Now the model stays the one its last answer came from
91
+ ("Keeps <model> from the conversation"; Advanced or `--model` picks
92
+ another), and a short list under the persona says "From now on it works
93
+ as <persona>: its role, its permissions (<mode>), Den's tools and
94
+ guards. Its history stays.", plus "It won't have: <servers>." for MCP
95
+ servers seen in its transcript that Den won't load. The persona of the
96
+ latest agent that worked in its folder is preselected (`suggest`), never
97
+ submitted. The list adds `model`, `lost` and `suggest`; the import takes
98
+ `model` and `version`.
99
+ - **The signed-out page shows Sign in with GitHub once the owner is
100
+ linked, with no reload.** With no owner yet it watches the session
101
+ probe (which now says `github.state` to a signed-out browser), and step 1
102
+ turns done and the button appears as soon as `web owner link` finishes.
103
+
104
+ - **The signed-out page explains Sign in with GitHub (SEC-2a-UX).** It
105
+ shows the same three steps whether or not an owner is linked: on the
106
+ machine Den runs on, run `<cli> web owner link` (or use Settings ›
107
+ Access there); enter the code it shows at github.com/login/device and
108
+ approve; come back and press Sign in with GitHub with the same account.
109
+ Once an owner is linked, step 1 shows as done ("Owner: @login") and the
110
+ button follows the steps. Below them: only the owner's GitHub account
111
+ can sign in this way, anyone else uses Approve this computer, and Learn
112
+ more opens docs/distributions.md, "Signing in with GitHub". English,
113
+ Spanish and Japanese.
114
+ - Another GitHub account is refused with "@login isn't the owner of this
115
+ Den. Only the owner's account can use Sign in with GitHub. Use Approve
116
+ this computer instead." It no longer says to ask the owner to add you:
117
+ a Den has one owner, and no one to add.
118
+
119
+ ### Fixed
120
+
121
+ - An open agent pane stayed on the old session after Change persona, Resume
122
+ or a rotation, showing "[the session exited (143)]" while the list showed
123
+ the new one running. A pane now follows its agent: it moves to the new
124
+ session in the same slot, keeps its scrollback, and says "Switched to
125
+ <persona>". After a session ends the page looks again every second for
126
+ 15 s, so the switch shows within about 2 s.
127
+
128
+ ### Migrations
129
+
130
+ - None.
131
+
7
132
  ## [0.4.15] - 2026-11-07
8
133
 
9
134
  ### Added
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.15/hero-dark.png">
14
- <img src="docs/images/0.4.15/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.17/hero-dark.png">
14
+ <img src="docs/images/0.4.17/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.15/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.17/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.15/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.15/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.15/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.15/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.17/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.17/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.17/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.17/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
@@ -355,6 +355,19 @@ is never mistaken for another repo's service. An alias that names more than
355
355
  one service is an error that lists them (`"worker" is ambiguous in workspace
356
356
  "default": one/worker, two/worker`); name one of them.
357
357
 
358
+ ## Opening a service
359
+
360
+ A service with a URL gets **Visit ↗**, which opens it in a new tab. It
361
+ shows on Workspaces (beside Start, Stop, Restart and Logs, and as a link
362
+ on the service's name), on Services and on Graph cards. Den picks the
363
+ URL in this order: the service's `urls.<env>`; for `local`, its edge
364
+ route when the edge serves one; else the origin of its HTTP health
365
+ check. A service with none of these has no Visit.
366
+
367
+ The ☆ star on Workspaces rows, Services and Graph cards marks a
368
+ favourite. It is one list, saved in `shell.pins`, so a star set on one
369
+ page shows on all of them.
370
+
358
371
  ## Health checks
359
372
 
360
373
  A service declares its health per environment under `health`, in one of
@@ -432,6 +445,36 @@ that a container existed, running or not).
432
445
  `service`, with both forms at once, or with a key of another type (`cmd`,
433
446
  `url`), and says which.
434
447
 
448
+ ### The Environments page
449
+
450
+ The Environments page lists every service's row at once, each cell pulsing
451
+ "checking" until its result arrives. The results stream in one at a time
452
+ (`GET /api/health/stream`, one event per check), so a slow host holds up
453
+ its own cell only. The server runs at most 12 checks at once, and no check
454
+ takes longer than 10 s. A check that runs out of time says "timed out after
455
+ 10 s" in its row. Re-check and the page's other controls work while the
456
+ checks run.
457
+
458
+ The page has no org picker of its own. It follows the top bar's View (all
459
+ active workspaces, or one), asks for that workspace only, and says so:
460
+ "Shows workspace south, as the top bar's View. Change it there." The
461
+ Release page follows the same rule with the same note. A page shows its own
462
+ org picker only when it filters by org.
463
+
464
+ ### Last test run
465
+
466
+ The Workspaces page's "Last test run" column shows each repo's latest test
467
+ run: its result (✓ or ✗), how long ago, and the pillar and environment on
468
+ hover. It reads the server's run history, so it shows runs started from any
469
+ browser or tab, and it survives a restart. The service's own state ("up 2h")
470
+ is shown apart from it, in the Health cell.
471
+
472
+ The history is `<state>/run-history.jsonl` (mode 0600): one line when a run
473
+ starts and one when it ends. Past 2000 lines it keeps the newest 1000.
474
+ `GET /api/runs/last` (read only) gives `{ repos: { <repo>: { id, pillar,
475
+ env, repos, startedAt, endedAt, exitCode, running } } }`. A run a restart
476
+ cut off has no end and is not shown.
477
+
435
478
  ## Placement
436
479
 
437
480
  Each service says where it runs, per environment: `local` (on this machine,
@@ -1687,14 +1730,31 @@ the account's id and login, never a token, and no other account gets in.
1687
1730
  `web owner unlink`. Settings › Access does the same in the browser
1688
1731
  (Link your GitHub account, Unlink): shell-grade actions, for a signed-in
1689
1732
  browser and never an agent's token.
1690
- - **Sign in with GitHub** on the signed-out page shows a code and
1691
- `github.com/login/device`. The server runs the flow, and when the owner
1692
- approves it sets the same never-expiring session cookie a one-time link
1693
- sets, and the page reloads signed in. Another account sees "@<login>
1694
- isn't allowed on this Den". The token is thrown away either way. At most
1695
- three sign-ins wait for GitHub at once; a code expires as GitHub says
1696
- (15 minutes). With no owner linked the page says to run
1697
- `web owner link`.
1733
+ - **Sign in with GitHub** on the signed-out page shows the same three
1734
+ steps whether or not an owner is linked:
1735
+ 1. On the machine Den runs on, run `web owner link`, or use
1736
+ Settings › Access there.
1737
+ 2. It shows a code. Enter it at `github.com/login/device` and approve.
1738
+ 3. Come back to the signed-out page and press **Sign in with GitHub**,
1739
+ with the same GitHub account.
1740
+
1741
+ Once an owner is linked, step 1 shows as done ("Owner: @<login>") and
1742
+ the button follows the steps; before that there is no button. Below the
1743
+ steps the page says only the owner's GitHub account can sign in this
1744
+ way, and that anyone else uses **Approve this computer**; **Learn more**
1745
+ opens this section. With no owner yet, the page watches (the session
1746
+ probe, `GET /api/session`, says `github.state` to a signed-out
1747
+ browser): once the owner is linked on the machine, step 1 shows done
1748
+ and the button appears, with no reload.
1749
+
1750
+ - Pressing the button shows a code and `github.com/login/device`. The
1751
+ server runs the flow, and when the owner approves it sets the same
1752
+ never-expiring session cookie a one-time link sets, and the page reloads
1753
+ signed in. Another account sees "@<login> isn't the owner of this Den.
1754
+ Only the owner's account can use Sign in with GitHub. Use Approve this
1755
+ computer instead." Den has one owner, so there is no one to add. The
1756
+ token is thrown away either way. At most three sign-ins wait for GitHub
1757
+ at once; a code expires as GitHub says (15 minutes).
1698
1758
  - **Settings › Access** lists the linked devices (the browser, how it
1699
1759
  signed in, the client address when the edge vouched for one, created and
1700
1760
  last seen; "This device" on yours), each with **Revoke**, which ends its session at once (its
@@ -2180,7 +2240,8 @@ server scoped to its workspace.
2180
2240
  <cli> agents stop <id> [-w <ws>]
2181
2241
  <cli> agents resume <id> [-w <ws>]
2182
2242
  <cli> agents import -w <ws> [--runtime <r>] [--json] # conversations to bring in
2183
- <cli> agents import -w <ws> --session <id> --persona <p> # bring one in
2243
+ <cli> agents import -w <ws> --session <id> --persona <p> [--model <m>] # bring one in
2244
+ <cli> agents persona <id> <persona> [-w <ws>] [--model <m>] [--now | --after-turn] # change its persona
2184
2245
  <cli> agents personas [--json]
2185
2246
  <cli> agents doctor [-w <ws>] [--json] # can each runtime run here, and why not
2186
2247
  <cli> agents handback-check <file|-> # is a hand-back complete and plain (see Guardrails)
@@ -2251,6 +2312,24 @@ import -w <ws>` lists them too.
2251
2312
  Den's token, MCP config, hooks, guards and audit trail apply. It is an
2252
2313
  agent like any other: stop, resume, rename. The record notes
2253
2314
  `broughtInAt`.
2315
+ - **What the persona changes.** The persona is the role it takes on from
2316
+ now on: its role prompt, its permission mode and allowed commands, and
2317
+ Den's tools and guards. The history stays. The dialog says so under the
2318
+ persona list: "From now on it works as <persona>: its role, its
2319
+ permissions (<mode>), Den's tools and guards. Its history stays."
2320
+ - **The model stays the conversation's own.** Den reads the model of its
2321
+ last answer (`message.model` on the last assistant record, in the ends
2322
+ it reads) and runs it on that: "Keeps <model> from the conversation".
2323
+ Advanced (or `--model`) picks another. With no model in the
2324
+ transcript, the persona's applies.
2325
+ - **The MCP servers it won't have.** An agent runs with Den's MCP only
2326
+ (`--strict-mcp-config`). When the transcript shows tool calls from other
2327
+ servers (`mcp__<server>__…`), the dialog names them: "It won't have:
2328
+ <servers>." The list's `lost` field has them.
2329
+ - **A suggested persona.** The list's `suggest` is the persona of the
2330
+ latest agent of the workspace that worked in the conversation's folder
2331
+ (or a folder inside it, or one containing it), else the workspace's
2332
+ latest agent's. The dialog preselects it; you confirm with Bring in.
2254
2333
  - **One writer only.** A conversation written in the last minute is taken
2255
2334
  to be still open in another terminal: "Still open in another terminal.
2256
2335
  Close it there first" (HTTP 409 `open_elsewhere`). A conversation an
@@ -2260,8 +2339,43 @@ import -w <ws>` lists them too.
2260
2339
  - Only den-claude can bring one in for now. Other runtimes say "<runtime>
2261
2340
  can't bring in a conversation yet".
2262
2341
  - Routes: `GET /api/workspaces/<ws>/conversations[?runtime=<r>]` and `POST
2263
- /api/workspaces/<ws>/conversations/<id>/import` `{ persona, runtime?, id?
2264
- }`. A person's only, unless MCP `agent_import` is opted in.
2342
+ /api/workspaces/<ws>/conversations/<id>/import` `{ persona, runtime?, id?,
2343
+ model?, version? }`. A person's only, unless MCP `agent_import` is opted
2344
+ in.
2345
+
2346
+ #### Change an agent's persona
2347
+
2348
+ **Change persona…** (the agent pane's ⋯ menu, running or stopped) and
2349
+ `agents persona <id> <persona>` switch an agent to another persona on the
2350
+ same conversation. Den stops its session and resumes the conversation with
2351
+ the new persona's role prompt, permission mode and allowed commands (the
2352
+ old persona's mode and commands go). The history stays, and so do the
2353
+ agent's id, name, workspace, folder, grants, tab and slot, and the model it
2354
+ runs unless Advanced (`--model`) picks another. The dialog lists the
2355
+ personas with the current one marked, and the same "From now on it works
2356
+ as…" line as Bring in.
2357
+
2358
+ - **Never mid-turn without asking.** While it works (den-claude: its
2359
+ transcript's last record is a tool call, or a prompt or tool result with
2360
+ no answer yet), the change is refused: "It's working. Change after this
2361
+ turn, or stop it now?" (HTTP 409 `busy`). **After this turn**
2362
+ (`--after-turn`, `when: "after-turn"`) waits for the turn to end, up to
2363
+ 30 minutes, and then switches (the API answers 202 `pending` at once;
2364
+ the record marks it `pendingPersona` while it waits). The wait lives in
2365
+ the server's memory: if Den restarts first, the change is dropped, the
2366
+ mark is cleared at start, and the audit trail records "persona change
2367
+ cancelled: Den restarted";
2368
+ **Stop it now** (`--now`, `when: "now"`) stops it and switches.
2369
+ - **A person's only.** An agent could widen its own permissions with it,
2370
+ so an agent's token is refused (403), the CLI refuses inside an agent's
2371
+ session, and there is no MCP tool.
2372
+ - Only a runtime that can resume offers it (`personaChange: true` on the
2373
+ agent's row); others can't.
2374
+ - The record keeps each change in `personaChanges` (from, to, when, and
2375
+ who), and the audit trail records "persona <id> in <ws>: <from> → <to>,
2376
+ by <who>".
2377
+ - Route: `POST /api/workspaces/<ws>/agents/<id>/persona` `{ persona, model?,
2378
+ version?, when? }`.
2265
2379
 
2266
2380
  ### On the Workspaces page
2267
2381
 
@@ -2481,6 +2595,18 @@ text frame `{"type":"resize","cols":c,"rows":r}` resizes, and the server
2481
2595
  sends `{"type":"session",…}` first and `{"type":"exit","exitCode":n}` when
2482
2596
  the session ends.
2483
2597
 
2598
+ The terminal route checks the person and the agent, not whether the
2599
+ agent's workspace is active: an agent in a workspace you've switched away
2600
+ from still opens. A `GET` of the io path (not a WebSocket) says why one
2601
+ would be refused: `401` when you're signed out (the pane then says "Sign
2602
+ in"), `404` with the reason when there is no such session here, and `426`
2603
+ when the upgrade would be allowed. The pane shows that reason, not "Sign
2604
+ in", for any refusal but the first.
2605
+
2606
+ The pane's header and the workspace's heading in the list link to the
2607
+ Workspaces page focused on that workspace (`/local-dev?ws=<ws>`), and ⌘K
2608
+ has "Go to workspace <ws>" for each workspace with agents.
2609
+
2484
2610
  ### Stopped agents: Resume, Start fresh, Remove
2485
2611
 
2486
2612
  An agent that stops stays in the list, below the running ones (AG-14, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,16 @@ export interface RuntimeConversation {
32
32
  lastAt: string;
33
33
  /** The folder it ran in. */
34
34
  cwd: string;
35
+ /**
36
+ * AG-17.1c: the model its last answer came from (the last assistant
37
+ * record's `message.model`), so Bring in keeps it. Absent when unknown.
38
+ */
39
+ model?: string;
40
+ /**
41
+ * AG-17.1c: the MCP servers its tools came from (`mcp__<server>__<tool>`
42
+ * tool calls in the ends read), sorted. Absent when none.
43
+ */
44
+ mcp?: string[];
35
45
  }
36
46
 
37
47
  /** Bytes read at each end of a transcript. */
@@ -114,22 +124,63 @@ function promptText(rec: Record<string, unknown>): string | null {
114
124
  return cleanName(text);
115
125
  }
116
126
 
117
- /** A transcript's cwd and title, from its two ends. */
127
+ /** A model name as a transcript records it: short, printable, not synthetic. */
128
+ const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
129
+ /** An MCP tool's name: `mcp__<server>__<tool>`. */
130
+ const MCP_TOOL_RE = /^mcp__([A-Za-z0-9_.-]+?)__[A-Za-z0-9_.-]+$/;
131
+
132
+ /** An assistant record's tool calls' names. */
133
+ function toolNames(rec: Record<string, unknown>): string[] {
134
+ const content = (rec.message as { content?: unknown } | undefined)?.content;
135
+ if (!Array.isArray(content)) return [];
136
+ return content.flatMap((c) => {
137
+ const b = c as { type?: unknown; name?: unknown };
138
+ return b?.type === "tool_use" && typeof b.name === "string" ? [b.name] : [];
139
+ });
140
+ }
141
+
142
+ /** A transcript's summary from its two ends (AG-17.1, AG-17.1c). */
143
+ export interface TranscriptSummary {
144
+ cwd: string | null;
145
+ title: string | null;
146
+ /** The last assistant record's model, or null. */
147
+ model: string | null;
148
+ /** The MCP servers its tool calls name, sorted. */
149
+ mcp: string[];
150
+ }
151
+
152
+ /** A transcript's cwd, title, last model and MCP servers, from its two ends. */
118
153
  export function transcriptSummary(
119
154
  file: string,
120
155
  bytes = ENDS_BYTES,
121
- ): { cwd: string | null; title: string | null } {
156
+ ): TranscriptSummary {
122
157
  const { head, tail } = readEnds(file, bytes);
123
158
  let cwd: string | null = null;
124
159
  let prompt: string | null = null;
125
160
  let title: string | null = null;
161
+ let model: string | null = null;
162
+ const mcp = new Set<string>();
126
163
  const titleIn = (line: string) => {
127
164
  if (!line.includes("custom-title")) return;
128
165
  const r = parse(line);
129
166
  if (r?.type === "custom-title") title = cleanName(r.customTitle) ?? title;
130
167
  };
168
+ // AG-17.1c: the last answer's model, and the MCP servers its tools came from.
169
+ const assistantIn = (line: string) => {
170
+ if (!line.includes('"assistant"')) return;
171
+ const r = parse(line);
172
+ if (r?.type !== "assistant") return;
173
+ const m = (r.message as { model?: unknown } | undefined)?.model;
174
+ if (typeof m === "string" && MODEL_RE.test(m) && m !== "<synthetic>")
175
+ model = m;
176
+ for (const n of toolNames(r)) {
177
+ const hit = MCP_TOOL_RE.exec(n);
178
+ if (hit) mcp.add(hit[1]);
179
+ }
180
+ };
131
181
  for (const line of head) {
132
182
  titleIn(line);
183
+ assistantIn(line);
133
184
  if (cwd && prompt) continue;
134
185
  if (!line.includes('"cwd"') && !line.includes('"user"')) continue;
135
186
  const r = parse(line);
@@ -138,8 +189,73 @@ export function transcriptSummary(
138
189
  cwd = r.cwd;
139
190
  prompt ??= promptText(r);
140
191
  }
141
- for (const line of tail) titleIn(line);
142
- return { cwd, title: title ?? prompt };
192
+ for (const line of tail) {
193
+ titleIn(line);
194
+ assistantIn(line);
195
+ }
196
+ return { cwd, title: title ?? prompt, model, mcp: [...mcp].sort() };
197
+ }
198
+
199
+ /** Written this recently, a turn whose last record is an answer is still going. */
200
+ export const TURN_QUIET_MS = 5_000;
201
+
202
+ /**
203
+ * AG-18: whether a conversation's turn is still going, from its tail: the
204
+ * last user or assistant record. An answer that ended (`end_turn`, a stop
205
+ * sequence, the token limit) is idle; a tool call, or a prompt or tool
206
+ * result with no answer yet, is busy; an answer with no stop reason is busy
207
+ * until the file has been quiet TURN_QUIET_MS. An interrupted turn is idle.
208
+ * No transcript: idle (nothing was asked yet).
209
+ */
210
+ export function transcriptBusy(
211
+ file: string | null,
212
+ now = Date.now(),
213
+ bytes = ENDS_BYTES,
214
+ ): boolean {
215
+ if (!file) return false;
216
+ let mtime: number;
217
+ try {
218
+ mtime = fs.statSync(file).mtimeMs;
219
+ } catch {
220
+ return false;
221
+ }
222
+ const { head, tail } = readEnds(file, bytes);
223
+ const lines = tail.length ? [...head, ...tail] : head;
224
+ for (let i = lines.length - 1; i >= 0; i--) {
225
+ const line = lines[i];
226
+ if (!line.includes('"user"') && !line.includes('"assistant"')) continue;
227
+ const r = parse(line);
228
+ if (!r || r.isSidechain === true || r.isMeta === true) continue;
229
+ if (r.type === "assistant") {
230
+ const msg = r.message as { stop_reason?: unknown } | undefined;
231
+ const stop = msg?.stop_reason;
232
+ if (stop === "tool_use" || toolNames(r).length) return true;
233
+ if (
234
+ stop === "end_turn" ||
235
+ stop === "stop_sequence" ||
236
+ stop === "max_tokens"
237
+ )
238
+ return false;
239
+ return now - mtime < TURN_QUIET_MS;
240
+ }
241
+ if (r.type === "user") {
242
+ const content = (r.message as { content?: unknown } | undefined)?.content;
243
+ const text =
244
+ typeof content === "string"
245
+ ? content
246
+ : Array.isArray(content)
247
+ ? ((
248
+ content.find(
249
+ (c) => (c as { type?: unknown })?.type === "text",
250
+ ) as { text?: unknown } | undefined
251
+ )?.text ?? "")
252
+ : "";
253
+ if (typeof text === "string" && text.startsWith("[Request interrupted"))
254
+ return false;
255
+ return true;
256
+ }
257
+ }
258
+ return false;
143
259
  }
144
260
 
145
261
  /** A folder's absolute forms: as given and its real path. */
@@ -236,6 +352,8 @@ export function claudeConversations(
236
352
  title: s.title,
237
353
  lastAt: new Date(f.mtimeMs).toISOString(),
238
354
  cwd,
355
+ ...(s.model ? { model: s.model } : {}),
356
+ ...(s.mcp.length ? { mcp: s.mcp } : {}),
239
357
  });
240
358
  }
241
359
  return out;
@@ -70,7 +70,11 @@ import { catalogFind } from "./catalog-runtime.js";
70
70
  import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
71
71
  import { claudeProjectsDir } from "../usage/paths.js";
72
72
  import { cleanName, transcriptTitle } from "./transcript-follow.js";
73
- import { claudeConversations, transcriptSummary } from "./conversations.js";
73
+ import {
74
+ claudeConversations,
75
+ transcriptBusy,
76
+ transcriptSummary,
77
+ } from "./conversations.js";
74
78
 
75
79
  /**
76
80
  * `/rename "x"` keeps the quotes in customTitle: one matching pair around
@@ -507,6 +511,14 @@ export const denClaude: Runtime = {
507
511
  return null;
508
512
  }
509
513
  },
514
+ // AG-18: mid-turn, from its transcript's tail (never the whole file).
515
+ turnBusy(token, env = process.env) {
516
+ try {
517
+ return transcriptBusy(transcriptFile(token, env));
518
+ } catch {
519
+ return false;
520
+ }
521
+ },
510
522
  // AG-4b, unofficial (catalog sessionName): `/rename` appends a
511
523
  // {"type":"custom-title","customTitle":…} record to the transcript; the
512
524
  // latest one, read on from the last ask (never the whole file), less one
@@ -103,6 +103,25 @@ export interface AgentRecord {
103
103
  * the list tags it. Start fresh and Relaunch keep the choices.
104
104
  */
105
105
  overrides?: Override[];
106
+ /**
107
+ * AG-18: each persona change, oldest first: from, to, when, and who (a
108
+ * person's sign-in, or "cli").
109
+ */
110
+ personaChanges?: PersonaChange[];
111
+ /**
112
+ * AG-18: a persona change waiting for this agent's turn to end. The wait
113
+ * lives in the server's memory, so a restart drops it; the upkeep at
114
+ * start clears this and audits the cancel (cancelStalePersonaChanges).
115
+ */
116
+ pendingPersona?: { to: string; since: string; by?: string };
117
+ }
118
+
119
+ /** One persona change of an agent (AG-18). */
120
+ export interface PersonaChange {
121
+ from: string;
122
+ to: string;
123
+ at: string;
124
+ by?: string;
106
125
  }
107
126
 
108
127
  /** A setting a session can override for one launch (AG-10.9). */
@@ -207,6 +207,11 @@ export interface Runtime {
207
207
  ): RuntimeConversation[];
208
208
  /** AG-17.1: the folder conversation `token` ran in, or null. Never throws. */
209
209
  conversationCwd?(token: string, env?: NodeJS.ProcessEnv): string | null;
210
+ /**
211
+ * AG-18: whether conversation `token` is mid-turn (den-claude: its
212
+ * transcript's tail). None: the runtime can't tell. Never throws.
213
+ */
214
+ turnBusy?(token: string, env?: NodeJS.ProcessEnv): boolean;
210
215
  /**
211
216
  * What the runtime prints when it exits at once (0.4.8): `noConversation`
212
217
  * when a resume finds nothing to continue, `idInUse` when a new
@@ -83,6 +83,13 @@ export function githubClientId(configured: string | undefined): string {
83
83
  return (configured ?? MO_DEN_GITHUB_CLIENT_ID).trim();
84
84
  }
85
85
 
86
+ /**
87
+ * The docs section the signed-out page's Learn more opens (SEC-2a-UX):
88
+ * docs/distributions.md, "Signing in with GitHub", in the public repo.
89
+ */
90
+ export const GITHUB_SIGNIN_DOCS =
91
+ "https://github.com/Monster-Orange/den/blob/main/docs/distributions.md#signing-in-with-github";
92
+
86
93
  /** What a GitHub button says while no client id is configured. */
87
94
  export function noClientIdMessage(cli: string): string {
88
95
  return (