@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.
@@ -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
+ }
@@ -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
@@ -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
+ }
@@ -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}`);