@monster-orange/den 0.4.12 → 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 (40) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +146 -10
  4. package/package.json +1 -1
  5. package/src/shared/agents/default-pack/be-dev.md +1 -0
  6. package/src/shared/agents/default-pack/devops.md +1 -0
  7. package/src/shared/agents/default-pack/fe-dev.md +1 -0
  8. package/src/shared/agents/default-pack/orchestrator.md +3 -1
  9. package/src/shared/agents/default-pack/planner.md +1 -0
  10. package/src/shared/agents/default-pack/reviewer.md +1 -0
  11. package/src/shared/agents/default-pack/tester.md +16 -0
  12. package/src/shared/agents/default-pack/ux-designer.md +1 -0
  13. package/src/shared/agents/den-claude.ts +119 -13
  14. package/src/shared/agents/permissions.ts +108 -0
  15. package/src/shared/agents/persona-store.ts +286 -0
  16. package/src/shared/agents/personas.ts +116 -12
  17. package/src/shared/agents/records.ts +28 -0
  18. package/src/shared/agents/roles.ts +288 -0
  19. package/src/shared/agents/runtime.ts +41 -0
  20. package/src/shared/den-config.ts +20 -1
  21. package/src/ui/cli/agent-ops.ts +535 -17
  22. package/src/ui/cli/agents-command.ts +12 -2
  23. package/src/ui/cli/env-probe.ts +4 -1
  24. package/src/ui/mcp/tools.ts +1 -1
  25. package/src/ui/server/agent-routes.ts +260 -21
  26. package/src/ui/server/public/agent-start.js +3 -2
  27. package/src/ui/server/public/agents-page.js +499 -48
  28. package/src/ui/server/public/agents.html +58 -15
  29. package/src/ui/server/public/i18n/es.js +120 -0
  30. package/src/ui/server/public/i18n/ja.js +120 -0
  31. package/src/ui/server/public/persona-ui.css +539 -0
  32. package/src/ui/server/public/persona-ui.js +796 -0
  33. package/src/ui/server/public/settings-page.js +4 -9
  34. package/src/ui/server/public/settings-registry.js +22 -0
  35. package/src/ui/server/public/settings.html +1 -0
  36. package/src/ui/server/public/shell.js +117 -0
  37. package/src/ui/server/public/topbar.js +2 -2
  38. package/src/ui/server/route-classes.ts +18 -0
  39. package/src/ui/server/session-routes.ts +21 -5
  40. package/src/ui/server/ui-assets.ts +3 -0
@@ -0,0 +1,796 @@
1
+ /**
2
+ * Personas in the page (AG-10.4 to 10.7, AG-11, AG-12, AG-13): what the
3
+ * Start dialog (agents-page.js) and Settings › Personas share.
4
+ *
5
+ * - the filter bar (search, category chips, source chips) and the list,
6
+ * grouped by category when no filter is set (AG-10.4);
7
+ * - a persona's row: "Rita (reviewer)", its source badge, its one-line
8
+ * description, and "not installed" when its runtime isn't (AG-12.2);
9
+ * - Details: its instructions, runtime, model and version, permission
10
+ * mode, preset and extra commands (AG-10.6);
11
+ * - the permission block: the mode (AG-13.1), the role's preset as chips
12
+ * (13.3), extra commands (13.4) and the preview of what the runtime is
13
+ * given (13.2), from the runtime's own translation (GET /api/personas:
14
+ * runtimes[].permissions). A runtime that takes none from Den says
15
+ * "not supported on <runtime>" for each command, never drops one;
16
+ * - the persona editor (New persona, Edit, Duplicate: AG-10.7), a dialog
17
+ * of its own, and the Settings › Personas row.
18
+ *
19
+ * Every string a person or a pack wrote is escaped; every write goes to
20
+ * the persona routes (agent-routes.ts), which check every field again.
21
+ */
22
+
23
+ const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
24
+ export const esc = (s) =>
25
+ String(s ?? "").replace(
26
+ /[&<>"']/g,
27
+ (c) =>
28
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
29
+ c
30
+ ],
31
+ );
32
+ const api = (method, url, body) => window.denShell.api(method, url, body);
33
+
34
+ /** Den's three permission intents (permissions.ts), in the switch's order. */
35
+ export const INTENTS = ["ask", "edits", "all"];
36
+ /** The sources a filter offers (hub: later, once Den Hub exists). */
37
+ export const ORIGINS = ["builtin", "distribution", "local"];
38
+
39
+ // The same rule as permissions.ts (the server checks again).
40
+ const PREFIX_RE = /^[A-Za-z0-9][A-Za-z0-9 ._/:=@%+,-]*$/;
41
+ const PATTERN_RE = /[*?[\]^$(){}|]/;
42
+ const MAX_COMMAND_LEN = 120;
43
+
44
+ /** Den's categories (roles.ts), for an answer that names none. */
45
+ const DEN_CATEGORIES = [
46
+ "engineering",
47
+ "design",
48
+ "product",
49
+ "marketing",
50
+ "operations",
51
+ ].map((id) => ({ id, label: id, source: "den" }));
52
+
53
+ /**
54
+ * GET /api/personas' answer, filled in where an older or partial one
55
+ * leaves a field out: every persona has a category and a description,
56
+ * and there is a category list.
57
+ */
58
+ export function normalizeData(data) {
59
+ // A role that isn't a registry key is an older answer's description.
60
+ const key = (r) => typeof r === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(r);
61
+ const personas = (data?.personas ?? []).map((p) => ({
62
+ ...p,
63
+ role: key(p.role) ? p.role : undefined,
64
+ category: p.category || "engineering",
65
+ description: p.description ?? (key(p.role) ? "" : (p.role ?? "")),
66
+ allowedCommands: p.allowedCommands ?? [],
67
+ }));
68
+ return {
69
+ ...data,
70
+ personas,
71
+ runtimes: data?.runtimes ?? [],
72
+ roles: data?.roles ?? [],
73
+ categories: data?.categories?.length ? data.categories : DEN_CATEGORIES,
74
+ };
75
+ }
76
+
77
+ /** Why `c` isn't a plain prefix ("pattern", "invalid", "long"), or null. */
78
+ export function commandProblem(c) {
79
+ if ([...c].length > MAX_COMMAND_LEN) return "long";
80
+ if (PATTERN_RE.test(c)) return "pattern";
81
+ if (!PREFIX_RE.test(c) || /\s{2,}/.test(c) || c !== c.trim())
82
+ return "invalid";
83
+ return null;
84
+ }
85
+
86
+ /** One command per line, trimmed, blank lines dropped, each once. */
87
+ export function splitCommands(text) {
88
+ return [
89
+ ...new Set(
90
+ String(text ?? "")
91
+ .split("\n")
92
+ .map((x) => x.trim())
93
+ .filter(Boolean),
94
+ ),
95
+ ];
96
+ }
97
+
98
+ /** A category's label: Den's in the page's language, a distribution's as given. */
99
+ export function categoryLabel(categories, id) {
100
+ const c = (categories ?? []).find((x) => x.id === id);
101
+ if (!c) return id ?? "";
102
+ return c.source === "den" ? t(`cat.${c.id}`) : c.label;
103
+ }
104
+
105
+ /** A persona's name as HTML: "Rita <role>(reviewer)</role>", else its id (role). */
106
+ export function personaNameHtml(p) {
107
+ if (p.displayName)
108
+ return `${esc(p.displayName)} <span class="role">(${esc(p.role || p.id)})</span>`;
109
+ return `${esc(p.id)}${p.role && p.role !== p.id ? ` <span class="role">(${esc(p.role)})</span>` : ""}`;
110
+ }
111
+
112
+ /** A persona's name as text. */
113
+ export function personaName(p) {
114
+ if (!p) return "";
115
+ return p.displayName ? `${p.displayName} (${p.role || p.id})` : p.id;
116
+ }
117
+
118
+ const originOf = (p) => (ORIGINS.includes(p.origin) ? p.origin : "builtin");
119
+
120
+ /** Its source badge (AG-10.3): built-in, distribution, mine (hub later). */
121
+ export function originBadge(p) {
122
+ const o = originOf(p);
123
+ return `<span class="tagx src-${o}" title="${esc(t(`pe.src.${o}.p`))}">${esc(t(`pe.src.${o}`))}</span>`;
124
+ }
125
+
126
+ /** Whether persona `p` matches filter `f` ({ q, cat, src }). */
127
+ export function matchPersona(p, f, categories) {
128
+ if (f.cat && p.category !== f.cat) return false;
129
+ if (f.src && originOf(p) !== f.src) return false;
130
+ if (!f.q) return true;
131
+ const hay = [
132
+ p.displayName,
133
+ p.id,
134
+ p.role,
135
+ p.description,
136
+ categoryLabel(categories, p.category),
137
+ ]
138
+ .join(" ")
139
+ .toLowerCase();
140
+ return f.q
141
+ .toLowerCase()
142
+ .split(/\s+/)
143
+ .filter(Boolean)
144
+ .every((w) => hay.includes(w));
145
+ }
146
+
147
+ const noFilter = (f) => !f.q && !f.cat && !f.src;
148
+
149
+ /** The filter bar (AG-10.4): search, then the category and source chips. */
150
+ export function filterBarHtml(f, ctx, personas, categories) {
151
+ const chip = (k, v, label, n) =>
152
+ `<button type="button" class="pfc" data-pf="${k}" data-v="${esc(v)}" aria-pressed="${(f[k] || "") === v}">${esc(label)}${n != null ? `<span class="c">${n}</span>` : ""}</button>`;
153
+ // Only the categories some persona is in.
154
+ const cats = (categories ?? []).filter((c) =>
155
+ personas.some((p) => p.category === c.id),
156
+ );
157
+ return (
158
+ `<div class="pfbar"><input type="search" id="${ctx}-q" value="${esc(f.q)}" placeholder="${esc(t("pf.search"))}" aria-label="${esc(t("pf.search"))}" autocomplete="off" spellcheck="false">` +
159
+ `<div class="pfrow" role="group" aria-label="${esc(t("pf.cat"))}"><span class="lab">${esc(t("pf.cat"))}</span>${chip("cat", "", t("pf.all"))}` +
160
+ cats
161
+ .map((c) =>
162
+ chip(
163
+ "cat",
164
+ c.id,
165
+ categoryLabel(categories, c.id),
166
+ personas.filter((p) => p.category === c.id).length,
167
+ ),
168
+ )
169
+ .join("") +
170
+ `</div><div class="pfrow" role="group" aria-label="${esc(t("pf.src"))}"><span class="lab">${esc(t("pf.src"))}</span>${chip("src", "", t("pf.all"))}` +
171
+ ORIGINS.map((o) => chip("src", o, t(`pe.src.${o}`))).join("") +
172
+ `</div></div>`
173
+ );
174
+ }
175
+
176
+ /**
177
+ * The list: grouped under category headings with no filter set, else the
178
+ * matches alone; "No persona matches" when none does.
179
+ */
180
+ export function personaListHtml(f, personas, categories, rowFn) {
181
+ const xs = personas.filter((p) => matchPersona(p, f, categories));
182
+ if (!xs.length)
183
+ return `<div class="pempty">${esc(t("pf.none"))} <button type="button" class="lnk" data-x="clear">${esc(t("pf.clear"))}</button></div>`;
184
+ if (!noFilter(f)) return xs.map(rowFn).join("");
185
+ const order = [
186
+ ...(categories ?? []).map((c) => c.id),
187
+ ...new Set(xs.map((p) => p.category)),
188
+ ];
189
+ return [...new Set(order)]
190
+ .filter((id) => xs.some((p) => p.category === id))
191
+ .map((id) => {
192
+ const g = xs.filter((p) => p.category === id);
193
+ return `<div class="pgh"><span>${esc(categoryLabel(categories, id))}</span><span>${g.length}</span></div>${g.map(rowFn).join("")}`;
194
+ })
195
+ .join("");
196
+ }
197
+
198
+ const runtimeOf = (data, id) => (data?.runtimes ?? []).find((r) => r.id === id);
199
+ const runtimeLabel = (data, id) => runtimeOf(data, id)?.label || id || "";
200
+
201
+ /** The runtime a persona runs on (its own, else the default). */
202
+ export function personaRuntime(data, p) {
203
+ return p?.runtime || (data?.runtimes ?? []).find((r) => r.default)?.id || "";
204
+ }
205
+
206
+ /** Whether persona `p`'s own runtime is installed here. */
207
+ export function runtimeInstalled(data, p) {
208
+ return runtimeOf(data, personaRuntime(data, p))?.available !== false;
209
+ }
210
+
211
+ /** A role's preset (AG-13.3), from the registry. */
212
+ export function rolePreset(data, role) {
213
+ return (data?.roles ?? []).find((r) => r.id === role)?.preset ?? [];
214
+ }
215
+
216
+ /** "latest" or a version, for people. */
217
+ export function versionLabel(v) {
218
+ return !v || v === "latest" ? t("ver.latest") : v;
219
+ }
220
+
221
+ /** The model a runtime offers by id, or undefined. */
222
+ export function modelOf(data, runtime, model) {
223
+ return (runtimeOf(data, runtime)?.models ?? []).find((m) => m.id === model);
224
+ }
225
+
226
+ /** Whether a pinned version is older than its model's newest (AG-12.4). */
227
+ export function isStale(data, runtime, model, version) {
228
+ const m = modelOf(data, runtime, model);
229
+ return Boolean(m && version && version !== "latest" && version !== m.latest);
230
+ }
231
+
232
+ const newerBadge = () =>
233
+ `<span class="tagx warn" data-newer title="${esc(t("ver.newerP"))}">${esc(t("ver.newer"))}</span>`;
234
+
235
+ /** A row of the Start list (a radio). */
236
+ export function personaRowHtml(p, checked, data) {
237
+ const missing = !runtimeInstalled(data, p)
238
+ ? ` <span class="tagx warn">${esc(t("rt.notInst", { r: runtimeLabel(data, personaRuntime(data, p)) }))}</span>`
239
+ : "";
240
+ return (
241
+ `<button type="button" class="pr" role="radio" data-pid="${esc(p.id)}" aria-checked="${checked}">` +
242
+ `<span class="rd" aria-hidden="true"></span><span class="pn">${personaNameHtml(p)}${missing}</span>${originBadge(p)}` +
243
+ `<span class="pd">${esc(p.description || "")}</span></button>`
244
+ );
245
+ }
246
+
247
+ /** The permission preview (AG-13.2): what runtime `rt` is given. */
248
+ export function permPreviewHtml(data, rtId, intent, preset, extras) {
249
+ const rt = runtimeOf(data, rtId);
250
+ const name = runtimeLabel(data, rtId);
251
+ const perms = rt?.permissions;
252
+ const rule = (c) =>
253
+ perms ? perms.commandRules.map((r) => r.split("{c}").join(c)) : null;
254
+ const line = (c, src) => {
255
+ const why = commandProblem(c);
256
+ const rules = why ? null : rule(c);
257
+ const body = rules
258
+ ? rules.map((r) => `<span class="mono">${esc(r)}</span>`).join(" ")
259
+ : `<span class="mono gone">${esc(c)}</span> <span class="tagx warn">${esc(
260
+ why === "pattern"
261
+ ? t("pe.nsPat")
262
+ : why
263
+ ? t("pe.nsBad")
264
+ : t("pe.ns", { r: name }),
265
+ )}</span>`;
266
+ return `<li data-cmd="${esc(c)}">${body} <span class="src">· ${esc(src)}</span></li>`;
267
+ };
268
+ const mode = perms
269
+ ? `<li><span class="mono">${esc(perms.modes[intent] ?? "")}</span></li>`
270
+ : `<li><span class="tagx warn">${esc(t("pe.nsMode", { r: name }))}</span></li>`;
271
+ return (
272
+ `<div class="pv" data-runtime="${esc(rtId)}"><b>${esc(t("pe.allows", { r: name }))}</b><ul>` +
273
+ mode +
274
+ preset.map((c) => line(c, t("pe.preset").toLowerCase())).join("") +
275
+ extras.map((c) => line(c, t("pe.extraS"))).join("") +
276
+ `</ul></div>`
277
+ );
278
+ }
279
+
280
+ const presetChips = (preset) =>
281
+ preset.length
282
+ ? preset.map((c) => `<span class="pchip">${esc(c)}</span>`).join("")
283
+ : `<span class="faint">—</span>`;
284
+
285
+ /**
286
+ * The permission block: mode, preset chips, extra commands (collapsed),
287
+ * the preview and the raw config stub (AG-13.5). `s` is
288
+ * { intent, role, extras, runtime, disabled }.
289
+ */
290
+ export function permBlockHtml(ctx, data, s) {
291
+ const dis = s.disabled ? " disabled" : "";
292
+ const preset = rolePreset(data, s.role);
293
+ return (
294
+ `<div class="fld"><span>${esc(t("pe.perms"))}</span><div class="seg pmode" role="radiogroup" aria-label="${esc(t("pe.perms"))}">` +
295
+ INTENTS.map(
296
+ (m) =>
297
+ `<button type="button" data-pm="${m}" role="radio" aria-checked="${s.intent === m}" aria-pressed="${s.intent === m}"${dis}>${esc(t(`pm.${m}`))}</button>`,
298
+ ).join("") +
299
+ `</div></div>` +
300
+ `<div class="fld"><span>${esc(t("pe.preset"))} · <span id="${ctx}-presetrole">${esc(s.role || "—")}</span></span><div class="pchips" id="${ctx}-preset">${presetChips(preset)}</div></div>` +
301
+ `<details class="xtra" id="${ctx}-xtra"${s.extrasOpen ? " open" : ""}><summary>${esc(t("pe.extra"))} (<span id="${ctx}-xn">${s.extras.length}</span>)</summary>` +
302
+ `<textarea id="${ctx}-tools" rows="3" spellcheck="false" aria-label="${esc(t("pe.extra"))}"${dis}>${esc(s.extras.join("\n"))}</textarea><small class="faint">${esc(t("pe.extraP"))}</small></details>` +
303
+ `<div class="pvw" id="${ctx}-pv">${permPreviewHtml(data, s.runtime, s.intent, preset, s.extras)}</div>` +
304
+ `<div class="helpl"><button type="button" class="lnk" data-x="raw" aria-disabled="true" title="${esc(t("pe.rawLater"))}">${esc(t("pe.raw"))}</button> <span class="tagx warn">${esc(t("pe.vendor"))}</span> <span class="tagx">${esc(t("rt.later"))}</span></div>`
305
+ );
306
+ }
307
+
308
+ /** Keep a permission block's preview, count and preset in step. */
309
+ export function syncPermBlock(root, ctx, data, s) {
310
+ const preset = rolePreset(data, s.role);
311
+ const pv = root.querySelector(`#${ctx}-pv`);
312
+ if (pv)
313
+ pv.innerHTML = permPreviewHtml(data, s.runtime, s.intent, preset, s.extras);
314
+ const xn = root.querySelector(`#${ctx}-xn`);
315
+ if (xn) xn.textContent = String(s.extras.length);
316
+ const pr = root.querySelector(`#${ctx}-preset`);
317
+ if (pr) pr.innerHTML = presetChips(preset);
318
+ const rl = root.querySelector(`#${ctx}-presetrole`);
319
+ if (rl) rl.textContent = s.role || "—";
320
+ for (const b of root.querySelectorAll("[data-pm]")) {
321
+ b.setAttribute("aria-pressed", String(b.dataset.pm === s.intent));
322
+ b.setAttribute("aria-checked", String(b.dataset.pm === s.intent));
323
+ }
324
+ }
325
+
326
+ /** Model options for runtime `rt`: "" first (labelled `first`), then its models. */
327
+ export function modelOptionsHtml(data, rt, cur, first) {
328
+ const ms = runtimeOf(data, rt)?.models ?? [];
329
+ return (
330
+ `<option value="">${esc(first)}</option>` +
331
+ ms
332
+ .map(
333
+ (m) =>
334
+ `<option value="${esc(m.id)}"${m.id === cur ? " selected" : ""}>${esc(m.label || m.id)}</option>`,
335
+ )
336
+ .join("")
337
+ );
338
+ }
339
+
340
+ /** Version options for a model: "" (labelled `first`) when given, Latest, then each. */
341
+ export function versionOptionsHtml(data, rt, model, cur, first) {
342
+ const m = modelOf(data, rt, model);
343
+ const opts = [
344
+ ...(first != null ? [["", first]] : []),
345
+ ["latest", t("ver.latestP")],
346
+ ...(m?.versions ?? []).map((v) => [v, v]),
347
+ ];
348
+ return opts
349
+ .map(
350
+ ([v, l]) =>
351
+ `<option value="${esc(v)}"${v === cur ? " selected" : ""}>${esc(l)}</option>`,
352
+ )
353
+ .join("");
354
+ }
355
+
356
+ /** Details (AG-10.6): `p` with its instructions (GET /api/personas/<id>). */
357
+ export function personaDetailHtml(p, data) {
358
+ const rt = personaRuntime(data, p);
359
+ const intent = p.permission || "ask";
360
+ const preset = rolePreset(data, p.role);
361
+ const extras = p.allowedCommands ?? [];
362
+ const list = (xs) =>
363
+ xs.length
364
+ ? xs.map((x) => `<span class="mono">${esc(x)}</span>`).join(" · ")
365
+ : esc(t("pe.noneCmd"));
366
+ return (
367
+ `<div class="pdet" id="ag-det"><div class="why">${originBadge(p)} ${esc(t(`pe.src.${originOf(p)}.p`))}</div>` +
368
+ `<div><span class="k">${esc(t("pe.instr"))}</span><pre>${p.instructions == null ? esc(t("pe.loading")) : esc(p.instructions)}</pre></div>` +
369
+ `<div><span class="k">${esc(t("pe.runtime"))}</span> <span class="mono">${esc(runtimeLabel(data, rt))}</span> · <span class="k">${esc(t("pe.model"))}</span> <span class="mono">${esc(p.model || t("pe.rtDefault"))} · ${esc(versionLabel(p.version))}</span>${isStale(data, rt, p.model, p.version) ? ` ${newerBadge()}` : ""}</div>` +
370
+ `<div><span class="k">${esc(t("pe.perms"))}</span> ${esc(t(`pm.${intent}`))}</div>` +
371
+ `<div><span class="k">${esc(t("pe.preset"))}</span> ${list(preset)}</div>` +
372
+ `<div><span class="k">${esc(t("pe.extra"))}</span> ${list(extras)}</div>` +
373
+ `<div class="pvw">${permPreviewHtml(data, rt, intent, preset, extras)}</div></div>`
374
+ );
375
+ }
376
+
377
+ const detailAsked = new Map();
378
+ /** A persona with its instructions, asked once per page. */
379
+ export function personaDetail(id) {
380
+ if (!detailAsked.has(id))
381
+ detailAsked.set(
382
+ id,
383
+ api("GET", `/api/personas/${encodeURIComponent(id)}`)
384
+ .then((r) => r.persona)
385
+ .catch((e) => {
386
+ detailAsked.delete(id);
387
+ throw e;
388
+ }),
389
+ );
390
+ return detailAsked.get(id);
391
+ }
392
+ /** Forget what was asked (after a save or delete). */
393
+ export function forgetPersonaDetails() {
394
+ detailAsked.clear();
395
+ }
396
+
397
+ /**
398
+ * The tags a session's row carries (AG-10.9, AG-12.4, AG-13.1): what it
399
+ * overrides of its persona, Auto-approve all always, and "newer available"
400
+ * when it is pinned to an older version. HTML.
401
+ */
402
+ export function overrideTagsHtml(r) {
403
+ const ov = r.overrides ?? [];
404
+ const tags = [];
405
+ if (ov.includes("runtime") && r.runtime)
406
+ tags.push(["", t("ov.runtime", { r: r.runtime }), "runtime"]);
407
+ if (ov.includes("model") && r.model)
408
+ tags.push(["", t("ov.model", { m: r.model }), "model"]);
409
+ if (ov.includes("version") && r.version)
410
+ tags.push([
411
+ "",
412
+ r.version === "latest"
413
+ ? t("ov.latest")
414
+ : t("ver.pinned", { v: r.version }),
415
+ "version",
416
+ ]);
417
+ if (r.permission === "all")
418
+ tags.push(["warn", t("pm.tag.all"), "permission"]);
419
+ else if (ov.includes("permission") && r.permission)
420
+ tags.push([
421
+ r.permission === "ask" ? "" : "warn",
422
+ t(`pm.tag.${r.permission}`),
423
+ "permission",
424
+ ]);
425
+ if (ov.includes("allow")) tags.push(["", t("ov.tools"), "allow"]);
426
+ if (r.newer) tags.push(["warn", t("ver.newer"), "newer"]);
427
+ return tags
428
+ .map(
429
+ ([cls, label, k]) =>
430
+ `<span class="tagx${cls ? ` ${cls}` : ""}" data-ov="${k}"${k === "newer" ? ` title="${esc(t("ver.newerOf", { v: r.newer }))}"` : ""}>${esc(label)}</span>`,
431
+ )
432
+ .join("");
433
+ }
434
+
435
+ // ── The persona editor (AG-10.7) ─────────────────────────────────────────
436
+
437
+ /** The fields the editor sends (persona-store.ts KEYS). */
438
+ function editorBody(dlg, s) {
439
+ const v = (sel) => dlg.querySelector(sel)?.value ?? "";
440
+ return {
441
+ displayName: v("#pe-name").trim(),
442
+ role: v("#pe-role"),
443
+ category: v("#pe-cat"),
444
+ description: v("#pe-desc").trim(),
445
+ instructions: v("#pe-instr"),
446
+ runtime: v("#pe-rt"),
447
+ model: v("#pe-model"),
448
+ version: v("#pe-ver") || "latest",
449
+ permission: s.intent,
450
+ allowedCommands: splitCommands(v("#pe-tools")),
451
+ };
452
+ }
453
+
454
+ function runtimeOptionsHtml(data, cur) {
455
+ const rts = (data?.runtimes ?? []).filter((r) => r.id !== "generic");
456
+ const opt = (r) =>
457
+ `<option value="${esc(r.id)}"${r.id === cur ? " selected" : ""}>${esc(r.label || r.id)}${r.available ? (r.version ? ` · ${esc(r.version)}` : "") : ` · ${esc(t("rt.missing"))}`}</option>`;
458
+ const ins = rts.filter((r) => r.available);
459
+ const out = rts.filter((r) => !r.available);
460
+ return (
461
+ `<optgroup label="${esc(t("rt.installed"))}">${ins.map(opt).join("")}</optgroup>` +
462
+ (out.length
463
+ ? `<optgroup label="${esc(t("rt.missing"))}">${out.map(opt).join("")}</optgroup>`
464
+ : "")
465
+ );
466
+ }
467
+
468
+ /**
469
+ * Open the persona editor. `o`: { data (GET /api/personas), src (a
470
+ * persona with instructions, to edit or duplicate; none: New), dup,
471
+ * view (read-only), onSaved(persona), onClosed() }. A persona that isn't
472
+ * local opens as View, with Duplicate.
473
+ */
474
+ export function openPersonaEditor(o) {
475
+ const data = o.data;
476
+ const src = o.src ?? null;
477
+ const ro = Boolean(src && !o.dup && src.origin !== "local");
478
+ const isNew = !src || o.dup;
479
+ const roles = data?.roles ?? [];
480
+ const firstRole = roles.find((r) => r.id === "sde") ?? roles[0];
481
+ const d = src
482
+ ? {
483
+ displayName: o.dup
484
+ ? t("pe.copy", { n: src.displayName || src.id })
485
+ : src.displayName || "",
486
+ role: src.role || firstRole?.id || "",
487
+ category: src.category || "engineering",
488
+ description: src.description || "",
489
+ instructions: src.instructions || "",
490
+ runtime: src.runtime || personaRuntime(data, src),
491
+ model: src.model || "",
492
+ version: src.version || "latest",
493
+ permission: src.permission || "ask",
494
+ allowedCommands: [...(src.allowedCommands ?? [])],
495
+ }
496
+ : {
497
+ displayName: "",
498
+ role: firstRole?.id || "",
499
+ category: firstRole?.category || "engineering",
500
+ description: "",
501
+ instructions: "",
502
+ runtime: personaRuntime(data, null),
503
+ model: "",
504
+ version: "latest",
505
+ permission: firstRole?.mode || "ask",
506
+ allowedCommands: [],
507
+ };
508
+ const s = {
509
+ intent: d.permission,
510
+ role: d.role,
511
+ runtime: d.runtime,
512
+ extras: d.allowedCommands,
513
+ };
514
+ const dis = ro ? " disabled" : "";
515
+ const title = ro
516
+ ? personaName(src)
517
+ : isNew
518
+ ? t(o.dup ? "pe.dupT" : "pe.new")
519
+ : t("pe.edit");
520
+ const dlg = document.createElement("dialog");
521
+ dlg.className = "ag-dlg pe-dlg";
522
+ dlg.id = "pe-dlg";
523
+ dlg.setAttribute("aria-labelledby", "pe-h");
524
+ const cats = data?.categories ?? [];
525
+ dlg.innerHTML =
526
+ `<form method="dialog" id="pe-form" novalidate>` +
527
+ `<h2 id="pe-h">${esc(title)}</h2>` +
528
+ (ro ? `<p class="help">${originBadge(src)} ${esc(t("pe.ro"))}</p>` : "") +
529
+ `<div class="two"><label class="fld"><span>${esc(t("pe.name"))}</span><input type="text" id="pe-name" maxlength="40" value="${esc(d.displayName)}" placeholder="${esc(t("pe.nameP"))}"${dis}></label>` +
530
+ `<label class="fld"><span>${esc(t("pe.role"))}</span><select id="pe-role"${dis}>${roles.map((r) => `<option value="${esc(r.id)}"${r.id === d.role ? " selected" : ""}>${esc(r.id)}</option>`).join("")}</select></label></div>` +
531
+ `<div class="helpl">${esc(t("pe.roleHelp"))}</div>` +
532
+ `<div class="two"><label class="fld"><span>${esc(t("pe.cat"))}</span><select id="pe-cat"${dis}>${cats.map((c) => `<option value="${esc(c.id)}"${c.id === d.category ? " selected" : ""}>${esc(categoryLabel(cats, c.id))}</option>`).join("")}</select></label>` +
533
+ `<label class="fld"><span>${esc(t("pe.desc"))}</span><input type="text" id="pe-desc" maxlength="200" value="${esc(d.description)}"${dis}></label></div>` +
534
+ `<label class="fld"><span>${esc(t("pe.instr"))}</span><textarea id="pe-instr" rows="5" spellcheck="true"${dis}>${esc(d.instructions)}</textarea></label>` +
535
+ `<div class="two"><label class="fld"><span>${esc(t("pe.runtime"))}</span><select id="pe-rt"${dis}>${runtimeOptionsHtml(data, d.runtime)}</select></label>` +
536
+ `<label class="fld"><span>${esc(t("pe.model"))}</span><select id="pe-model"${dis}>${modelOptionsHtml(data, d.runtime, d.model, t("pe.rtDefault"))}</select></label></div>` +
537
+ `<div class="two"><label class="fld"><span>${esc(t("pe.ver"))}</span><select id="pe-ver"${dis}>${versionOptionsHtml(data, d.runtime, d.model, d.version)}</select></label><span class="helpl" id="pe-newer">${isStale(data, d.runtime, d.model, d.version) ? `${newerBadge()} <button type="button" class="lnk" data-x="latest">${esc(t("ver.use"))}</button>` : ""}</span></div>` +
538
+ `<div class="pblk">${permBlockHtml("pe", data, { ...s, disabled: ro, extrasOpen: s.extras.length > 0 })}</div>` +
539
+ `<p class="err" id="pe-err" role="alert" hidden></p>` +
540
+ `<div class="btns">${!isNew && src.origin === "local" ? `<button type="button" class="den-btn" data-x="del">${esc(t("pe.del"))}</button>` : ""}<span class="sp"></span>` +
541
+ `<button type="button" class="den-btn" data-x="cancel">${esc(t("ag.cancel"))}</button>` +
542
+ (ro
543
+ ? `<button type="button" class="den-btn primary" data-x="dup">${esc(t("pe.dup"))}</button>`
544
+ : `<button type="submit" class="den-btn primary" id="pe-go">${esc(t("pe.save"))}</button>`) +
545
+ `</div></form>`;
546
+ document.body.append(dlg);
547
+ const $ = (sel) => dlg.querySelector(sel);
548
+ const close = () => {
549
+ dlg.close?.();
550
+ dlg.remove();
551
+ o.onClosed?.();
552
+ };
553
+ dlg.addEventListener("cancel", (e) => {
554
+ e.preventDefault();
555
+ close();
556
+ });
557
+ const sync = () => {
558
+ s.extras = splitCommands($("#pe-tools").value);
559
+ s.role = $("#pe-role").value;
560
+ s.runtime = $("#pe-rt").value;
561
+ syncPermBlock(dlg, "pe", data, s);
562
+ };
563
+ // A role fills a new persona in from its template, never over what is
564
+ // already typed (AG-11.2).
565
+ const prev = { instr: d.instructions, intent: s.intent, cat: d.category };
566
+ const prefill = () => {
567
+ if (!isNew || o.dup) return;
568
+ const r = roles.find((x) => x.id === $("#pe-role").value);
569
+ if (!r) return;
570
+ const ti = $("#pe-instr");
571
+ if (ti.value === prev.instr) ti.value = prev.instr = r.instructions || "";
572
+ if (s.intent === prev.intent) s.intent = prev.intent = r.mode || "ask";
573
+ const cs = $("#pe-cat");
574
+ if (cs.value === prev.cat && r.category) cs.value = prev.cat = r.category;
575
+ };
576
+ if (isNew && !o.dup) prefill();
577
+ sync();
578
+ $("#pe-role").addEventListener("change", () => {
579
+ prefill();
580
+ sync();
581
+ });
582
+ $("#pe-tools").addEventListener("input", sync);
583
+ const newer = () => {
584
+ $("#pe-newer").innerHTML = isStale(
585
+ data,
586
+ $("#pe-rt").value,
587
+ $("#pe-model").value,
588
+ $("#pe-ver").value,
589
+ )
590
+ ? `${newerBadge()} <button type="button" class="lnk" data-x="latest">${esc(t("ver.use"))}</button>`
591
+ : "";
592
+ };
593
+ $("#pe-rt").addEventListener("change", () => {
594
+ $("#pe-model").innerHTML = modelOptionsHtml(
595
+ data,
596
+ $("#pe-rt").value,
597
+ "",
598
+ t("pe.rtDefault"),
599
+ );
600
+ $("#pe-ver").innerHTML = versionOptionsHtml(
601
+ data,
602
+ $("#pe-rt").value,
603
+ "",
604
+ "latest",
605
+ );
606
+ sync();
607
+ newer();
608
+ });
609
+ $("#pe-model").addEventListener("change", () => {
610
+ $("#pe-ver").innerHTML = versionOptionsHtml(
611
+ data,
612
+ $("#pe-rt").value,
613
+ $("#pe-model").value,
614
+ "latest",
615
+ );
616
+ newer();
617
+ });
618
+ $("#pe-ver").addEventListener("change", newer);
619
+ dlg.addEventListener("click", async (e) => {
620
+ const pm = e.target.closest("[data-pm]");
621
+ if (pm && !pm.disabled) {
622
+ e.preventDefault();
623
+ s.intent = pm.dataset.pm;
624
+ sync();
625
+ return;
626
+ }
627
+ const b = e.target.closest("[data-x]");
628
+ if (!b) return;
629
+ const x = b.dataset.x;
630
+ if (x === "cancel") close();
631
+ else if (x === "raw") window.denShell?.toast?.(t("pe.rawLater"));
632
+ else if (x === "latest") {
633
+ $("#pe-ver").value = "latest";
634
+ newer();
635
+ } else if (x === "dup") {
636
+ close();
637
+ openPersonaEditor({ ...o, dup: true });
638
+ } else if (x === "del") {
639
+ try {
640
+ await api("DELETE", `/api/personas/${encodeURIComponent(src.id)}`);
641
+ forgetPersonaDetails();
642
+ window.denShell?.toast?.(t("pe.deleted", { n: personaName(src) }));
643
+ dlg.close?.();
644
+ dlg.remove();
645
+ await o.onSaved?.(null);
646
+ } catch (err) {
647
+ showErr(err);
648
+ }
649
+ }
650
+ });
651
+ const showErr = (err) => {
652
+ const el = $("#pe-err");
653
+ el.textContent = err?.message || String(err);
654
+ el.hidden = false;
655
+ };
656
+ $("#pe-form").addEventListener("submit", async (e) => {
657
+ e.preventDefault();
658
+ if (ro) return;
659
+ const body = editorBody(dlg, s);
660
+ const bad = body.allowedCommands.find((c) => commandProblem(c));
661
+ if (bad) return showErr(new Error(t("pe.badCmd", { c: bad })));
662
+ const go = $("#pe-go");
663
+ go.disabled = true;
664
+ try {
665
+ const r = isNew
666
+ ? await api("POST", "/api/personas", body)
667
+ : await api(
668
+ "POST",
669
+ `/api/personas/${encodeURIComponent(src.id)}`,
670
+ body,
671
+ );
672
+ forgetPersonaDetails();
673
+ window.denShell?.toast?.(t("pe.saved", { n: personaName(r.persona) }));
674
+ dlg.close?.();
675
+ dlg.remove();
676
+ await o.onSaved?.(r.persona);
677
+ } catch (err) {
678
+ showErr(err);
679
+ } finally {
680
+ go.disabled = false;
681
+ }
682
+ });
683
+ if (typeof dlg.showModal === "function") dlg.showModal();
684
+ else dlg.setAttribute("open", "");
685
+ (ro ? $('[data-x="dup"]') : $("#pe-name"))?.focus();
686
+ return dlg;
687
+ }
688
+
689
+ // ── Settings › Personas (AG-10.7) ────────────────────────────────────────
690
+
691
+ /**
692
+ * The Settings › Personas row: the filter bar, the list (View or Edit,
693
+ * Duplicate, Delete for your own), "+ New persona", and "newer
694
+ * available" on one pinned to an older version (AG-12.4).
695
+ */
696
+ export function renderPersonasSettings(host, ctx) {
697
+ const f = { q: "", cat: "", src: "" };
698
+ let data = null;
699
+ const row = (p) => {
700
+ const mine = p.origin === "local";
701
+ const rt = personaRuntime(data, p);
702
+ const stale = isStale(data, rt, p.model, p.version);
703
+ return (
704
+ `<div class="prow" data-pid="${esc(p.id)}"><div class="pn">${personaNameHtml(p)} ${originBadge(p)}${stale ? ` ${newerBadge()}${mine ? ` <button type="button" class="lnk" data-x="latest">${esc(t("ver.use"))}</button>` : ""}` : ""}</div>` +
705
+ `<div class="pd">${esc(p.description || "")}</div>` +
706
+ `<div class="pm"><span class="mono">${esc(runtimeLabel(data, rt))}${p.model ? ` · ${esc(p.model)} · ${esc(versionLabel(p.version))}` : ""}</span> · ${esc(t(`pm.${p.permission || "ask"}`))}</div>` +
707
+ `<div class="pa"><button type="button" class="den-btn sm" data-x="${mine ? "edit" : "view"}">${esc(t(mine ? "pe.editB" : "pe.view"))}</button>` +
708
+ `<button type="button" class="den-btn sm" data-x="dup">${esc(t("pe.dup"))}</button>` +
709
+ (mine
710
+ ? `<button type="button" class="den-btn sm" data-x="del">${esc(t("pe.del"))}</button>`
711
+ : "") +
712
+ `</div></div>`
713
+ );
714
+ };
715
+ const drawList = () => {
716
+ const l = host.querySelector(".plist");
717
+ if (l)
718
+ l.innerHTML = personaListHtml(f, data.personas, data.categories, row);
719
+ };
720
+ const draw = () => {
721
+ host.innerHTML =
722
+ `<div class="pe-set"><div class="fldh"><span class="helpl">${esc(t("pe.what"))}</span><span class="sp"></span><button type="button" class="den-btn sm" data-x="new">${esc(t("ag.new.newP"))}</button></div>` +
723
+ filterBarHtml(f, "set", data.personas, data.categories) +
724
+ `<div class="plist" role="list" aria-label="${esc(t("set.agents.personas"))}"></div></div>`;
725
+ drawList();
726
+ };
727
+ const load = async () => {
728
+ // Under an invalid config the personas answer 422: say why, ask nothing.
729
+ if ((ctx?.shell ?? window.denShell)?.configInvalid?.() === true) {
730
+ host.innerHTML = `<div class="set-note">${esc(t("pe.configFirst"))}</div>`;
731
+ return;
732
+ }
733
+ try {
734
+ data = normalizeData(await api("GET", "/api/personas"));
735
+ draw();
736
+ } catch (e) {
737
+ host.innerHTML = `<div class="set-note">${esc(e?.message || t("pe.loadFail"))}</div>`;
738
+ }
739
+ };
740
+ host.addEventListener("input", (e) => {
741
+ if (e.target.id !== "set-q") return;
742
+ f.q = e.target.value;
743
+ drawList();
744
+ });
745
+ host.addEventListener("click", async (e) => {
746
+ const chip = e.target.closest("[data-pf]");
747
+ if (chip) {
748
+ f[chip.dataset.pf] = chip.dataset.v;
749
+ for (const c of host.querySelectorAll(`[data-pf="${chip.dataset.pf}"]`))
750
+ c.setAttribute("aria-pressed", String(c.dataset.v === chip.dataset.v));
751
+ return drawList();
752
+ }
753
+ const b = e.target.closest("[data-x]");
754
+ if (!b) return;
755
+ const x = b.dataset.x;
756
+ const reload = async () => {
757
+ forgetPersonaDetails();
758
+ await load();
759
+ };
760
+ if (x === "clear") {
761
+ f.q = f.cat = f.src = "";
762
+ return draw();
763
+ }
764
+ if (x === "new") return void openPersonaEditor({ data, onSaved: reload });
765
+ const id = b.closest("[data-pid]")?.dataset.pid;
766
+ if (!id) return;
767
+ try {
768
+ const p = await personaDetail(id);
769
+ if (x === "del") {
770
+ await api("DELETE", `/api/personas/${encodeURIComponent(id)}`);
771
+ window.denShell?.toast?.(t("pe.deleted", { n: personaName(p) }));
772
+ return void reload();
773
+ }
774
+ if (x === "latest") {
775
+ await api("POST", `/api/personas/${encodeURIComponent(id)}`, {
776
+ displayName: p.displayName || "",
777
+ role: p.role,
778
+ category: p.category,
779
+ description: p.description,
780
+ instructions: p.instructions,
781
+ runtime: personaRuntime(data, p),
782
+ model: p.model || "",
783
+ version: "latest",
784
+ permission: p.permission || "ask",
785
+ allowedCommands: p.allowedCommands ?? [],
786
+ });
787
+ window.denShell?.toast?.(t("ver.usedLatest", { n: personaName(p) }));
788
+ return void reload();
789
+ }
790
+ openPersonaEditor({ data, src: p, dup: x === "dup", onSaved: reload });
791
+ } catch (err) {
792
+ window.denShell?.errorToast?.(err);
793
+ }
794
+ });
795
+ void load();
796
+ }