@monster-orange/den 0.4.11 → 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 +67 -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 +2 -0
- package/src/ui/server/public/i18n/es.js +19 -0
- package/src/ui/server/public/i18n/ja.js +19 -0
- package/src/ui/server/public/settings-registry.js +44 -0
- package/src/ui/server/public/shell.js +20 -0
- package/src/ui/server/public/term-input.js +129 -0
- package/src/ui/server/public/topbar.js +22 -4
- 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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a session's name from what a runtime writes (AG-4b), without
|
|
3
|
+
* ever reading a whole file at once:
|
|
4
|
+
*
|
|
5
|
+
* - transcriptTitle(file): the latest `{"type":"custom-title",
|
|
6
|
+
* "customTitle":…}` record in a JSON-lines transcript (Claude Code's
|
|
7
|
+
* `/rename`). Each ask reads on from where the last one stopped, at
|
|
8
|
+
* most FOLLOW_MAX_BYTES, so a long transcript is caught up over a few
|
|
9
|
+
* asks; a file that got shorter is read again from the start. Only
|
|
10
|
+
* lines that mention "custom-title" are parsed. Any failure is null.
|
|
11
|
+
* - oscTitles(chunk, carry): the terminal titles (OSC 0 and OSC 2) in a
|
|
12
|
+
* PTY's output, for runtimes the catalog marks `titleFromOsc`.
|
|
13
|
+
*
|
|
14
|
+
* A name is cleaned for the page: control, format and line-separator
|
|
15
|
+
* characters go, spaces are squeezed, and it is cut at MAX_NAME.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
|
|
19
|
+
/** The most one ask reads of a transcript. */
|
|
20
|
+
export const FOLLOW_MAX_BYTES = 1024 * 1024;
|
|
21
|
+
const CHUNK = 64 * 1024;
|
|
22
|
+
/** A line longer than this is no title record: skipped to its end. */
|
|
23
|
+
const MAX_LINE = 64 * 1024;
|
|
24
|
+
/** The longest name kept. */
|
|
25
|
+
export const MAX_NAME = 80;
|
|
26
|
+
/** The most files followed at once (the oldest is forgotten first). */
|
|
27
|
+
const MAX_FOLLOWED = 256;
|
|
28
|
+
|
|
29
|
+
interface Follow {
|
|
30
|
+
offset: number;
|
|
31
|
+
title: string | null;
|
|
32
|
+
/** The start of a line whose newline hasn't come yet. */
|
|
33
|
+
rest: Buffer;
|
|
34
|
+
/** Inside a line too long to be a title: drop bytes to its newline. */
|
|
35
|
+
skipping: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const followers = new Map<string, Follow>();
|
|
39
|
+
|
|
40
|
+
/** A name as the page may show it, or null when nothing is left. */
|
|
41
|
+
export function cleanName(v: unknown): string | null {
|
|
42
|
+
if (typeof v !== "string") return null;
|
|
43
|
+
const s = v
|
|
44
|
+
.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ")
|
|
45
|
+
.replace(/\s+/g, " ")
|
|
46
|
+
.trim();
|
|
47
|
+
if (!s) return null;
|
|
48
|
+
return [...s].slice(0, MAX_NAME).join("").trim() || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function titleOf(line: Buffer): string | null | undefined {
|
|
52
|
+
if (!line.includes("custom-title")) return undefined;
|
|
53
|
+
try {
|
|
54
|
+
const rec = JSON.parse(line.toString("utf-8")) as {
|
|
55
|
+
type?: unknown;
|
|
56
|
+
customTitle?: unknown;
|
|
57
|
+
};
|
|
58
|
+
if (rec?.type !== "custom-title") return undefined;
|
|
59
|
+
return cleanName(rec.customTitle) ?? undefined;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The latest custom-title in `file` (read on from the last ask), or null. */
|
|
66
|
+
export function transcriptTitle(file: string): string | null {
|
|
67
|
+
let fd: number | undefined;
|
|
68
|
+
try {
|
|
69
|
+
const st = fs.statSync(file);
|
|
70
|
+
if (!st.isFile()) {
|
|
71
|
+
followers.delete(file);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
let f = followers.get(file);
|
|
75
|
+
if (!f || st.size < f.offset) {
|
|
76
|
+
f = { offset: 0, title: null, rest: Buffer.alloc(0), skipping: false };
|
|
77
|
+
followers.delete(file);
|
|
78
|
+
if (followers.size >= MAX_FOLLOWED)
|
|
79
|
+
followers.delete(followers.keys().next().value as string);
|
|
80
|
+
followers.set(file, f);
|
|
81
|
+
}
|
|
82
|
+
if (st.size === f.offset) return f.title;
|
|
83
|
+
fd = fs.openSync(file, "r");
|
|
84
|
+
const end = Math.min(st.size, f.offset + FOLLOW_MAX_BYTES);
|
|
85
|
+
const buf = Buffer.alloc(CHUNK);
|
|
86
|
+
while (f.offset < end) {
|
|
87
|
+
const n = fs.readSync(
|
|
88
|
+
fd,
|
|
89
|
+
buf,
|
|
90
|
+
0,
|
|
91
|
+
Math.min(CHUNK, end - f.offset),
|
|
92
|
+
f.offset,
|
|
93
|
+
);
|
|
94
|
+
if (n <= 0) break;
|
|
95
|
+
f.offset += n;
|
|
96
|
+
let data = buf.subarray(0, n);
|
|
97
|
+
for (;;) {
|
|
98
|
+
const nl = data.indexOf(0x0a);
|
|
99
|
+
if (nl < 0) {
|
|
100
|
+
if (!f.skipping) {
|
|
101
|
+
f.rest = Buffer.concat([f.rest, data]);
|
|
102
|
+
if (f.rest.length > MAX_LINE) {
|
|
103
|
+
f.rest = Buffer.alloc(0);
|
|
104
|
+
f.skipping = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
const piece = data.subarray(0, nl);
|
|
110
|
+
data = data.subarray(nl + 1);
|
|
111
|
+
if (f.skipping) {
|
|
112
|
+
f.skipping = false;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const line = f.rest.length ? Buffer.concat([f.rest, piece]) : piece;
|
|
116
|
+
f.rest = Buffer.alloc(0);
|
|
117
|
+
const t = titleOf(line);
|
|
118
|
+
if (t) f.title = t;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return f.title;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
} finally {
|
|
125
|
+
if (fd !== undefined)
|
|
126
|
+
try {
|
|
127
|
+
fs.closeSync(fd);
|
|
128
|
+
} catch {
|
|
129
|
+
/* closed */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Where a followed file is up to (tests). */
|
|
135
|
+
export function followState(file: string): { offset: number } | undefined {
|
|
136
|
+
const f = followers.get(file);
|
|
137
|
+
return f ? { offset: f.offset } : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Forget every followed file (tests). */
|
|
141
|
+
export function resetFollowers(): void {
|
|
142
|
+
followers.clear();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The most of an unended OSC kept between chunks. */
|
|
146
|
+
const MAX_CARRY = 1024;
|
|
147
|
+
const OSC_START = "\u001b]";
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The terminal titles (OSC 0 and OSC 2, ended by BEL or ST) in `chunk`,
|
|
151
|
+
* with `carry` the unended OSC the last chunk left; returns what this one
|
|
152
|
+
* leaves. Every other OSC (a link, the cwd) is not a title.
|
|
153
|
+
*/
|
|
154
|
+
export function oscTitles(
|
|
155
|
+
chunk: string,
|
|
156
|
+
carry: string,
|
|
157
|
+
): { titles: string[]; carry: string } {
|
|
158
|
+
const s = carry + chunk;
|
|
159
|
+
const titles: string[] = [];
|
|
160
|
+
let i = 0;
|
|
161
|
+
for (;;) {
|
|
162
|
+
const at = s.indexOf(OSC_START, i);
|
|
163
|
+
if (at < 0) {
|
|
164
|
+
// A lone ESC at the very end may start an OSC in the next chunk.
|
|
165
|
+
return { titles, carry: s.endsWith("\u001b") ? "\u001b" : "" };
|
|
166
|
+
}
|
|
167
|
+
const bel = s.indexOf("\u0007", at + 2);
|
|
168
|
+
const st = s.indexOf("\u001b\\", at + 2);
|
|
169
|
+
const ends = [bel, st].filter((x) => x >= 0);
|
|
170
|
+
if (!ends.length) return { titles, carry: s.slice(at, at + MAX_CARRY) };
|
|
171
|
+
const stop = Math.min(...ends);
|
|
172
|
+
const body = s.slice(at + 2, stop);
|
|
173
|
+
const m = /^([02]);([\s\S]*)$/.exec(body);
|
|
174
|
+
if (m) {
|
|
175
|
+
const t = cleanName(m[2]);
|
|
176
|
+
if (t) titles.push(t);
|
|
177
|
+
}
|
|
178
|
+
i = stop + (stop === bel ? 1 : 2);
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/shared/den-config.ts
CHANGED
|
@@ -339,6 +339,11 @@ export interface UserConfig {
|
|
|
339
339
|
* editor instead of the default.
|
|
340
340
|
*/
|
|
341
341
|
editor_overrides?: Record<string, string>;
|
|
342
|
+
/**
|
|
343
|
+
* Where a file path in an agent's pane opens (AG-4b): vscode, cursor or
|
|
344
|
+
* zed by URL scheme, or none (copy the path). editor-open-pref.ts.
|
|
345
|
+
*/
|
|
346
|
+
editor_open?: string;
|
|
342
347
|
/**
|
|
343
348
|
* Personal override of the team release config (config/den.yml `release:`).
|
|
344
349
|
* Wave-assignment edits made in the Release tab are saved here so they stay
|
|
@@ -397,6 +402,8 @@ export interface UserConfig {
|
|
|
397
402
|
* (the default), 1d or 7d. Interrupted agents are never forgotten.
|
|
398
403
|
*/
|
|
399
404
|
remove_stopped_after?: unknown;
|
|
405
|
+
/** AG-4b: the Agents tab's layout (src/ui/server/agents-layout-pref.ts). */
|
|
406
|
+
layout?: unknown;
|
|
400
407
|
};
|
|
401
408
|
/**
|
|
402
409
|
* The web server's per-machine keys (src/shared/web-session.ts
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
type CatalogEntry,
|
|
72
72
|
} from "../../shared/agents/catalog.js";
|
|
73
73
|
import { checkRuntimeBin } from "../../shared/agents/catalog-runtime.js";
|
|
74
|
+
import { agentSessionName } from "../../shared/agents/session-name.js";
|
|
74
75
|
import { cliEntrypoint } from "../../shared/engine.js";
|
|
75
76
|
import { getDistRoot } from "../../shared/paths.js";
|
|
76
77
|
import { envKey } from "../../shared/product.js";
|
|
@@ -89,6 +90,7 @@ import {
|
|
|
89
90
|
removeRecord,
|
|
90
91
|
restoreRecord,
|
|
91
92
|
writeRecord,
|
|
93
|
+
checkDisplayName,
|
|
92
94
|
writeRuntimeFiles,
|
|
93
95
|
type AgentRecord,
|
|
94
96
|
} from "../../shared/agents/records.js";
|
|
@@ -355,6 +357,13 @@ export function agentMcp(
|
|
|
355
357
|
*/
|
|
356
358
|
export interface AgentRow {
|
|
357
359
|
agent: string;
|
|
360
|
+
/** The name a person gave it (AG-4b), when one did. */
|
|
361
|
+
displayName?: string;
|
|
362
|
+
/**
|
|
363
|
+
* Its session's name (AG-4b): the latest of Den's rename and the
|
|
364
|
+
* runtime's own (Claude Code's /rename), when there is one.
|
|
365
|
+
*/
|
|
366
|
+
sessionName?: string;
|
|
358
367
|
persona?: string;
|
|
359
368
|
runtime?: string;
|
|
360
369
|
model?: string;
|
|
@@ -460,9 +469,11 @@ export function agentRows(
|
|
|
460
469
|
isStoppedState(st) && !resumable
|
|
461
470
|
? (resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null))
|
|
462
471
|
: null;
|
|
472
|
+
const named = stateDir ? agentSessionName(rec, stateDir) : undefined;
|
|
463
473
|
return {
|
|
464
474
|
agent: rec.id,
|
|
465
475
|
...recordFields(rec),
|
|
476
|
+
...(named ? { sessionName: named } : {}),
|
|
466
477
|
state: st,
|
|
467
478
|
startedAt: s?.startedAt ?? rec.startedAt,
|
|
468
479
|
resumable,
|
|
@@ -489,6 +500,7 @@ export function agentRows(
|
|
|
489
500
|
|
|
490
501
|
function recordFields(rec: AgentRecord): Partial<AgentRow> {
|
|
491
502
|
return {
|
|
503
|
+
...(rec.displayName ? { displayName: rec.displayName } : {}),
|
|
492
504
|
persona: rec.persona,
|
|
493
505
|
runtime: rec.runtime,
|
|
494
506
|
...(rec.model ? { model: rec.model } : {}),
|
|
@@ -1171,6 +1183,12 @@ export async function startAgent(
|
|
|
1171
1183
|
workspace: ws,
|
|
1172
1184
|
cwd: input.worktree,
|
|
1173
1185
|
sessionName: sessionName(d.dist, ws, id),
|
|
1186
|
+
// AG-4b: a name given to the agent is the agent's, not its
|
|
1187
|
+
// conversation's: Start fresh and Relaunch keep it.
|
|
1188
|
+
...(prev?.displayName ? { displayName: prev.displayName } : {}),
|
|
1189
|
+
...(prev?.displayName && prev.displayNameAt
|
|
1190
|
+
? { displayNameAt: prev.displayNameAt }
|
|
1191
|
+
: {}),
|
|
1174
1192
|
// 0.4.8: launched on this token, so a resume continues it.
|
|
1175
1193
|
...(out.resumeToken
|
|
1176
1194
|
? { resumeToken: out.resumeToken, launched: true }
|
|
@@ -1212,6 +1230,37 @@ export function agentRecord(
|
|
|
1212
1230
|
return rec;
|
|
1213
1231
|
}
|
|
1214
1232
|
|
|
1233
|
+
/**
|
|
1234
|
+
* Name agent `id` (AG-4b: a person double-clicked its tab): `name` as
|
|
1235
|
+
* checkDisplayName takes it; null or blank clears it. The id never
|
|
1236
|
+
* changes. A usage error (400) when the name isn't one.
|
|
1237
|
+
*/
|
|
1238
|
+
export function renameAgent(
|
|
1239
|
+
d: AgentsResolved,
|
|
1240
|
+
id: string,
|
|
1241
|
+
ws: string,
|
|
1242
|
+
name: unknown,
|
|
1243
|
+
): AgentRecord {
|
|
1244
|
+
const rec = agentRecord(d, id, ws);
|
|
1245
|
+
let checked: string | null;
|
|
1246
|
+
try {
|
|
1247
|
+
checked = checkDisplayName(name);
|
|
1248
|
+
} catch (e) {
|
|
1249
|
+
throw usage((e as Error).message);
|
|
1250
|
+
}
|
|
1251
|
+
const next: AgentRecord = { ...rec };
|
|
1252
|
+
if (checked) {
|
|
1253
|
+
next.displayName = checked;
|
|
1254
|
+
// AG-4b: the latest rename wins, Den's or the runtime's.
|
|
1255
|
+
next.displayNameAt = new Date().toISOString();
|
|
1256
|
+
} else {
|
|
1257
|
+
delete next.displayName;
|
|
1258
|
+
delete next.displayNameAt;
|
|
1259
|
+
}
|
|
1260
|
+
writeRecord(d.stateDir(), next);
|
|
1261
|
+
return next;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1215
1264
|
export interface Stopped {
|
|
1216
1265
|
rec: AgentRecord;
|
|
1217
1266
|
/** The session it sent TERM to; undefined when it wasn't running. */
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which file paths an agent printed are links (AG-4b): GET
|
|
3
|
+
* /api/workspaces/<ws>/agents/<id>/paths?p=<path>&p=<path>… answers
|
|
4
|
+
* { paths: { <path as given>: <absolute path> | null } }.
|
|
5
|
+
*
|
|
6
|
+
* A path is resolved against the agent's cwd (an absolute one stays), and
|
|
7
|
+
* is a link only when it exists and its real path (symlinks followed) is
|
|
8
|
+
* inside the agent's workspace: the workspace's directory, or the agent's
|
|
9
|
+
* own cwd (a worktree of it). Anything else is null, so a path outside
|
|
10
|
+
* the workspace is never a link and the page never learns whether it
|
|
11
|
+
* exists. Only stat is used: nothing is read, nothing is opened.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
|
|
16
|
+
/** The most paths one ask takes (a screen's worth). */
|
|
17
|
+
export const MAX_PATHS = 64;
|
|
18
|
+
/** The longest path taken. */
|
|
19
|
+
const MAX_PATH_LEN = 1024;
|
|
20
|
+
|
|
21
|
+
function realOrNull(p: string): string | null {
|
|
22
|
+
try {
|
|
23
|
+
return fs.realpathSync(p);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const inside = (p: string, root: string) =>
|
|
30
|
+
p === root || p.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
31
|
+
|
|
32
|
+
/** Each path's absolute path when it is a link, else null. */
|
|
33
|
+
export function agentPathLinks(
|
|
34
|
+
given: readonly string[],
|
|
35
|
+
cwd: string,
|
|
36
|
+
roots: readonly string[],
|
|
37
|
+
): Record<string, string | null> {
|
|
38
|
+
const real = roots
|
|
39
|
+
.map((r) => realOrNull(r))
|
|
40
|
+
.filter((r): r is string => r !== null);
|
|
41
|
+
const base = realOrNull(cwd) ?? path.resolve(cwd);
|
|
42
|
+
const out: Record<string, string | null> = {};
|
|
43
|
+
for (const p of given) {
|
|
44
|
+
if (Object.hasOwn(out, p)) continue;
|
|
45
|
+
out[p] = null;
|
|
46
|
+
if (!p || p.length > MAX_PATH_LEN || p.includes("\0")) continue;
|
|
47
|
+
const abs = path.resolve(base, p);
|
|
48
|
+
const r = realOrNull(abs);
|
|
49
|
+
if (r && real.some((root) => inside(r, root))) out[p] = r;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
* first prompt is its
|
|
24
24
|
* handoff.md; a resume of
|
|
25
25
|
* one is refused (409 rotated)
|
|
26
|
+
* POST /api/workspaces/<ws>/agents/<id>/name { name: string | null }: the
|
|
27
|
+
* name its tab and the lists
|
|
28
|
+
* show (AG-4b); the id never
|
|
29
|
+
* changes; null or "" clears
|
|
30
|
+
* GET /api/workspaces/<ws>/agents/<id>/paths?p=… which printed paths are
|
|
31
|
+
* links (AG-4b): inside its
|
|
32
|
+
* workspace and there (stat
|
|
33
|
+
* only; agent-paths.ts)
|
|
26
34
|
* POST /api/workspaces/<ws>/agents/<id>/remove forget a stopped one (Undo:
|
|
27
35
|
* /api/agents/restore)
|
|
28
36
|
* POST /api/agents/clear-stopped { workspaces: [...] }: forget
|
|
@@ -74,6 +82,7 @@ import {
|
|
|
74
82
|
NOT_IN_WORKSPACE,
|
|
75
83
|
pruneStopped,
|
|
76
84
|
removeAgent,
|
|
85
|
+
renameAgent,
|
|
77
86
|
removeStopped,
|
|
78
87
|
restoreAgents,
|
|
79
88
|
resumeInterrupted,
|
|
@@ -88,7 +97,9 @@ import {
|
|
|
88
97
|
type AgentsResolved,
|
|
89
98
|
type Started,
|
|
90
99
|
} from "../cli/agent-ops.js";
|
|
100
|
+
import { agentSessionName } from "../../shared/agents/session-name.js";
|
|
91
101
|
import { recordAction } from "./audit.js";
|
|
102
|
+
import { agentPathLinks, MAX_PATHS } from "./agent-paths.js";
|
|
92
103
|
import {
|
|
93
104
|
agentPrincipalOf,
|
|
94
105
|
auditActorOf,
|
|
@@ -104,7 +115,7 @@ export interface AgentRouteDeps {
|
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
const AGENTS_PATH =
|
|
107
|
-
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove))?$/;
|
|
118
|
+
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths))?$/;
|
|
108
119
|
|
|
109
120
|
const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
|
|
110
121
|
/** A start's body is a few short names. */
|
|
@@ -163,10 +174,19 @@ function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
|
|
|
163
174
|
throw e;
|
|
164
175
|
}
|
|
165
176
|
|
|
177
|
+
function sessionNameOf(
|
|
178
|
+
rec: Started["rec"],
|
|
179
|
+
d: AgentsResolved,
|
|
180
|
+
): { sessionName?: string } {
|
|
181
|
+
const n = agentSessionName(rec, d.stateDir());
|
|
182
|
+
return n ? { sessionName: n } : {};
|
|
183
|
+
}
|
|
184
|
+
|
|
166
185
|
/** A started or resumed agent's answer: its record, or its early exit. */
|
|
167
186
|
function sendStarted(
|
|
168
187
|
res: http.ServerResponse,
|
|
169
188
|
s: Started & { fresh?: boolean },
|
|
189
|
+
d: AgentsResolved,
|
|
170
190
|
): void {
|
|
171
191
|
if (s.early) {
|
|
172
192
|
const code =
|
|
@@ -197,6 +217,10 @@ function sendStarted(
|
|
|
197
217
|
// The host's id: the Agents tab opens its pane by it (?open=<id>).
|
|
198
218
|
sessionId: s.info.ref.id,
|
|
199
219
|
startedAt: s.rec.startedAt,
|
|
220
|
+
...(s.rec.displayName ? { displayName: s.rec.displayName } : {}),
|
|
221
|
+
// AG-4b: its session's name (Den's rename or the runtime's), as the
|
|
222
|
+
// lists show it (MCP agent_resume answers with this).
|
|
223
|
+
...sessionNameOf(s.rec, d),
|
|
200
224
|
},
|
|
201
225
|
// A resume with nothing to continue yet started fresh (same id).
|
|
202
226
|
...(s.fresh ? { fresh: true } : {}),
|
|
@@ -294,6 +318,8 @@ export async function handleAgentRoutes(
|
|
|
294
318
|
| "fresh"
|
|
295
319
|
| "relaunch"
|
|
296
320
|
| "remove"
|
|
321
|
+
| "name"
|
|
322
|
+
| "paths"
|
|
297
323
|
| undefined;
|
|
298
324
|
|
|
299
325
|
if (!action && method === "GET") {
|
|
@@ -313,6 +339,38 @@ export async function handleAgentRoutes(
|
|
|
313
339
|
}
|
|
314
340
|
return true;
|
|
315
341
|
}
|
|
342
|
+
// AG-4b: which paths an agent printed are links (read-only: stat only).
|
|
343
|
+
if (action === "paths") {
|
|
344
|
+
if (method !== "GET" && method !== "HEAD")
|
|
345
|
+
return methodNotAllowed(res, "GET");
|
|
346
|
+
try {
|
|
347
|
+
const rec = agentRecord(d, id!, ws);
|
|
348
|
+
const given = new URL(
|
|
349
|
+
req.url ?? "/",
|
|
350
|
+
"http://localhost",
|
|
351
|
+
).searchParams.getAll("p");
|
|
352
|
+
if (given.length > MAX_PATHS)
|
|
353
|
+
throw new AgentError(`at most ${MAX_PATHS} paths at once`, {
|
|
354
|
+
usage: true,
|
|
355
|
+
});
|
|
356
|
+
let wsDir: string | null = null;
|
|
357
|
+
try {
|
|
358
|
+
wsDir = d.workspaceDir(ws);
|
|
359
|
+
} catch {
|
|
360
|
+
wsDir = null;
|
|
361
|
+
}
|
|
362
|
+
sendJson(res, {
|
|
363
|
+
paths: agentPathLinks(
|
|
364
|
+
given,
|
|
365
|
+
rec.cwd,
|
|
366
|
+
[wsDir, rec.cwd].filter((x): x is string => !!x),
|
|
367
|
+
),
|
|
368
|
+
});
|
|
369
|
+
} catch (e) {
|
|
370
|
+
sendError(res, e, ws);
|
|
371
|
+
}
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
316
374
|
if (method !== "POST")
|
|
317
375
|
return methodNotAllowed(res, action ? "POST" : "GET, POST");
|
|
318
376
|
|
|
@@ -354,7 +412,7 @@ export async function handleAgentRoutes(
|
|
|
354
412
|
});
|
|
355
413
|
what = `start ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
|
|
356
414
|
audit(what, s.early ? "exited at once" : undefined);
|
|
357
|
-
sendStarted(res, s);
|
|
415
|
+
sendStarted(res, s, d);
|
|
358
416
|
} else if (verb === "stop") {
|
|
359
417
|
// The answer comes once the session has ended (200, stopped: true),
|
|
360
418
|
// or, still running after the wait, 202 with stillStopping: true.
|
|
@@ -375,12 +433,25 @@ export async function handleAgentRoutes(
|
|
|
375
433
|
const s = await startFresh(d, id!, ws);
|
|
376
434
|
what = `start ${s.rec.id} fresh (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
|
|
377
435
|
audit(what, s.early ? "exited at once" : undefined);
|
|
378
|
-
sendStarted(res, s);
|
|
436
|
+
sendStarted(res, s, d);
|
|
379
437
|
} else if (verb === "relaunch") {
|
|
380
438
|
const s = await relaunchAgent(d, id!, ws);
|
|
381
439
|
what = `relaunch ${s.rec.id} from its handoff (${s.why}) in ${ws}`;
|
|
382
440
|
audit(what, s.early ? "exited at once" : undefined);
|
|
383
|
-
sendStarted(res, s);
|
|
441
|
+
sendStarted(res, s, d);
|
|
442
|
+
} else if (verb === "name") {
|
|
443
|
+
const body = await jsonBody(req);
|
|
444
|
+
if (!("name" in body))
|
|
445
|
+
throw new AgentError("name is required (null clears it)", {
|
|
446
|
+
usage: true,
|
|
447
|
+
});
|
|
448
|
+
const rec = renameAgent(d, id!, ws, body.name);
|
|
449
|
+
audit(what);
|
|
450
|
+
sendJson(res, {
|
|
451
|
+
ok: true,
|
|
452
|
+
agent: rec.id,
|
|
453
|
+
...(rec.displayName ? { displayName: rec.displayName } : {}),
|
|
454
|
+
});
|
|
384
455
|
} else if (verb === "remove") {
|
|
385
456
|
const removed = await removeAgent(d, id!, ws);
|
|
386
457
|
audit(what);
|
|
@@ -388,7 +459,7 @@ export async function handleAgentRoutes(
|
|
|
388
459
|
} else {
|
|
389
460
|
const s = await resumeAgent(d, id!, ws);
|
|
390
461
|
audit(what);
|
|
391
|
-
sendStarted(res, s);
|
|
462
|
+
sendStarted(res, s, d);
|
|
392
463
|
}
|
|
393
464
|
} catch (e) {
|
|
394
465
|
audit(what, e instanceof Error ? e.message : String(e));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agents tab's layout (AG-4b), a person's view preference: Focus (one
|
|
3
|
+
* pane, the default), Tabs or Tile. Stored in the user config as
|
|
4
|
+
* `agents.layout` and served by /api/settings as `agents_layout`, like the
|
|
5
|
+
* other view prefs (graph-prefs.ts, shell-prefs.ts). It changes no session
|
|
6
|
+
* and no agent: only how the page lays out their terminals.
|
|
7
|
+
*/
|
|
8
|
+
import type { UserConfig } from "../../shared/den-config.js";
|
|
9
|
+
|
|
10
|
+
export const AGENTS_LAYOUTS = ["focus", "tabs", "tile"] as const;
|
|
11
|
+
export type AgentsLayout = (typeof AGENTS_LAYOUTS)[number];
|
|
12
|
+
export const AGENTS_LAYOUT_DEFAULT: AgentsLayout = "focus";
|
|
13
|
+
|
|
14
|
+
const isLayout = (v: unknown): v is AgentsLayout =>
|
|
15
|
+
typeof v === "string" && (AGENTS_LAYOUTS as readonly string[]).includes(v);
|
|
16
|
+
|
|
17
|
+
/** The stored layout, or the default when unset or hand-broken. */
|
|
18
|
+
export function agentsLayout(user: UserConfig): AgentsLayout {
|
|
19
|
+
const v = (user.agents as { layout?: unknown } | undefined)?.layout;
|
|
20
|
+
return isLayout(v) ? v : AGENTS_LAYOUT_DEFAULT;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Apply a POSTed `agents_layout` to `user` (in place): a layout sets
|
|
25
|
+
* agents.layout, null clears it (and an agents block left empty goes).
|
|
26
|
+
* Anything else: the error to answer with.
|
|
27
|
+
*/
|
|
28
|
+
export function patchAgentsLayout(
|
|
29
|
+
user: UserConfig,
|
|
30
|
+
v: unknown,
|
|
31
|
+
): { ok: true } | { ok: false; error: string } {
|
|
32
|
+
if (v !== null && !isLayout(v))
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: `agents_layout must be one of ${AGENTS_LAYOUTS.map((l) => `"${l}"`).join(", ")}, or null`,
|
|
36
|
+
};
|
|
37
|
+
const agents = { ...(user.agents ?? {}) } as Record<string, unknown>;
|
|
38
|
+
if (v === null) delete agents.layout;
|
|
39
|
+
else agents.layout = v;
|
|
40
|
+
if (Object.keys(agents).length) user.agents = agents as UserConfig["agents"];
|
|
41
|
+
else delete user.agents;
|
|
42
|
+
return { ok: true };
|
|
43
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a file path in an agent's pane opens (AG-4b): ⌘-click (Ctrl-click
|
|
3
|
+
* elsewhere) opens it in this editor by its URL scheme, at the line
|
|
4
|
+
* (vscode://file/…, cursor://file/…, zed://file/…), or, with `none` (the
|
|
5
|
+
* default), copies its absolute path. A person's view preference, stored
|
|
6
|
+
* in the user config as `editor_open` (the `editor` key is already the
|
|
7
|
+
* repo editor's, ↗) and served by /api/settings as `editor_open`. Nothing
|
|
8
|
+
* here runs an editor: the browser hands the URL to the OS.
|
|
9
|
+
*/
|
|
10
|
+
import type { UserConfig } from "../../shared/den-config.js";
|
|
11
|
+
|
|
12
|
+
export const EDITOR_OPENS = ["vscode", "cursor", "zed", "none"] as const;
|
|
13
|
+
export type EditorOpen = (typeof EDITOR_OPENS)[number];
|
|
14
|
+
export const EDITOR_OPEN_DEFAULT: EditorOpen = "none";
|
|
15
|
+
|
|
16
|
+
const isEditorOpen = (v: unknown): v is EditorOpen =>
|
|
17
|
+
typeof v === "string" && (EDITOR_OPENS as readonly string[]).includes(v);
|
|
18
|
+
|
|
19
|
+
/** The stored choice, or none when unset or hand-broken. */
|
|
20
|
+
export function editorOpen(user: UserConfig): EditorOpen {
|
|
21
|
+
const v = user.editor_open;
|
|
22
|
+
return isEditorOpen(v) ? v : EDITOR_OPEN_DEFAULT;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Apply a POSTed `editor_open` to `user` (in place): an editor sets it,
|
|
27
|
+
* null clears it. Anything else: the error to answer with.
|
|
28
|
+
*/
|
|
29
|
+
export function patchEditorOpen(
|
|
30
|
+
user: UserConfig,
|
|
31
|
+
v: unknown,
|
|
32
|
+
): { ok: true } | { ok: false; error: string } {
|
|
33
|
+
if (v !== null && !isEditorOpen(v))
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
error: `editor_open must be one of ${EDITOR_OPENS.map((e) => `"${e}"`).join(", ")}, or null`,
|
|
37
|
+
};
|
|
38
|
+
if (v === null) delete user.editor_open;
|
|
39
|
+
else user.editor_open = v;
|
|
40
|
+
return { ok: true };
|
|
41
|
+
}
|
package/src/ui/server/index.ts
CHANGED
|
@@ -256,6 +256,8 @@ import {
|
|
|
256
256
|
sshTarget,
|
|
257
257
|
} from "../../shared/cli-command.js";
|
|
258
258
|
import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
|
|
259
|
+
import { agentsLayout, patchAgentsLayout } from "./agents-layout-pref.js";
|
|
260
|
+
import { editorOpen, patchEditorOpen } from "./editor-open-pref.js";
|
|
259
261
|
import { graphPageHtml } from "./graph-page.js";
|
|
260
262
|
import { mergeShellPrefs, shellPrefs, storedShell } from "./shell-prefs.js";
|
|
261
263
|
import { distributionSettings, userWebPort } from "./settings-distribution.js";
|
|
@@ -4467,6 +4469,10 @@ async function handleRequest(
|
|
|
4467
4469
|
// graph: { direction, group, density, panelWidth } (see graph-prefs.ts)
|
|
4468
4470
|
// appearance: { theme: id | null, mode: system|dark|light } (appearance-prefs.ts)
|
|
4469
4471
|
// shell: { pins, collapsed, hidden, order, dock, dockHeight } (shell-prefs.ts)
|
|
4472
|
+
// agents_layout: focus | tabs | tile, the Agents tab's layout (AG-4b,
|
|
4473
|
+
// agents-layout-pref.ts; user.yml agents.layout); null clears it
|
|
4474
|
+
// editor_open: vscode | cursor | zed | none, where a path in an agent's
|
|
4475
|
+
// pane opens (AG-4b, editor-open-pref.ts); null clears it (none)
|
|
4470
4476
|
// language: the UI's language, en | ja | es (ui-language.ts); null or
|
|
4471
4477
|
// "en" clears it (English is the default)
|
|
4472
4478
|
// It writes config/local/user.yml only. `distribution` is read-only: what
|
|
@@ -4512,6 +4518,8 @@ async function handleRequest(
|
|
|
4512
4518
|
graph: graphPrefs(user.graph),
|
|
4513
4519
|
appearance: appearancePrefs(user.appearance),
|
|
4514
4520
|
shell: shellPrefs(user.shell),
|
|
4521
|
+
agents_layout: agentsLayout(user),
|
|
4522
|
+
editor_open: editorOpen(user),
|
|
4515
4523
|
distribution: distributionSettings(getDistRoot()),
|
|
4516
4524
|
// Set from the command line (`<cli> config`), shown read-only.
|
|
4517
4525
|
user_web_port: userWebPort(user as Record<string, unknown>),
|
|
@@ -4537,8 +4545,20 @@ async function handleRequest(
|
|
|
4537
4545
|
shell?: unknown;
|
|
4538
4546
|
language?: unknown;
|
|
4539
4547
|
machine_name?: unknown;
|
|
4548
|
+
agents_layout?: unknown;
|
|
4549
|
+
editor_open?: unknown;
|
|
4540
4550
|
};
|
|
4541
4551
|
const user = readUserConfig();
|
|
4552
|
+
// AG-4b: the Agents tab's layout, a view preference only.
|
|
4553
|
+
if (body.agents_layout !== undefined) {
|
|
4554
|
+
const l = patchAgentsLayout(user, body.agents_layout);
|
|
4555
|
+
if (!l.ok) return send400(res, l.error);
|
|
4556
|
+
}
|
|
4557
|
+
// AG-4b: where a path link in an agent's pane opens.
|
|
4558
|
+
if (body.editor_open !== undefined) {
|
|
4559
|
+
const e = patchEditorOpen(user, body.editor_open);
|
|
4560
|
+
if (!e.ok) return send400(res, e.error);
|
|
4561
|
+
}
|
|
4542
4562
|
// SEC-2a: web.machine_name (user.yml); null or "" clears it.
|
|
4543
4563
|
if (body.machine_name !== undefined) {
|
|
4544
4564
|
let name: string | null;
|
|
@@ -4648,6 +4668,8 @@ async function handleRequest(
|
|
|
4648
4668
|
shell: shellPrefs(user.shell),
|
|
4649
4669
|
language: uiLanguage(user.language),
|
|
4650
4670
|
machine_name: machineNameSetting(user),
|
|
4671
|
+
agents_layout: agentsLayout(user),
|
|
4672
|
+
editor_open: editorOpen(user),
|
|
4651
4673
|
});
|
|
4652
4674
|
} catch (e) {
|
|
4653
4675
|
return send400(res, `Invalid request: ${(e as Error).message}`);
|