@monster-orange/den 0.4.10 → 0.4.12
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 +92 -0
- package/README.md +7 -7
- package/docs/distributions.md +67 -3
- package/package.json +2 -1
- package/src/shared/agents/catalog.ts +77 -0
- package/src/shared/agents/den-claude.ts +36 -8
- package/src/shared/agents/records.ts +29 -0
- package/src/shared/agents/runtime.ts +8 -0
- package/src/shared/agents/session-name.ts +110 -0
- package/src/shared/agents/transcript-follow.ts +180 -0
- package/src/shared/den-config.ts +7 -0
- package/src/ui/cli/agent-ops.ts +49 -0
- package/src/ui/server/agent-paths.ts +52 -0
- package/src/ui/server/agent-routes.ts +76 -5
- package/src/ui/server/agents-layout-pref.ts +43 -0
- package/src/ui/server/editor-open-pref.ts +41 -0
- package/src/ui/server/index.ts +22 -0
- package/src/ui/server/public/agents-page.js +539 -20
- package/src/ui/server/public/agents-section.js +1 -1
- package/src/ui/server/public/agents.html +178 -5
- package/src/ui/server/public/header.js +30 -19
- package/src/ui/server/public/i18n/es.js +23 -5
- package/src/ui/server/public/i18n/ja.js +23 -5
- package/src/ui/server/public/local-dev.html +1 -1
- package/src/ui/server/public/settings-registry.js +44 -0
- package/src/ui/server/public/shell.js +34 -20
- package/src/ui/server/public/term-input.js +129 -0
- package/src/ui/server/public/topbar.js +22 -4
- package/src/ui/server/public/workspace-picker.js +9 -6
- package/src/ui/server/route-classes.ts +11 -0
- package/src/ui/server/session-routes.ts +17 -0
- package/src/ui/server/ui-assets.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,98 @@ 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.12] - 2026-11-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Agents tab layouts.** A switch in the page header picks Focus, Tabs or
|
|
12
|
+
Tile, and so do ⌃⇧1, ⌃⇧2 and ⌃⇧3 (Ctrl+Shift+digit on every OS) and ⌘K
|
|
13
|
+
"Layout: Focus/Tabs/Tile". ⌘1-9 and Ctrl+1-9 stay the browser's, for its
|
|
14
|
+
tabs; inside a terminal the layout keys never reach the agent. Focus, the default, shows one pane: a
|
|
15
|
+
click replaces it, and Shift-click or ⌘-click adds one alongside. Tabs
|
|
16
|
+
shows one pane under a strip of tabs. Tile is a grid: one pane fills
|
|
17
|
+
it, two sit side by side, three or four make a 2×2. At phone width,
|
|
18
|
+
Focus and Tile show as Tabs. Your choice is saved for you
|
|
19
|
+
(`agents.layout` in `config/local/user.yml`, `agents_layout` in
|
|
20
|
+
`/api/settings`, and Settings › Agents), and the URL says it
|
|
21
|
+
(`&layout=`).
|
|
22
|
+
- **Named tabs.** Each tab shows the agent's name, then its persona and
|
|
23
|
+
workspace, and a dot when it is waiting. Double-click a tab to rename
|
|
24
|
+
the agent. The name is stored in its record (`displayName`) and shows in
|
|
25
|
+
the list, its pane, ⌘K, the Workspaces page and `agents list --json`.
|
|
26
|
+
Start fresh and Relaunch keep it. The id never changes. The route is
|
|
27
|
+
`POST /api/workspaces/<ws>/agents/<id>/name`.
|
|
28
|
+
- **Shift+Enter is a soft newline in agent terminals.** It sends the
|
|
29
|
+
runtime's `softNewline` from the runtime catalog, not Enter: ESC CR for
|
|
30
|
+
Claude Code and Gemini CLI (and by default), Ctrl+J for Codex CLI. A
|
|
31
|
+
plain session's Shift+Enter is still Enter. Catalog entries may set
|
|
32
|
+
`softNewline: esc-cr | lf | none`.
|
|
33
|
+
- **The session's name in the pane header.** Claude Code's `/rename` shows
|
|
34
|
+
after the runtime (` · api spike`), and in the tab, the list and ⌘K. Den
|
|
35
|
+
reads it from the session's transcript, following the file from where it
|
|
36
|
+
last stopped (the catalog marks this source `unofficial`; a name it
|
|
37
|
+
can't read is none). The most recent rename wins, Den's or the
|
|
38
|
+
runtime's. `GET /api/sessions`, `agents list --json` and the start and
|
|
39
|
+
resume answers carry it as `sessionName`. Catalog entries may set
|
|
40
|
+
`titleFromOsc` (no runtime does yet, and the session host doesn't read
|
|
41
|
+
titles yet).
|
|
42
|
+
- **Links and paths in agent panes are clickable.** ⌘-click (Ctrl-click on
|
|
43
|
+
Windows and Linux) opens a URL or an OSC 8 link in a new tab
|
|
44
|
+
(`noopener`), http and https only; a plain click still selects. A file
|
|
45
|
+
path inside the agent's workspace (`src/foo.ts:42`, absolute paths) is
|
|
46
|
+
a link: ⌘-click opens it in the editor set in Settings › Editor &
|
|
47
|
+
terminal › "Open file links in" (`editor_open`: vscode, cursor, zed, or
|
|
48
|
+
none to copy the path, the default). The server checks a path exists
|
|
49
|
+
inside the workspace with `GET /api/workspaces/<ws>/agents/<id>/paths`
|
|
50
|
+
(stat only). Den now ships `@xterm/addon-web-links` 0.12.0, served from
|
|
51
|
+
`/vendor/`.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **The Agents tab fills the window.** The list and the panes reach down to
|
|
56
|
+
14px above the runs dock at any window height, with the dock open or
|
|
57
|
+
shut. They used to stop at a fixed guess of the window less 300px.
|
|
58
|
+
- **Re-picking the workspace in view on a page with `?ws=`** (Workspaces'
|
|
59
|
+
detail pane) now updates `?ws=` before the reload, so the page shows the
|
|
60
|
+
pick and not the old workspace.
|
|
61
|
+
- **Every terminal fits its pane.** A terminal used to be sized for its
|
|
62
|
+
pane's padding as well, so its last row could be cut off. It now fits
|
|
63
|
+
the space inside the padding, in every layout, and refits when the
|
|
64
|
+
window or the dock changes size.
|
|
65
|
+
|
|
66
|
+
### Migrations
|
|
67
|
+
|
|
68
|
+
None. The record's new `displayName` and `displayNameAt` are optional:
|
|
69
|
+
records without them read as before. The layout pref is optional and
|
|
70
|
+
defaults to Focus; `editor_open` is optional and defaults to none. The
|
|
71
|
+
catalog's new `softNewline`, `sessionName` and `titleFromOsc` are
|
|
72
|
+
optional.
|
|
73
|
+
|
|
74
|
+
## [0.4.11] - 2026-11-07
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
|
|
78
|
+
- **Picking a workspace in the top bar also makes it the CLI default.**
|
|
79
|
+
There is one "current workspace": choosing one in the Workspace menu
|
|
80
|
+
sets what pages show and where `<cli>` commands run (workspaces.yml, as
|
|
81
|
+
Local Dev's "Make CLI default" does). "All active (n)" changes only what
|
|
82
|
+
pages show; the CLI default stays. If the CLI default can't be set,
|
|
83
|
+
nothing changes and the error shows.
|
|
84
|
+
- **The menu's separate "Make <ws> the CLI default" item is gone**, since
|
|
85
|
+
picking a workspace does it. The "CLI default" tag stays on its row, and
|
|
86
|
+
Local Dev keeps its "Make CLI default" button, to set it without
|
|
87
|
+
changing what pages show. Each workspace row's tooltip says so.
|
|
88
|
+
- **Picking the workspace already in view makes it the CLI default** when
|
|
89
|
+
it isn't one (after Local Dev's "Make CLI default" or `<cli> workspace
|
|
90
|
+
focus` moved it). The view stays as it is.
|
|
91
|
+
- **Pages whose address names a workspace follow the pick.** Workspaces'
|
|
92
|
+
detail pane and breadcrumb now show the workspace the top bar picked,
|
|
93
|
+
not the one in the old `?ws=`.
|
|
94
|
+
|
|
95
|
+
### Migrations
|
|
96
|
+
|
|
97
|
+
None.
|
|
98
|
+
|
|
7
99
|
## [0.4.10] - 2026-11-07
|
|
8
100
|
|
|
9
101
|
### Changed
|
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.12/hero-dark.png">
|
|
14
|
+
<img src="docs/images/0.4.12/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.12/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.12/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.12/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.12/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.12/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
|
@@ -2132,15 +2132,75 @@ agent of the workspaces in view, and their terminals in the browser.
|
|
|
2132
2132
|
it, or start it again.
|
|
2133
2133
|
- **A pane** is the session's terminal: it shows the scrollback, then the
|
|
2134
2134
|
live output, and your keys go to the session; its size follows the
|
|
2135
|
-
pane.
|
|
2136
|
-
|
|
2137
|
-
|
|
2135
|
+
pane. Up to four are open at once (a fifth closes the oldest). The URL
|
|
2136
|
+
holds the open panes (`/agents?open=<id>,<id>`), so a link, a reload or
|
|
2137
|
+
Back reopens them. The list and the panes fill the window down to the
|
|
2138
|
+
runs dock.
|
|
2139
|
+
- **Layouts.** The switch in the page header picks one, or ⌃⇧1, ⌃⇧2, ⌃⇧3
|
|
2140
|
+
(Ctrl+Shift+1/2/3 on every OS; ⌘1-9 and Ctrl+1-9 stay the browser's, for
|
|
2141
|
+
its tabs), or ⌘K "Layout: Focus", "Layout: Tabs", "Layout: Tile". Inside
|
|
2142
|
+
a terminal those keys switch the layout and never reach the agent:
|
|
2143
|
+
- **Focus** (the default) shows one pane. A click replaces it;
|
|
2144
|
+
Shift-click or ⌘-click adds one alongside.
|
|
2145
|
+
- **Tabs** shows one pane under a strip of tabs. Each tab has the
|
|
2146
|
+
agent's name, then its persona and workspace ("reviewer · north"), and
|
|
2147
|
+
a dot when it is waiting.
|
|
2148
|
+
- **Tile** shows the open panes in a grid: one fills it, two sit side
|
|
2149
|
+
by side, three or four make a 2×2.
|
|
2150
|
+
|
|
2151
|
+
At phone width, Focus and Tile show as Tabs. The choice is yours: it is
|
|
2152
|
+
saved in `config/local/user.yml` as `agents.layout` (`POST /api/settings
|
|
2153
|
+
{ agents_layout: "focus" | "tabs" | "tile" | null }`, also in Settings ›
|
|
2154
|
+
Agents). The URL says it too (`&layout=tabs`); a link's layout is used
|
|
2155
|
+
for that page and saves nothing.
|
|
2156
|
+
|
|
2157
|
+
- **Rename an agent** by double-clicking its tab: Enter saves, Esc keeps
|
|
2158
|
+
the old name. The name (trimmed, up to 40 characters, no control
|
|
2159
|
+
characters) is stored in the agent's record as `displayName`, and the
|
|
2160
|
+
list, its pane, ⌘K and `agents list --json` show it. The agent's id
|
|
2161
|
+
never changes. Clear the name to go back to the id. The route is `POST
|
|
2162
|
+
/api/workspaces/<ws>/agents/<id>/name` (`{ name: string | null }`), for
|
|
2163
|
+
a person only.
|
|
2138
2164
|
- **Close** (×) detaches, as leaving the page does: the session keeps
|
|
2139
2165
|
running, and opening it again replays what you missed. **Stop** (an
|
|
2140
2166
|
agent only) is `agents stop`. **+ Start agent** (or
|
|
2141
2167
|
`/agents?start=1`) starts one and opens its pane; under All it asks for
|
|
2142
2168
|
the workspace. It says before Start whether the runtime can run, as the
|
|
2143
2169
|
Workspaces page's form does. It never asks for the admin password.
|
|
2170
|
+
- **Shift+Enter** adds a soft newline instead of submitting. It sends the
|
|
2171
|
+
runtime's `softNewline` from the runtime catalog: `esc-cr` (ESC CR, the
|
|
2172
|
+
default; Claude Code and Gemini CLI), `lf` (Ctrl+J; Codex CLI) or `none`
|
|
2173
|
+
(Enter itself). A plain session's Shift+Enter is Enter. A catalog entry
|
|
2174
|
+
a distribution or `user.yml` adds may set `softNewline`.
|
|
2175
|
+
- **The session's name.** A runtime may name its conversation: Claude
|
|
2176
|
+
Code's `/rename` writes a `custom-title` record to its transcript, and
|
|
2177
|
+
Den reads the latest one (the catalog marks this source `unofficial`:
|
|
2178
|
+
the format is undocumented, so a name that can't be read is simply
|
|
2179
|
+
none). It reads on from where it last stopped, never the whole file at
|
|
2180
|
+
once. The most recent rename wins, Den's (a tab's) or the runtime's. The
|
|
2181
|
+
pane header adds ` · <name>` after the runtime, and the tab, the list and
|
|
2182
|
+
⌘K add it too, when it is not empty and says more than the agent's own
|
|
2183
|
+
name or persona. `GET /api/sessions`, `agents list --json` and the start
|
|
2184
|
+
and resume answers (MCP `agent_resume`) carry it as `sessionName`.
|
|
2185
|
+
Codex CLI, Gemini CLI and Grok Build have no name of their own yet. A
|
|
2186
|
+
catalog entry's `titleFromOsc: true` marks a runtime whose terminal
|
|
2187
|
+
title (OSC 0/2) names the session; no built-in runtime sets it, and the
|
|
2188
|
+
session host doesn't read titles yet.
|
|
2189
|
+
- **Links.** A URL in a pane, and an OSC 8 hyperlink, open on ⌘-click
|
|
2190
|
+
(Ctrl-click on Windows and Linux) in a new tab, with `noopener`. Only
|
|
2191
|
+
`http:` and `https:` open; an OSC 8 link with any other scheme
|
|
2192
|
+
(`javascript:`, `file:`) is no link. A plain click selects text as
|
|
2193
|
+
ever. A file path an agent prints (`src/foo.ts:42`, `./a/b.js:3:7`, an
|
|
2194
|
+
absolute path) is a link when it exists inside the agent's workspace
|
|
2195
|
+
(its directory, or the agent's own folder), resolved against the
|
|
2196
|
+
agent's folder; one outside it never is. The server checks with `GET
|
|
2197
|
+
/api/workspaces/<ws>/agents/<id>/paths?p=<path>&p=…` (stat only, nothing
|
|
2198
|
+
is read; at most 64 at once), which answers each path's absolute path
|
|
2199
|
+
or null. ⌘-click opens it in the editor of Settings › Editor & terminal ›
|
|
2200
|
+
"Open file links in" (`editor_open` in `config/local/user.yml` and
|
|
2201
|
+
`/api/settings`: `vscode`, `cursor` or `zed`, by its URL scheme at the
|
|
2202
|
+
line), or, with `none` (the default), copies its absolute path and says
|
|
2203
|
+
"Copied".
|
|
2144
2204
|
- ⌘K lists each as "Agents › <ws> / <name>", and "Start agent…".
|
|
2145
2205
|
- Several browsers and `<cli> sessions attach` can attach to one session
|
|
2146
2206
|
at once and each sees every byte; the terminal's size is the last
|
|
@@ -2626,6 +2686,10 @@ agents:
|
|
|
2626
2686
|
dirs: [~/.local/bin, $AIDER_DIR, "{system}"]
|
|
2627
2687
|
launch:
|
|
2628
2688
|
argv: ["{bin}"] # "{bin}" first: the path found
|
|
2689
|
+
# Optional, how it behaves in an Agents tab pane (AG-4b):
|
|
2690
|
+
softNewline: esc-cr # what Shift+Enter sends: esc-cr (default), lf, none
|
|
2691
|
+
titleFromOsc: false # its terminal title (OSC 0/2) names the session
|
|
2692
|
+
# sessionName: official | unofficial (built-in adapters only)
|
|
2629
2693
|
```
|
|
2630
2694
|
|
|
2631
2695
|
**Where a runtime's CLI is found.** As your terminal would find it, not
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monster-orange/den",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.12",
|
|
4
4
|
"description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"@playwright/test": "^1.61.1",
|
|
65
65
|
"@slack/webhook": "^7.0.2",
|
|
66
66
|
"@xterm/addon-fit": "0.11.0",
|
|
67
|
+
"@xterm/addon-web-links": "0.12.0",
|
|
67
68
|
"@xterm/xterm": "6.0.0",
|
|
68
69
|
"ajv": "^8.12.0",
|
|
69
70
|
"ajv-formats": "^2.1.1",
|
|
@@ -17,6 +17,18 @@
|
|
|
17
17
|
* /usr/local/bin and npm's global bin)
|
|
18
18
|
* launch argv: its interactive CLI, "{bin}" first (the path found)
|
|
19
19
|
*
|
|
20
|
+
* And, optional, how it behaves in a terminal pane (AG-4b):
|
|
21
|
+
*
|
|
22
|
+
* softNewline what Shift+Enter sends in an Agents tab pane: "esc-cr"
|
|
23
|
+
* (ESC CR, the default), "lf" (Ctrl+J) or "none" (Enter
|
|
24
|
+
* itself: Shift+Enter submits)
|
|
25
|
+
* sessionName where the runtime adapter reads a session's name from:
|
|
26
|
+
* "official" (a documented interface) or "unofficial" (an
|
|
27
|
+
* undocumented file format, read defensively). None: the
|
|
28
|
+
* runtime has no name of its own; Den's rename still works.
|
|
29
|
+
* titleFromOsc the terminal title (OSC 0/2) is a session name. Off by
|
|
30
|
+
* default: many CLIs put a spinner or the cwd there.
|
|
31
|
+
*
|
|
20
32
|
* Each built-in entry was checked against the vendor's own docs or
|
|
21
33
|
* installer (the URLs beside it). A vendor with no official CLI is left
|
|
22
34
|
* out (docs/distributions.md, Runtimes, says which, and why); nothing is
|
|
@@ -38,6 +50,19 @@ export interface CatalogIdentity {
|
|
|
38
50
|
realpath_under?: string[];
|
|
39
51
|
}
|
|
40
52
|
|
|
53
|
+
/** What Shift+Enter sends in a pane (AG-4b). */
|
|
54
|
+
export const SOFT_NEWLINES = ["esc-cr", "lf", "none"] as const;
|
|
55
|
+
export type SoftNewline = (typeof SOFT_NEWLINES)[number];
|
|
56
|
+
const SOFT_NEWLINE_SEQ: Record<SoftNewline, string> = {
|
|
57
|
+
"esc-cr": "\x1b\r",
|
|
58
|
+
lf: "\n",
|
|
59
|
+
// none: Shift+Enter is Enter.
|
|
60
|
+
none: "\r",
|
|
61
|
+
};
|
|
62
|
+
/** How stable a runtime's session-name source is (AG-4b). */
|
|
63
|
+
export const SESSION_NAME_SOURCES = ["official", "unofficial"] as const;
|
|
64
|
+
export type SessionNameSource = (typeof SESSION_NAME_SOURCES)[number];
|
|
65
|
+
|
|
41
66
|
export interface CatalogEntry {
|
|
42
67
|
id: string;
|
|
43
68
|
vendor: string;
|
|
@@ -46,6 +71,19 @@ export interface CatalogEntry {
|
|
|
46
71
|
version: { args: string[]; identity: CatalogIdentity };
|
|
47
72
|
dirs: string[];
|
|
48
73
|
launch: { argv: string[] };
|
|
74
|
+
/** What Shift+Enter sends (AG-4b); unset: "esc-cr". */
|
|
75
|
+
softNewline?: SoftNewline;
|
|
76
|
+
/** The adapter reads a session name, from a source this stable. */
|
|
77
|
+
sessionName?: SessionNameSource;
|
|
78
|
+
/** The terminal title (OSC 0/2) names the session. Unset: no. */
|
|
79
|
+
titleFromOsc?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The bytes Shift+Enter sends for an entry (ESC CR when it says none). */
|
|
83
|
+
export function softNewlineSeq(
|
|
84
|
+
e: Pick<CatalogEntry, "softNewline"> | undefined,
|
|
85
|
+
): string {
|
|
86
|
+
return SOFT_NEWLINE_SEQ[e?.softNewline ?? "esc-cr"];
|
|
49
87
|
}
|
|
50
88
|
|
|
51
89
|
/** The keys an entry takes (the validator refuses any other). */
|
|
@@ -57,6 +95,9 @@ const ENTRY_KEYS = [
|
|
|
57
95
|
"version",
|
|
58
96
|
"dirs",
|
|
59
97
|
"launch",
|
|
98
|
+
"softNewline",
|
|
99
|
+
"sessionName",
|
|
100
|
+
"titleFromOsc",
|
|
60
101
|
] as const;
|
|
61
102
|
|
|
62
103
|
/** Den's built-in catalog, in the order lists show them. */
|
|
@@ -80,6 +121,12 @@ export const BUILTIN_CATALOG: readonly CatalogEntry[] = [
|
|
|
80
121
|
},
|
|
81
122
|
dirs: ["~/.local/bin", "~/.claude/local", "{system}"],
|
|
82
123
|
launch: { argv: ["{bin}"] },
|
|
124
|
+
// https://code.claude.com/docs/en/terminal-config: Option+Enter (Meta,
|
|
125
|
+
// ESC CR) is a newline; /terminal-setup binds Shift+Enter to it.
|
|
126
|
+
softNewline: "esc-cr",
|
|
127
|
+
// `/rename` appends {"type":"custom-title","customTitle":…} to the
|
|
128
|
+
// session's transcript (<token>.jsonl). Undocumented: read defensively.
|
|
129
|
+
sessionName: "unofficial",
|
|
83
130
|
},
|
|
84
131
|
// Codex CLI. https://github.com/openai/codex: binary `codex`, npm
|
|
85
132
|
// @openai/codex, Homebrew cask codex. The standalone installer
|
|
@@ -98,6 +145,9 @@ export const BUILTIN_CATALOG: readonly CatalogEntry[] = [
|
|
|
98
145
|
},
|
|
99
146
|
dirs: ["$CODEX_INSTALL_DIR", "~/.local/bin", "{system}"],
|
|
100
147
|
launch: { argv: ["{bin}"] },
|
|
148
|
+
// The composer's insert_newline is Ctrl+J (codex-rs/tui keymap): a
|
|
149
|
+
// plain terminal's Shift+Enter and Alt+Enter submit (openai/codex#20580).
|
|
150
|
+
softNewline: "lf",
|
|
101
151
|
},
|
|
102
152
|
// Gemini CLI. https://github.com/google-gemini/gemini-cli: binary
|
|
103
153
|
// `gemini`, npm @google/gemini-cli, Homebrew formula gemini-cli;
|
|
@@ -115,6 +165,9 @@ export const BUILTIN_CATALOG: readonly CatalogEntry[] = [
|
|
|
115
165
|
},
|
|
116
166
|
dirs: ["{system}"],
|
|
117
167
|
launch: { argv: ["{bin}"] },
|
|
168
|
+
// https://geminicli.com/docs/reference/keyboard-shortcuts: Alt+Enter
|
|
169
|
+
// (ESC CR), Ctrl+J and Shift+Enter insert a newline.
|
|
170
|
+
softNewline: "esc-cr",
|
|
118
171
|
},
|
|
119
172
|
// Grok Build. https://docs.x.ai/build/overview: install with
|
|
120
173
|
// https://x.ai/cli/install.sh, run `grok` in a project directory. The
|
|
@@ -279,6 +332,29 @@ export function validateCatalogEntry(
|
|
|
279
332
|
throw new Error(
|
|
280
333
|
`${where}.launch.argv[0]: must be "{bin}" (the path Den found)`,
|
|
281
334
|
);
|
|
335
|
+
const traits: Pick<
|
|
336
|
+
CatalogEntry,
|
|
337
|
+
"softNewline" | "sessionName" | "titleFromOsc"
|
|
338
|
+
> = {};
|
|
339
|
+
if (raw.softNewline !== undefined) {
|
|
340
|
+
if (!(SOFT_NEWLINES as readonly unknown[]).includes(raw.softNewline))
|
|
341
|
+
throw new Error(
|
|
342
|
+
`${where}.softNewline: ${shown(raw.softNewline)} must be one of ${SOFT_NEWLINES.join(", ")}`,
|
|
343
|
+
);
|
|
344
|
+
traits.softNewline = raw.softNewline as SoftNewline;
|
|
345
|
+
}
|
|
346
|
+
if (raw.sessionName !== undefined) {
|
|
347
|
+
if (!(SESSION_NAME_SOURCES as readonly unknown[]).includes(raw.sessionName))
|
|
348
|
+
throw new Error(
|
|
349
|
+
`${where}.sessionName: ${shown(raw.sessionName)} must be one of ${SESSION_NAME_SOURCES.join(", ")}`,
|
|
350
|
+
);
|
|
351
|
+
traits.sessionName = raw.sessionName as SessionNameSource;
|
|
352
|
+
}
|
|
353
|
+
if (raw.titleFromOsc !== undefined) {
|
|
354
|
+
if (typeof raw.titleFromOsc !== "boolean")
|
|
355
|
+
throw new Error(`${where}.titleFromOsc: must be true or false`);
|
|
356
|
+
traits.titleFromOsc = raw.titleFromOsc;
|
|
357
|
+
}
|
|
282
358
|
return {
|
|
283
359
|
id,
|
|
284
360
|
vendor: text(`${where}.vendor`, raw.vendor),
|
|
@@ -287,6 +363,7 @@ export function validateCatalogEntry(
|
|
|
287
363
|
version: { args, identity },
|
|
288
364
|
dirs,
|
|
289
365
|
launch: { argv },
|
|
366
|
+
...traits,
|
|
290
367
|
};
|
|
291
368
|
}
|
|
292
369
|
|
|
@@ -66,6 +66,7 @@ import { builtinEntry } from "./catalog.js";
|
|
|
66
66
|
import { catalogFind } from "./catalog-runtime.js";
|
|
67
67
|
import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
|
|
68
68
|
import { claudeProjectsDir } from "../usage/paths.js";
|
|
69
|
+
import { transcriptTitle } from "./transcript-follow.js";
|
|
69
70
|
|
|
70
71
|
export const DEN_CLAUDE_ID = "den-claude";
|
|
71
72
|
const BIN = "claude";
|
|
@@ -313,6 +314,31 @@ export function firstPromptArg(text: string | undefined): string[] {
|
|
|
313
314
|
return [t.startsWith("-") ? `Prompt: ${t}` : t];
|
|
314
315
|
}
|
|
315
316
|
|
|
317
|
+
const TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Claude Code's transcript for conversation `token`: <token>.jsonl in any
|
|
321
|
+
* project under its projects dir, or null (a bad token, none written yet).
|
|
322
|
+
*/
|
|
323
|
+
export function transcriptFile(
|
|
324
|
+
token: string | undefined,
|
|
325
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
326
|
+
): string | null {
|
|
327
|
+
if (!token || !TOKEN_RE.test(token)) return null;
|
|
328
|
+
const root = claudeProjectsDir(env);
|
|
329
|
+
let projects: string[] = [];
|
|
330
|
+
try {
|
|
331
|
+
projects = fs.readdirSync(root);
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
for (const p of projects) {
|
|
336
|
+
const file = path.join(root, p, `${token}.jsonl`);
|
|
337
|
+
if (fs.existsSync(file)) return file;
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
316
342
|
export const denClaude: Runtime = {
|
|
317
343
|
id: DEN_CLAUDE_ID,
|
|
318
344
|
label: DEN_CLAUDE_CATALOG.name,
|
|
@@ -355,17 +381,19 @@ export const denClaude: Runtime = {
|
|
|
355
381
|
},
|
|
356
382
|
// Its transcript, <token>.jsonl in any project under its projects dir.
|
|
357
383
|
conversationExists(token, env = process.env) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
384
|
+
return transcriptFile(token, env) !== null;
|
|
385
|
+
},
|
|
386
|
+
// AG-4b, unofficial (catalog sessionName): `/rename` appends a
|
|
387
|
+
// {"type":"custom-title","customTitle":…} record to the transcript; the
|
|
388
|
+
// latest one, read on from the last ask (never the whole file). Null on
|
|
389
|
+
// any failure: it must never break a pane.
|
|
390
|
+
sessionName(rec, env = process.env) {
|
|
361
391
|
try {
|
|
362
|
-
|
|
392
|
+
const file = transcriptFile(rec.resumeToken, env);
|
|
393
|
+
return file ? transcriptTitle(file) : null;
|
|
363
394
|
} catch {
|
|
364
|
-
return
|
|
395
|
+
return null;
|
|
365
396
|
}
|
|
366
|
-
return projects.some((p) =>
|
|
367
|
-
fs.existsSync(path.join(root, p, `${token}.jsonl`)),
|
|
368
|
-
);
|
|
369
397
|
},
|
|
370
398
|
launchFailures: {
|
|
371
399
|
noConversation: /no conversation found/i,
|
|
@@ -72,6 +72,35 @@ export interface AgentRecord {
|
|
|
72
72
|
* rotation), oldest first.
|
|
73
73
|
*/
|
|
74
74
|
rotations?: RotationEntry[];
|
|
75
|
+
/**
|
|
76
|
+
* The name a person gave it (AG-4b: double-click its tab), shown in the
|
|
77
|
+
* Agents tab, its tabs, ⌘K and the agent lists; the id never changes.
|
|
78
|
+
* checkDisplayName says what one may be.
|
|
79
|
+
*/
|
|
80
|
+
displayName?: string;
|
|
81
|
+
/** When displayName was given (ISO 8601): the latest rename wins (AG-4b). */
|
|
82
|
+
displayNameAt?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The longest name a person may give an agent (AG-4b). */
|
|
86
|
+
export const MAX_DISPLAY_NAME = 40;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A name given to an agent, checked (AG-4b): a string, trimmed, at most
|
|
90
|
+
* MAX_DISPLAY_NAME characters, with no control or format characters (no
|
|
91
|
+
* newline, no bidi override). Blank (or null) clears it: null. Anything
|
|
92
|
+
* else throws, saying why.
|
|
93
|
+
*/
|
|
94
|
+
export function checkDisplayName(v: unknown): string | null {
|
|
95
|
+
if (v === null) return null;
|
|
96
|
+
if (typeof v !== "string") throw new Error("name must be a string, or null");
|
|
97
|
+
const name = v.trim();
|
|
98
|
+
if (!name) return null;
|
|
99
|
+
if ([...name].length > MAX_DISPLAY_NAME)
|
|
100
|
+
throw new Error(`name must be at most ${MAX_DISPLAY_NAME} characters`);
|
|
101
|
+
if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(name))
|
|
102
|
+
throw new Error("name can't have control characters");
|
|
103
|
+
return name;
|
|
75
104
|
}
|
|
76
105
|
|
|
77
106
|
/** What an agent's run came to (den#6). Read locally; never sent. */
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type StrategyEntry,
|
|
18
18
|
} from "../strategies/registry.js";
|
|
19
19
|
import type { CatalogEntry } from "./catalog.js";
|
|
20
|
+
import type { AgentRecord } from "./records.js";
|
|
20
21
|
import type { Persona } from "./personas.js";
|
|
21
22
|
import type { BinSource, RuntimeFind } from "./runtime-search.js";
|
|
22
23
|
|
|
@@ -157,6 +158,13 @@ export interface Runtime {
|
|
|
157
158
|
* conversation's id is taken. Den then tries the other way, once.
|
|
158
159
|
*/
|
|
159
160
|
launchFailures?: { noConversation?: RegExp; idInUse?: RegExp };
|
|
161
|
+
/**
|
|
162
|
+
* The name the runtime gives the agent's conversation (AG-4b: Claude
|
|
163
|
+
* Code's `/rename`), or null when it has none or can't say. Optional:
|
|
164
|
+
* a runtime without it has Den's rename only. Must be quick and never
|
|
165
|
+
* throw; its catalog entry's `sessionName` says how stable its source is.
|
|
166
|
+
*/
|
|
167
|
+
sessionName?(rec: AgentRecord, env?: NodeJS.ProcessEnv): string | null;
|
|
160
168
|
/** Where permission prompts surface (Q-AG-2). */
|
|
161
169
|
approvals?: "terminal" | "hook";
|
|
162
170
|
/**
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An agent's session name (AG-4b): what its pane header adds after
|
|
3
|
+
* `{workspace} · {runtime}`, and what its tab, the Agents list and ⌘K show.
|
|
4
|
+
*
|
|
5
|
+
* Two sources, and the most recent rename wins:
|
|
6
|
+
*
|
|
7
|
+
* - Den's own: a person double-clicked its tab (the record's displayName,
|
|
8
|
+
* made at displayNameAt). Every runtime has it.
|
|
9
|
+
* - The runtime's, when its adapter has `sessionName` (Runtime, AG-4b):
|
|
10
|
+
* Claude Code's `/rename`. Its time is when Den first saw it, kept in
|
|
11
|
+
* the agent's files dir (session-name.json, with the conversation's
|
|
12
|
+
* token: Start fresh forgets it). An adapter that fails or throws is no
|
|
13
|
+
* name; it never breaks the list.
|
|
14
|
+
*
|
|
15
|
+
* Den never writes a name back into the runtime.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
import { agentFilesDir, writePrivate, type AgentRecord } from "./records.js";
|
|
20
|
+
import { getRuntime } from "./runtimes.js";
|
|
21
|
+
import { cleanName } from "./transcript-follow.js";
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
FOLLOW_MAX_BYTES,
|
|
25
|
+
followState,
|
|
26
|
+
oscTitles,
|
|
27
|
+
resetFollowers,
|
|
28
|
+
transcriptTitle,
|
|
29
|
+
} from "./transcript-follow.js";
|
|
30
|
+
|
|
31
|
+
interface Seen {
|
|
32
|
+
token: string | null;
|
|
33
|
+
name: string;
|
|
34
|
+
/** When Den first saw it (ms). */
|
|
35
|
+
at: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const seenFile = (stateDir: string, id: string) =>
|
|
39
|
+
path.join(agentFilesDir(stateDir, id), "session-name.json");
|
|
40
|
+
|
|
41
|
+
function readSeen(file: string): Seen | null {
|
|
42
|
+
try {
|
|
43
|
+
const v = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<Seen>;
|
|
44
|
+
if (typeof v.name !== "string" || typeof v.at !== "number") return null;
|
|
45
|
+
return {
|
|
46
|
+
token: typeof v.token === "string" ? v.token : null,
|
|
47
|
+
name: v.name,
|
|
48
|
+
at: v.at,
|
|
49
|
+
};
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The runtime's name for the agent's conversation, with when it was first seen. */
|
|
56
|
+
function runtimeName(
|
|
57
|
+
rec: AgentRecord,
|
|
58
|
+
stateDir: string,
|
|
59
|
+
env: NodeJS.ProcessEnv,
|
|
60
|
+
now: () => number,
|
|
61
|
+
): Seen | null {
|
|
62
|
+
const rt = getRuntime(rec.runtime);
|
|
63
|
+
if (!rt?.sessionName) return null;
|
|
64
|
+
const token = rec.resumeToken ?? null;
|
|
65
|
+
const file = seenFile(stateDir, rec.id);
|
|
66
|
+
let seen = readSeen(file);
|
|
67
|
+
if (seen && seen.token !== token) seen = null;
|
|
68
|
+
let name: string | null = null;
|
|
69
|
+
try {
|
|
70
|
+
name = cleanName(rt.sessionName(rec, env));
|
|
71
|
+
} catch {
|
|
72
|
+
name = null;
|
|
73
|
+
}
|
|
74
|
+
// null: nothing new (not caught up, not found); the last name stays.
|
|
75
|
+
if (name && name !== seen?.name) {
|
|
76
|
+
seen = { token, name, at: now() };
|
|
77
|
+
try {
|
|
78
|
+
writePrivate(file, JSON.stringify(seen) + "\n");
|
|
79
|
+
} catch {
|
|
80
|
+
/* shown, not kept: it is seen again next time */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return seen;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The agent's session name: the latest of Den's rename and the runtime's,
|
|
88
|
+
* or undefined when neither has one. Never throws.
|
|
89
|
+
*/
|
|
90
|
+
export function agentSessionName(
|
|
91
|
+
rec: AgentRecord,
|
|
92
|
+
stateDir: string,
|
|
93
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
94
|
+
now: () => number = Date.now,
|
|
95
|
+
): string | undefined {
|
|
96
|
+
let rt: Seen | null = null;
|
|
97
|
+
try {
|
|
98
|
+
rt = runtimeName(rec, stateDir, env, now);
|
|
99
|
+
} catch {
|
|
100
|
+
rt = null;
|
|
101
|
+
}
|
|
102
|
+
const den = rec.displayName
|
|
103
|
+
? {
|
|
104
|
+
name: rec.displayName,
|
|
105
|
+
at: Date.parse(rec.displayNameAt ?? rec.startedAt) || 0,
|
|
106
|
+
}
|
|
107
|
+
: null;
|
|
108
|
+
if (den && rt) return rt.at > den.at ? rt.name : den.name;
|
|
109
|
+
return (den ?? rt)?.name;
|
|
110
|
+
}
|