@monster-orange/den 0.4.11 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +212 -12
  4. package/package.json +2 -1
  5. package/src/shared/agents/catalog.ts +77 -0
  6. package/src/shared/agents/default-pack/be-dev.md +1 -0
  7. package/src/shared/agents/default-pack/devops.md +1 -0
  8. package/src/shared/agents/default-pack/fe-dev.md +1 -0
  9. package/src/shared/agents/default-pack/orchestrator.md +3 -1
  10. package/src/shared/agents/default-pack/planner.md +1 -0
  11. package/src/shared/agents/default-pack/reviewer.md +1 -0
  12. package/src/shared/agents/default-pack/tester.md +16 -0
  13. package/src/shared/agents/default-pack/ux-designer.md +1 -0
  14. package/src/shared/agents/den-claude.ts +151 -17
  15. package/src/shared/agents/permissions.ts +108 -0
  16. package/src/shared/agents/persona-store.ts +286 -0
  17. package/src/shared/agents/personas.ts +116 -12
  18. package/src/shared/agents/records.ts +57 -0
  19. package/src/shared/agents/roles.ts +288 -0
  20. package/src/shared/agents/runtime.ts +49 -0
  21. package/src/shared/agents/session-name.ts +110 -0
  22. package/src/shared/agents/transcript-follow.ts +180 -0
  23. package/src/shared/den-config.ts +27 -1
  24. package/src/ui/cli/agent-ops.ts +578 -11
  25. package/src/ui/cli/agents-command.ts +12 -2
  26. package/src/ui/cli/env-probe.ts +4 -1
  27. package/src/ui/mcp/tools.ts +1 -1
  28. package/src/ui/server/agent-paths.ts +52 -0
  29. package/src/ui/server/agent-routes.ts +329 -19
  30. package/src/ui/server/agents-layout-pref.ts +43 -0
  31. package/src/ui/server/editor-open-pref.ts +41 -0
  32. package/src/ui/server/index.ts +22 -0
  33. package/src/ui/server/public/agent-start.js +3 -2
  34. package/src/ui/server/public/agents-page.js +1037 -67
  35. package/src/ui/server/public/agents-section.js +1 -1
  36. package/src/ui/server/public/agents.html +236 -20
  37. package/src/ui/server/public/header.js +2 -0
  38. package/src/ui/server/public/i18n/es.js +139 -0
  39. package/src/ui/server/public/i18n/ja.js +139 -0
  40. package/src/ui/server/public/persona-ui.css +539 -0
  41. package/src/ui/server/public/persona-ui.js +796 -0
  42. package/src/ui/server/public/settings-page.js +4 -9
  43. package/src/ui/server/public/settings-registry.js +66 -0
  44. package/src/ui/server/public/settings.html +1 -0
  45. package/src/ui/server/public/shell.js +137 -0
  46. package/src/ui/server/public/term-input.js +129 -0
  47. package/src/ui/server/public/topbar.js +22 -4
  48. package/src/ui/server/route-classes.ts +29 -0
  49. package/src/ui/server/session-routes.ts +34 -1
  50. package/src/ui/server/ui-assets.ts +8 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * A person's own personas (AG-10.3, AG-10.7): <state_dir>/personas/<id>.md,
3
+ * each 0600 in a 0700 directory, written in the same format a pack uses
4
+ * (Claude Code's agent markdown plus Den's keys: personas.ts), so a file
5
+ * made here reads like one made by hand.
6
+ *
7
+ * Only these files are ever written or removed: a built-in or a
8
+ * distribution's persona is read-only, and editing one makes a local
9
+ * duplicate first (the caller's choice: the routes do). Every field is
10
+ * checked strictly (checkPersonaInput): an unknown key, a control
11
+ * character, a role, category, runtime, model or version that isn't
12
+ * registered, or a command that isn't a plain prefix is refused, naming
13
+ * the field. Nothing here runs a command or passes text to a shell.
14
+ */
15
+ import * as fs from "fs";
16
+ import * as path from "path";
17
+ import * as yaml from "yaml";
18
+ import { writePrivate } from "./records.js";
19
+ import { PERSONA_ID_RE } from "./personas.js";
20
+ import {
21
+ checkCommands,
22
+ checkIntent,
23
+ type PermissionIntent,
24
+ } from "./permissions.js";
25
+ import type { RuntimeModel } from "./runtime.js";
26
+
27
+ /** Where a person's own personas live. */
28
+ export function localPersonasDir(stateDir: string): string {
29
+ return path.join(stateDir, "personas");
30
+ }
31
+
32
+ /** What New persona and Edit send, checked. */
33
+ export interface PersonaInput {
34
+ displayName?: string;
35
+ role: string;
36
+ category: string;
37
+ description: string;
38
+ instructions: string;
39
+ runtime: string;
40
+ model?: string;
41
+ version: string;
42
+ permission: PermissionIntent;
43
+ allowedCommands: string[];
44
+ }
45
+
46
+ /** What a field may name: the registries and runtimes it is checked against. */
47
+ export interface PersonaChoices {
48
+ roles: readonly string[];
49
+ categories: readonly string[];
50
+ runtimes: readonly { id: string; models: readonly RuntimeModel[] }[];
51
+ }
52
+
53
+ export const MAX_INSTRUCTIONS = 20_000;
54
+ export const MAX_DESCRIPTION = 200;
55
+ export const MAX_PERSONA_NAME = 40;
56
+
57
+ const KEYS = [
58
+ "displayName",
59
+ "role",
60
+ "category",
61
+ "description",
62
+ "instructions",
63
+ "runtime",
64
+ "model",
65
+ "version",
66
+ "permission",
67
+ "allowedCommands",
68
+ ] as const;
69
+
70
+ /** A usage error: a field a person sent isn't what it may be. */
71
+ export class PersonaInputError extends Error {
72
+ constructor(message: string) {
73
+ super(message);
74
+ this.name = "PersonaInputError";
75
+ }
76
+ }
77
+
78
+ const shown = (v: unknown) =>
79
+ (JSON.stringify(v) ?? "undefined").replace(/[^\x20-\x7e]/g, "?").slice(0, 60);
80
+
81
+ function oneLine(
82
+ key: string,
83
+ v: unknown,
84
+ max: number,
85
+ required: boolean,
86
+ ): string | undefined {
87
+ if (v === undefined || v === null || v === "") {
88
+ if (required) throw new PersonaInputError(`${key} is required`);
89
+ return undefined;
90
+ }
91
+ if (typeof v !== "string")
92
+ throw new PersonaInputError(`${key} must be a string`);
93
+ const s = v.trim();
94
+ if (!s) {
95
+ if (required) throw new PersonaInputError(`${key} is required`);
96
+ return undefined;
97
+ }
98
+ if ([...s].length > max)
99
+ throw new PersonaInputError(`${key} must be at most ${max} characters`);
100
+ if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(s))
101
+ throw new PersonaInputError(`${key} can't have control characters`);
102
+ return s;
103
+ }
104
+
105
+ function oneOf(key: string, v: unknown, list: readonly string[]): string {
106
+ if (typeof v !== "string" || !list.includes(v))
107
+ throw new PersonaInputError(
108
+ `${key}: ${shown(v)} is not one of ${list.join(", ") || "(none)"}`,
109
+ );
110
+ return v;
111
+ }
112
+
113
+ /**
114
+ * A model and version for runtime `rt` (AG-12.3): the model one it offers
115
+ * (none: no model), the version "latest" or one of that model's.
116
+ */
117
+ export function checkModelChoice(
118
+ rt: { id: string; models: readonly RuntimeModel[] },
119
+ model: unknown,
120
+ version: unknown,
121
+ ): { model?: string; version: string } {
122
+ const ver =
123
+ version === undefined || version === null || version === ""
124
+ ? "latest"
125
+ : version;
126
+ if (model === undefined || model === null || model === "") {
127
+ if (ver !== "latest") throw new PersonaInputError("version needs a model");
128
+ return { version: "latest" };
129
+ }
130
+ if (!rt.models.length)
131
+ throw new PersonaInputError(`${rt.id} takes no model choice from Den`);
132
+ const m = rt.models.find((x) => x.id === model);
133
+ if (!m)
134
+ throw new PersonaInputError(
135
+ `model: ${shown(model)} is not one of ${rt.id}'s (${rt.models.map((x) => x.id).join(", ")})`,
136
+ );
137
+ if (
138
+ ver !== "latest" &&
139
+ !(typeof ver === "string" && m.versions.includes(ver))
140
+ )
141
+ throw new PersonaInputError(
142
+ `version: ${shown(ver)} is not "latest" or one of ${m.id}'s (${m.versions.join(", ")})`,
143
+ );
144
+ return { model: m.id, version: ver as string };
145
+ }
146
+
147
+ /**
148
+ * A persona as New persona or Edit sends it, checked against `choices`.
149
+ * Throws PersonaInputError naming the first field that is wrong.
150
+ */
151
+ export function checkPersonaInput(
152
+ raw: unknown,
153
+ choices: PersonaChoices,
154
+ ): PersonaInput {
155
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
156
+ throw new PersonaInputError("a persona must be a JSON object");
157
+ const r = raw as Record<string, unknown>;
158
+ for (const k of Object.keys(r))
159
+ if (!(KEYS as readonly string[]).includes(k))
160
+ throw new PersonaInputError(
161
+ `${k}: unknown field (a persona takes ${KEYS.join(", ")})`,
162
+ );
163
+ const role = oneOf("role", r.role, choices.roles);
164
+ const category = oneOf("category", r.category, choices.categories);
165
+ const runtime = oneOf(
166
+ "runtime",
167
+ r.runtime,
168
+ choices.runtimes.map((x) => x.id),
169
+ );
170
+ const rt = choices.runtimes.find((x) => x.id === runtime)!;
171
+ const { model, version } = checkModelChoice(rt, r.model, r.version);
172
+ let permission: PermissionIntent;
173
+ let allowedCommands: string[];
174
+ try {
175
+ permission = checkIntent(r.permission ?? "ask", "permission");
176
+ allowedCommands = checkCommands(r.allowedCommands, "allowedCommands");
177
+ } catch (e) {
178
+ throw new PersonaInputError((e as Error).message);
179
+ }
180
+ const instructions = r.instructions ?? "";
181
+ if (typeof instructions !== "string")
182
+ throw new PersonaInputError("instructions must be a string");
183
+ const text = instructions.replace(/\r\n?/g, "\n").trim();
184
+ if (!text) throw new PersonaInputError("instructions are required");
185
+ if (text.length > MAX_INSTRUCTIONS)
186
+ throw new PersonaInputError(
187
+ `instructions must be at most ${MAX_INSTRUCTIONS} characters`,
188
+ );
189
+ // Newlines and tabs only: no escape sequences, no bidi overrides.
190
+ // eslint-disable-next-line no-control-regex
191
+ if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/.test(text))
192
+ throw new PersonaInputError("instructions can't have control characters");
193
+ const displayName = oneLine(
194
+ "displayName",
195
+ r.displayName,
196
+ MAX_PERSONA_NAME,
197
+ false,
198
+ );
199
+ return {
200
+ ...(displayName ? { displayName } : {}),
201
+ role,
202
+ category,
203
+ description: oneLine("description", r.description, MAX_DESCRIPTION, true)!,
204
+ instructions: text,
205
+ runtime,
206
+ ...(model ? { model } : {}),
207
+ version,
208
+ permission,
209
+ allowedCommands,
210
+ };
211
+ }
212
+
213
+ /** The persona file for `input`: front matter (YAML) and its instructions. */
214
+ export function personaMarkdown(id: string, input: PersonaInput): string {
215
+ const fm: Record<string, unknown> = {
216
+ name: id,
217
+ ...(input.displayName ? { display_name: input.displayName } : {}),
218
+ role: input.role,
219
+ category: input.category,
220
+ description: input.description,
221
+ runtime: input.runtime,
222
+ ...(input.model ? { model: input.model } : {}),
223
+ version: input.version,
224
+ permission_mode: input.permission,
225
+ ...(input.allowedCommands.length
226
+ ? { allowed_commands: input.allowedCommands }
227
+ : {}),
228
+ };
229
+ return `---\n${yaml.stringify(fm).trimEnd()}\n---\n\n${input.instructions}\n`;
230
+ }
231
+
232
+ function fileOf(stateDir: string, id: string): string {
233
+ if (!PERSONA_ID_RE.test(id))
234
+ throw new PersonaInputError(`"${shown(id)}" is not a persona id`);
235
+ return path.join(localPersonasDir(stateDir), `${id}.md`);
236
+ }
237
+
238
+ /** Whether a local persona file `id` exists. */
239
+ export function localPersonaExists(stateDir: string, id: string): boolean {
240
+ return PERSONA_ID_RE.test(id) && fs.existsSync(fileOf(stateDir, id));
241
+ }
242
+
243
+ /** Write local persona `id` (0600, atomically). */
244
+ export function writeLocalPersona(
245
+ stateDir: string,
246
+ id: string,
247
+ input: PersonaInput,
248
+ ): string {
249
+ const file = fileOf(stateDir, id);
250
+ writePrivate(file, personaMarkdown(id, input));
251
+ return file;
252
+ }
253
+
254
+ /** Remove local persona `id`; false when there was none. Only that file. */
255
+ export function deleteLocalPersona(stateDir: string, id: string): boolean {
256
+ const file = fileOf(stateDir, id);
257
+ try {
258
+ fs.unlinkSync(file);
259
+ return true;
260
+ } catch (e) {
261
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return false;
262
+ throw e;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * A new persona's id: from its name (else its role), lowercased, made of
268
+ * what an id may hold; "-2", "-3", … when taken.
269
+ */
270
+ export function newPersonaId(
271
+ input: Pick<PersonaInput, "displayName" | "role">,
272
+ taken: ReadonlySet<string>,
273
+ ): string {
274
+ const base =
275
+ (input.displayName ?? input.role)
276
+ .toLowerCase()
277
+ .normalize("NFKD")
278
+ .replace(/[^a-z0-9._-]+/g, "-")
279
+ .replace(/^[^a-z0-9]+|-+$/g, "")
280
+ .slice(0, 56) || "persona";
281
+ if (!taken.has(base)) return base;
282
+ for (let i = 2; ; i++) {
283
+ const id = `${base}-${i}`;
284
+ if (!taken.has(id)) return id;
285
+ }
286
+ }
@@ -12,20 +12,35 @@
12
12
  * model the default model; `inherit` means none of its own
13
13
  * tools "Read, Grep" or a list: hints, never a restriction Den applies
14
14
  *
15
- * Den's own keys sit beside them: `runtime` (default den-claude),
15
+ * Den's own keys sit beside them (AG-10.2): `display_name` ("Rita", shown
16
+ * as "Rita (reviewer)"), `role` (a key into the role registry, roles.ts:
17
+ * planner, sde, …; a sentence there, as before AG-10, is read as the
18
+ * description), `category` (engineering, design, …; default from the
19
+ * role), `version` ("latest" or a pinned model version),
20
+ * `allowed_commands` (plain command prefixes run without asking: AG-13.4),
21
+ * `runtime` (default den-claude),
16
22
  * `standing` (starts with its workspace, §5; resumes after a restart),
17
23
  * `resume_on_restart` (AG-14.3: resumes after a restart too), `context` (what to read
18
24
  * first), `argv` (the generic runtime's template), and `permission_mode`
19
- * (den-claude's Claude Code permission mode; it checks the value), and
25
+ * (Den's intent: ask, edits or all; or a Claude Code mode, which
26
+ * den-claude checks), and
20
27
  * `den_hooks` / `den_instructions` (true or false: this persona's
21
28
  * guardrails, guardrails.ts, over its pack's). Any other key (a
22
29
  * Claude Code `color`) is ignored. A file without front matter (a pack's
23
30
  * README) is not a persona.
31
+ *
32
+ * Where personas come from (AG-10.3), each with its origin: built-in
33
+ * (Den's default pack), distribution (agents.persona_packs) and local
34
+ * (<state_dir>/personas/*.md, made in the UI or by hand: persona-store.ts).
35
+ * Of two with one id, local wins over distribution, and distribution over
36
+ * built-in. Hub comes later; only its origin value exists now.
24
37
  */
25
38
  import * as fs from "fs";
26
39
  import * as path from "path";
27
40
  import * as yaml from "yaml";
28
41
  import { WORKSPACE_NAME_RE } from "../agent-actor.js";
42
+ import { checkCommands } from "./permissions.js";
43
+ import { DEN_ROLES, REGISTRY_ID_RE } from "./roles.js";
29
44
  import { enginePath } from "../engine.js";
30
45
  import {
31
46
  DEFAULT_PACK_GUARDRAILS,
@@ -34,10 +49,31 @@ import {
34
49
  type Guardrails,
35
50
  } from "./guardrails.js";
36
51
 
52
+ /** Where a persona came from (AG-10.3). */
53
+ export type PersonaOrigin = "builtin" | "distribution" | "local" | "hub";
54
+ export const PERSONA_ORIGINS: readonly PersonaOrigin[] = [
55
+ "builtin",
56
+ "distribution",
57
+ "local",
58
+ "hub",
59
+ ];
60
+
37
61
  export interface Persona {
38
62
  id: string;
63
+ /** A person's name for it ("Rita"), shown as "Rita (reviewer)". */
64
+ displayName?: string;
65
+ /** Its role: a key into the role registry (roles.ts), when it has one. */
66
+ role?: string;
39
67
  /** One line: what it does. */
40
- role: string;
68
+ description: string;
69
+ /** Its category (roles.ts): the role's, else engineering. */
70
+ category: string;
71
+ /** "latest" (default) or a pinned version of its model. */
72
+ version?: string;
73
+ /** Plain command prefixes it may run without asking (AG-13.4). */
74
+ allowedCommands: string[];
75
+ /** Where it came from (loadPersonas sets it). */
76
+ origin: PersonaOrigin;
41
77
  /** The role prompt: the file's body. */
42
78
  prompt: string;
43
79
  /** The runtime it runs on unless an agent says otherwise. */
@@ -70,6 +106,9 @@ export interface Persona {
70
106
  source: string;
71
107
  }
72
108
 
109
+ /** A model version as a persona pins it: "latest" or a version id. */
110
+ export const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,63}$/;
111
+
73
112
  /** A persona id: the same rules as an agent id or a workspace name. */
74
113
  export const PERSONA_ID_RE = WORKSPACE_NAME_RE;
75
114
 
@@ -115,7 +154,11 @@ function stringList(
115
154
  }
116
155
 
117
156
  /** One persona file, or null when it has no front matter. */
118
- export function parsePersona(text: string, file: string): Persona | null {
157
+ export function parsePersona(
158
+ text: string,
159
+ file: string,
160
+ origin: PersonaOrigin = "builtin",
161
+ ): Persona | null {
119
162
  const m = FRONT_MATTER.exec(text);
120
163
  if (!m) return null;
121
164
  let fm: unknown;
@@ -155,6 +198,35 @@ export function parsePersona(text: string, file: string): Persona | null {
155
198
  argv = f.argv as string[];
156
199
  }
157
200
  const model = optString(f, "model", file);
201
+ const displayName = optString(f, "display_name", file);
202
+ if (
203
+ displayName &&
204
+ ([...displayName].length > 40 ||
205
+ /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(displayName))
206
+ )
207
+ throw new Error(
208
+ `${file}: display_name must be at most 40 characters, with no control characters`,
209
+ );
210
+ // AG-10.2: `role` is a role key; a sentence there (before AG-10) is the
211
+ // description it always was.
212
+ const roleRaw = optString(f, "role", file);
213
+ const role = roleRaw && REGISTRY_ID_RE.test(roleRaw) ? roleRaw : undefined;
214
+ const category = optString(f, "category", file);
215
+ if (category && !REGISTRY_ID_RE.test(category))
216
+ throw new Error(
217
+ `${file}: category "${category.slice(0, 40)}" must be lowercase letters, digits and "-"`,
218
+ );
219
+ const version = optString(f, "version", file);
220
+ if (version && !VERSION_RE.test(version))
221
+ throw new Error(
222
+ `${file}: version must be "latest" or a version id (letters, digits, . _ @ : -)`,
223
+ );
224
+ let allowedCommands: string[];
225
+ try {
226
+ allowedCommands = checkCommands(f.allowed_commands, "allowed_commands");
227
+ } catch (e) {
228
+ throw new Error(`${file}: ${(e as Error).message}`);
229
+ }
158
230
  const runtime = optString(f, "runtime", file);
159
231
  const permissionMode = optString(f, "permission_mode", file);
160
232
  const guardrailsSet: Partial<Guardrails> = {};
@@ -170,7 +242,19 @@ export function parsePersona(text: string, file: string): Persona | null {
170
242
  }
171
243
  return {
172
244
  id,
173
- role: optString(f, "role", file) ?? optString(f, "description", file) ?? "",
245
+ ...(displayName ? { displayName } : {}),
246
+ ...(role ? { role } : {}),
247
+ description:
248
+ (roleRaw && !role ? roleRaw : undefined) ??
249
+ optString(f, "description", file) ??
250
+ "",
251
+ category:
252
+ category ??
253
+ DEN_ROLES.find((r) => r.id === role)?.category ??
254
+ "engineering",
255
+ ...(version && version !== "latest" ? { version } : {}),
256
+ allowedCommands,
257
+ origin,
174
258
  prompt: text.slice(m[0].length).trim(),
175
259
  ...(runtime ? { runtime } : {}),
176
260
  ...(model && model !== "inherit" ? { model } : {}),
@@ -189,7 +273,10 @@ export function parsePersona(text: string, file: string): Persona | null {
189
273
  * A pack's personas, by file name. A directory with an `agents/` inside (a
190
274
  * Claude Code plugin) is read from there.
191
275
  */
192
- export function readPack(dir: string): Persona[] {
276
+ export function readPack(
277
+ dir: string,
278
+ origin: PersonaOrigin = "builtin",
279
+ ): Persona[] {
193
280
  const agents = path.join(dir, "agents");
194
281
  const from = fs.statSync(agents, { throwIfNoEntry: false })?.isDirectory()
195
282
  ? agents
@@ -200,7 +287,7 @@ export function readPack(dir: string): Persona[] {
200
287
  if (!name.endsWith(".md")) continue;
201
288
  const file = path.join(from, name);
202
289
  if (!fs.statSync(file).isFile()) continue;
203
- const p = parsePersona(fs.readFileSync(file, "utf-8"), file);
290
+ const p = parsePersona(fs.readFileSync(file, "utf-8"), file, origin);
204
291
  if (!p) continue;
205
292
  const prev = seen.get(p.id);
206
293
  if (prev) throw new Error(`${prev} and ${file} both define "${p.id}"`);
@@ -237,17 +324,34 @@ export function loadPersonas(
237
324
  packDirs: string[],
238
325
  defaultPack: string = DEFAULT_PACK_DIR,
239
326
  guardrails: PackGuardrails = {},
327
+ /**
328
+ * <state_dir>/personas (AG-10.3): a person's own, read last so one wins
329
+ * over a pack's of the same id. Absent: none. They get the default
330
+ * pack's guardrails (Den's hooks and instructions) unless their own
331
+ * front matter says otherwise.
332
+ */
333
+ localDir?: string,
240
334
  ): Map<string, Persona> {
241
335
  const all = new Map<string, Persona>();
242
- const packs: [string, Guardrails][] = [
243
- [defaultPack, { ...DEFAULT_PACK_GUARDRAILS, ...guardrails.defaultPack }],
244
- ...packDirs.map((dir, i): [string, Guardrails] => [
336
+ const packs: [string, Guardrails, PersonaOrigin][] = [
337
+ [
338
+ defaultPack,
339
+ { ...DEFAULT_PACK_GUARDRAILS, ...guardrails.defaultPack },
340
+ "builtin",
341
+ ],
342
+ ...packDirs.map((dir, i): [string, Guardrails, PersonaOrigin] => [
245
343
  dir,
246
344
  { ...PACK_GUARDRAILS, ...guardrails.packs?.[i] },
345
+ "distribution",
247
346
  ]),
248
347
  ];
249
- for (const [dir, g] of packs)
250
- for (const p of readPack(dir))
348
+ if (
349
+ localDir &&
350
+ fs.statSync(localDir, { throwIfNoEntry: false })?.isDirectory()
351
+ )
352
+ packs.push([localDir, { ...DEFAULT_PACK_GUARDRAILS }, "local"]);
353
+ for (const [dir, g, origin] of packs)
354
+ for (const p of readPack(dir, origin))
251
355
  all.set(p.id, { ...p, guardrails: { ...g, ...p.guardrailsSet } });
252
356
  return all;
253
357
  }
@@ -21,6 +21,7 @@ import * as path from "path";
21
21
  import { WORKSPACE_NAME_RE } from "../agent-actor.js";
22
22
  import type { SessionRef } from "../sessions/types.js";
23
23
  import type { RotationEntry } from "./rotation.js";
24
+ import type { PermissionIntent } from "./permissions.js";
24
25
 
25
26
  export interface AgentRecord {
26
27
  id: string;
@@ -72,6 +73,62 @@ export interface AgentRecord {
72
73
  * rotation), oldest first.
73
74
  */
74
75
  rotations?: RotationEntry[];
76
+ /**
77
+ * The name a person gave it (AG-4b: double-click its tab), shown in the
78
+ * Agents tab, its tabs, ⌘K and the agent lists; the id never changes.
79
+ * checkDisplayName says what one may be.
80
+ */
81
+ displayName?: string;
82
+ /** When displayName was given (ISO 8601): the latest rename wins (AG-4b). */
83
+ displayNameAt?: string;
84
+ /**
85
+ * The model version it runs (AG-12.3): "latest" (the model's newest), or
86
+ * a pinned version. Absent: before AG-12, `model` as given.
87
+ */
88
+ version?: string;
89
+ /** The permission intent it was started with (AG-13), when one was set. */
90
+ permission?: PermissionIntent;
91
+ /**
92
+ * The extra allowed commands it was started with (AG-13.4: plain
93
+ * prefixes; its role's preset comes on top), when set for it.
94
+ */
95
+ allow?: string[];
96
+ /**
97
+ * What it was started with that differs from its persona (AG-10.9):
98
+ * the list tags it. Start fresh and Relaunch keep the choices.
99
+ */
100
+ overrides?: Override[];
101
+ }
102
+
103
+ /** A setting a session can override for one launch (AG-10.9). */
104
+ export type Override = "runtime" | "model" | "version" | "permission" | "allow";
105
+ export const OVERRIDES: readonly Override[] = [
106
+ "runtime",
107
+ "model",
108
+ "version",
109
+ "permission",
110
+ "allow",
111
+ ];
112
+
113
+ /** The longest name a person may give an agent (AG-4b). */
114
+ export const MAX_DISPLAY_NAME = 40;
115
+
116
+ /**
117
+ * A name given to an agent, checked (AG-4b): a string, trimmed, at most
118
+ * MAX_DISPLAY_NAME characters, with no control or format characters (no
119
+ * newline, no bidi override). Blank (or null) clears it: null. Anything
120
+ * else throws, saying why.
121
+ */
122
+ export function checkDisplayName(v: unknown): string | null {
123
+ if (v === null) return null;
124
+ if (typeof v !== "string") throw new Error("name must be a string, or null");
125
+ const name = v.trim();
126
+ if (!name) return null;
127
+ if ([...name].length > MAX_DISPLAY_NAME)
128
+ throw new Error(`name must be at most ${MAX_DISPLAY_NAME} characters`);
129
+ if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(name))
130
+ throw new Error("name can't have control characters");
131
+ return name;
75
132
  }
76
133
 
77
134
  /** What an agent's run came to (den#6). Read locally; never sent. */