@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 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.10/hero-dark.png">
14
- <img src="docs/images/0.4.10/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.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.10/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.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.10/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.10/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.10/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.10/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.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
@@ -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. Click more rows to tile up to four (a fifth closes the oldest).
2136
- The URL holds the open panes (`/agents?open=<id>,<id>`), so a link, a
2137
- reload or Back reopens them.
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.10",
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
- if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(token)) return false;
359
- const root = claudeProjectsDir(env);
360
- let projects: string[] = [];
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
- projects = fs.readdirSync(root);
392
+ const file = transcriptFile(rec.resumeToken, env);
393
+ return file ? transcriptTitle(file) : null;
363
394
  } catch {
364
- return false;
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
+ }