@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
@@ -410,13 +410,23 @@ async function listPersonas(
410
410
  return 0;
411
411
  }
412
412
  for (const l of columns([
413
- ["PERSONA", "STANDING", "RUNTIME", "MODEL", "ROLE"],
413
+ [
414
+ "PERSONA",
415
+ "SOURCE",
416
+ "STANDING",
417
+ "RUNTIME",
418
+ "MODEL",
419
+ "ROLE",
420
+ "DESCRIPTION",
421
+ ],
414
422
  ...all.map((x) => [
415
423
  x.id,
424
+ x.origin,
416
425
  x.standing ? "yes" : "-",
417
426
  x.runtime ?? DEFAULT_RUNTIME_ID,
418
427
  x.model ?? "-",
419
- x.role,
428
+ x.role ?? "-",
429
+ x.description,
420
430
  ]),
421
431
  ]))
422
432
  d.out(l);
@@ -112,7 +112,10 @@ function probeInput(d: ProbeDeps, dir: string): LaunchInput {
112
112
  return {
113
113
  persona: {
114
114
  id: PROBE_ID,
115
- role: "",
115
+ description: "",
116
+ category: "engineering",
117
+ allowedCommands: [],
118
+ origin: "builtin",
116
119
  prompt: "",
117
120
  standing: false,
118
121
  tools: [],
@@ -740,7 +740,7 @@ const agentResumeTool: DenTool = {
740
740
  name: "agent_resume",
741
741
  title: "Resume an agent",
742
742
  description:
743
- "Resume a stopped or interrupted agent in the same conversation, persona, workspace and folder. Never starts a new conversation: an agent whose runtime can't resume is refused. Never the caller itself.",
743
+ "Resume a stopped or interrupted agent in the same conversation, persona, workspace and folder. Never starts a new conversation: an agent whose runtime can't resume is refused. Never the caller itself. Answers with agent: its id, persona, runtime, workspace, cwd, session (the session handle, <dist>-<ws>:<id>), sessionId, displayName (a person's name for it) and sessionTitle (the name its session shows: Den's rename or the runtime's, the later), when set.",
744
744
  action: true,
745
745
  shape: {
746
746
  agent: z.string().describe("The agent's id"),
@@ -12,8 +12,26 @@
12
12
  * runs its version command and
13
13
  * refuses a path that isn't
14
14
  * that runtime (AG-0.5)
15
+ * GET /api/personas/<id> one persona with its
16
+ * instructions (Details)
17
+ * POST /api/personas New persona (AG-10.7): a
18
+ * local one; a person only
19
+ * POST /api/personas/<id> Edit: a local one only
20
+ * (409 read_only otherwise)
21
+ * DELETE /api/personas/<id> Delete: a local one only
15
22
  * GET /api/workspaces/<ws>/agents the workspace's agents
16
- * POST /api/workspaces/<ws>/agents start { persona, runtime?, model?, id? }
23
+ * POST /api/workspaces/<ws>/agents start { persona, runtime?,
24
+ * model?, version?,
25
+ * permission?, allow?,
26
+ * instruction?, id? }
27
+ * (AG-10.1): each checked
28
+ * against the catalog; the
29
+ * instruction is typed into
30
+ * the session once it is
31
+ * ready, never an argument
32
+ * POST /api/workspaces/<ws>/sessions a plain session: the login
33
+ * shell in the workspace's
34
+ * folder (AG-10.1)
17
35
  * POST /api/workspaces/<ws>/agents/<id>/stop
18
36
  * POST /api/workspaces/<ws>/agents/<id>/resume the same conversation (AG-14)
19
37
  * POST /api/workspaces/<ws>/agents/<id>/fresh a new conversation, same
@@ -75,8 +93,16 @@ import { HostError } from "../../shared/sessions/client.js";
75
93
  import {
76
94
  AgentError,
77
95
  agentRecord,
96
+ checkInstruction,
97
+ createPersona,
98
+ deletePersona,
78
99
  listAgents,
79
100
  listPersonas,
101
+ personaDetail,
102
+ registries,
103
+ sendFirstInstruction,
104
+ startPlainSession,
105
+ updatePersona,
80
106
  listRuntimes,
81
107
  markInterrupted,
82
108
  NOT_IN_WORKSPACE,
@@ -112,14 +138,18 @@ import type { Actor } from "../../shared/agent-actor.js";
112
138
  export interface AgentRouteDeps {
113
139
  /** The operations' deps (tests: an in-process host, a temp state dir). */
114
140
  agents?: AgentsDeps;
141
+ /** When a first instruction counts its session ready (tests: shorter). */
142
+ instruction?: { quietMs?: number; maxMs?: number };
115
143
  }
116
144
 
117
145
  const AGENTS_PATH =
118
146
  /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths))?$/;
119
147
 
120
148
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
121
- /** A start's body is a few short names. */
122
- const MAX_BODY = 4096;
149
+ const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
150
+ const PLAIN_PATH = /^\/api\/workspaces\/([^/]+)\/sessions$/;
151
+ /** A start's body: a few names, commands and a first instruction. */
152
+ const MAX_BODY = 32 * 1024;
123
153
  /** A list of names (a page's workspaces, an Undo's agents). */
124
154
  const MANY_BODY = 64 * 1024;
125
155
  const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -174,12 +204,12 @@ function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
174
204
  throw e;
175
205
  }
176
206
 
177
- function sessionNameOf(
207
+ function sessionTitleOf(
178
208
  rec: Started["rec"],
179
209
  d: AgentsResolved,
180
- ): { sessionName?: string } {
210
+ ): { sessionTitle?: string } {
181
211
  const n = agentSessionName(rec, d.stateDir());
182
- return n ? { sessionName: n } : {};
212
+ return n ? { sessionTitle: n } : {};
183
213
  }
184
214
 
185
215
  /** A started or resumed agent's answer: its record, or its early exit. */
@@ -187,6 +217,7 @@ function sendStarted(
187
217
  res: http.ServerResponse,
188
218
  s: Started & { fresh?: boolean },
189
219
  d: AgentsResolved,
220
+ extra: Record<string, unknown> = {},
190
221
  ): void {
191
222
  if (s.early) {
192
223
  const code =
@@ -217,21 +248,47 @@ function sendStarted(
217
248
  // The host's id: the Agents tab opens its pane by it (?open=<id>).
218
249
  sessionId: s.info.ref.id,
219
250
  startedAt: s.rec.startedAt,
251
+ ...(s.rec.model ? { model: s.rec.model } : {}),
252
+ ...(s.rec.version ? { version: s.rec.version } : {}),
253
+ ...(s.rec.permission ? { permission: s.rec.permission } : {}),
254
+ ...(s.rec.overrides?.length ? { overrides: s.rec.overrides } : {}),
220
255
  ...(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),
256
+ // AG-4b: the name its session shows (Den's rename or the runtime's),
257
+ // as the lists show it (MCP agent_resume answers with this). Its
258
+ // handle is `session`.
259
+ ...sessionTitleOf(s.rec, d),
224
260
  },
225
261
  // A resume with nothing to continue yet started fresh (same id).
226
262
  ...(s.fresh ? { fresh: true } : {}),
227
263
  ...(s.warnings.length ? { warnings: s.warnings } : {}),
264
+ ...extra,
228
265
  });
229
266
  }
230
267
 
231
- /** What a POST's body names: a few optional strings. */
232
- async function startBody(
233
- req: http.IncomingMessage,
234
- ): Promise<Record<string, string | undefined>> {
268
+ /** What a start's body names: optional strings, a command list, an instruction. */
269
+ interface StartBody {
270
+ persona?: string;
271
+ runtime?: string;
272
+ model?: string;
273
+ id?: string;
274
+ version?: string;
275
+ permission?: string;
276
+ allow?: unknown;
277
+ instruction?: unknown;
278
+ }
279
+
280
+ const START_KEYS = [
281
+ "persona",
282
+ "runtime",
283
+ "model",
284
+ "id",
285
+ "version",
286
+ "permission",
287
+ "allow",
288
+ "instruction",
289
+ ];
290
+
291
+ async function startBody(req: http.IncomingMessage): Promise<StartBody> {
235
292
  const raw = await readBody(req).catch(() => "");
236
293
  if (raw.length > MAX_BODY)
237
294
  throw new AgentError("request too large", { status: 413, usage: true });
@@ -243,15 +300,38 @@ async function startBody(
243
300
  }
244
301
  if (!v || typeof v !== "object" || Array.isArray(v))
245
302
  throw new AgentError("the body must be a JSON object", { usage: true });
246
- const out: Record<string, string | undefined> = {};
247
- for (const k of ["persona", "runtime", "model", "id"]) {
248
- const x = (v as Record<string, unknown>)[k];
303
+ const o = v as Record<string, unknown>;
304
+ for (const k of Object.keys(o))
305
+ if (!START_KEYS.includes(k))
306
+ throw new AgentError(
307
+ `${k}: unknown field (a start takes ${START_KEYS.join(", ")})`,
308
+ { usage: true },
309
+ );
310
+ const out: Record<string, unknown> = {};
311
+ for (const k of [
312
+ "persona",
313
+ "runtime",
314
+ "model",
315
+ "id",
316
+ "version",
317
+ "permission",
318
+ ]) {
319
+ const x = o[k];
249
320
  if (x === undefined || x === null || x === "") continue;
250
321
  if (typeof x !== "string")
251
322
  throw new AgentError(`${k} must be a string`, { usage: true });
323
+ if (x.length > 128 || /[^\x21-\x7e]/.test(x))
324
+ throw new AgentError(
325
+ `${k} must be a short name (no spaces or control characters)`,
326
+ {
327
+ usage: true,
328
+ },
329
+ );
252
330
  out[k] = x;
253
331
  }
254
- return out;
332
+ if (o.allow !== undefined && o.allow !== null) out.allow = o.allow;
333
+ if (o.instruction !== undefined) out.instruction = o.instruction;
334
+ return out as StartBody;
255
335
  }
256
336
 
257
337
  /**
@@ -267,6 +347,8 @@ export async function handleAgentRoutes(
267
347
  ): Promise<boolean> {
268
348
  const m = AGENTS_PATH.exec(pathname);
269
349
  const rp = RUNTIME_PATH.exec(pathname);
350
+ const pp = PERSONA_PATH.exec(pathname);
351
+ const plain = PLAIN_PATH.exec(pathname);
270
352
  // AG-14's routes over many agents at once.
271
353
  const many =
272
354
  pathname === "/api/agents/clear-stopped" ||
@@ -277,22 +359,34 @@ export async function handleAgentRoutes(
277
359
  pathname !== "/api/agents/runtimes" &&
278
360
  !m &&
279
361
  !rp &&
362
+ !pp &&
363
+ !plain &&
280
364
  !many
281
365
  )
282
366
  return false;
283
367
  const method = req.method ?? "GET";
284
368
  const d = webDeps(deps.agents);
285
369
 
370
+ if (pp) return personaRoute(req, res, safeDecode(pp[1]), d);
371
+ if (plain) return plainSession(req, res, safeDecode(plain[1]), d);
372
+ if (pathname === "/api/personas" && method === "POST")
373
+ return personaRoute(req, res, null, d);
374
+
286
375
  if (rp) return runtimePath(req, res, safeDecode(rp[1]), d);
287
376
  if (many) return manyAgents(req, res, pathname, d);
288
377
  if (!m) {
289
378
  if (method !== "GET" && method !== "HEAD")
290
- return methodNotAllowed(res, "GET");
379
+ return methodNotAllowed(
380
+ res,
381
+ pathname === "/api/personas" ? "GET, POST" : "GET",
382
+ );
291
383
  try {
292
384
  const ws = queryWorkspace(req, d);
293
385
  const runtimes = await listRuntimes(d, ws);
294
386
  sendJson(res, {
295
- ...(pathname === "/api/personas" ? { personas: listPersonas(d) } : {}),
387
+ ...(pathname === "/api/personas"
388
+ ? { personas: listPersonas(d), ...registries(d) }
389
+ : {}),
296
390
  runtimes,
297
391
  ...(ws ? { workspace: ws } : {}),
298
392
  // AG-0.2: the file a person edits to set a runtime's path.
@@ -403,16 +497,54 @@ export async function handleAgentRoutes(
403
497
  if (!body.persona)
404
498
  throw new AgentError("persona is required", { usage: true });
405
499
  what = `start ${body.id ?? body.persona} in ${ws}`;
500
+ const instruction = checkInstruction(body.instruction);
406
501
  const s = await startAgent(d, {
407
502
  workspace: ws,
408
503
  persona: body.persona,
409
504
  runtime: body.runtime,
410
505
  model: body.model,
411
506
  id: body.id,
507
+ ...(body.version !== undefined ? { version: body.version } : {}),
508
+ ...(body.permission !== undefined
509
+ ? { permission: body.permission }
510
+ : {}),
511
+ ...(body.allow !== undefined ? { allow: body.allow } : {}),
512
+ strict: true,
412
513
  });
413
- what = `start ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
514
+ const ov = s.rec.overrides?.length
515
+ ? `; overrides ${s.rec.overrides.join(", ")}`
516
+ : "";
517
+ what = `start ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}${ov}) in ${ws}`;
414
518
  audit(what, s.early ? "exited at once" : undefined);
415
- sendStarted(res, s, d);
519
+ sendStarted(res, s, d, instruction ? { instruction: "pending" } : {});
520
+ // AG-10.1: its first message, once it is ready: typed into its
521
+ // session (the host's write path), never an argument. Its text is
522
+ // never logged; its length is.
523
+ if (instruction && !s.early) {
524
+ const id = s.rec.id;
525
+ void sendFirstInstruction(
526
+ d,
527
+ s.info.ref.id,
528
+ s.rec.runtime,
529
+ instruction,
530
+ deps.instruction ?? {},
531
+ ).then((sent) =>
532
+ recordAction(
533
+ actor,
534
+ {
535
+ verb: "agents",
536
+ detail: `first instruction to ${id} in ${ws} (${instruction.length} characters)`,
537
+ ...(sent
538
+ ? {}
539
+ : {
540
+ outcome: "refused" as const,
541
+ reason: "its session ended or no host",
542
+ }),
543
+ },
544
+ principalOf(req),
545
+ ),
546
+ );
547
+ }
416
548
  } else if (verb === "stop") {
417
549
  // The answer comes once the session has ended (200, stopped: true),
418
550
  // or, still running after the wait, 202 with stillStopping: true.
@@ -586,6 +718,113 @@ function queryWorkspace(
586
718
  return ws;
587
719
  }
588
720
 
721
+ /**
722
+ * The persona routes (AG-10.6, 10.7): GET one (anyone who may read);
723
+ * POST (new: `id` null; edit: a local one), DELETE (a local one): a
724
+ * person only. An agent's token can't make or change a persona (its
725
+ * permissions are the person's to set). Every change is audited, refused
726
+ * or done; never the instructions themselves.
727
+ */
728
+ async function personaRoute(
729
+ req: http.IncomingMessage,
730
+ res: http.ServerResponse,
731
+ id: string | null,
732
+ d: AgentsResolved,
733
+ ): Promise<true> {
734
+ const method = req.method ?? "GET";
735
+ if (id !== null && (method === "GET" || method === "HEAD")) {
736
+ try {
737
+ sendJson(res, { persona: personaDetail(d, id) });
738
+ } catch (e) {
739
+ sendError(res, e);
740
+ }
741
+ return true;
742
+ }
743
+ if (method !== "POST" && !(method === "DELETE" && id !== null))
744
+ return methodNotAllowed(
745
+ res,
746
+ id === null ? "GET, POST" : "GET, POST, DELETE",
747
+ );
748
+ const actor = auditActorOf(req);
749
+ const verb = id === null ? "create" : method === "DELETE" ? "delete" : "edit";
750
+ let what = `persona ${verb}${id ? ` ${id}` : ""}`;
751
+ const audit = (reason?: string) =>
752
+ recordAction(
753
+ actor,
754
+ {
755
+ verb: "agents",
756
+ detail: what,
757
+ ...(reason ? { outcome: "refused" as const, reason } : {}),
758
+ },
759
+ principalOf(req),
760
+ );
761
+ try {
762
+ if (actor && !actor.unverified)
763
+ throw new AgentError(
764
+ "an agent can't create, change or delete personas: a person sets what agents may do",
765
+ { status: 403 },
766
+ );
767
+ if (verb === "delete") {
768
+ deletePersona(d, id!);
769
+ audit();
770
+ sendJson(res, { ok: true, deleted: id });
771
+ return true;
772
+ }
773
+ const body = await jsonBody(req);
774
+ const p =
775
+ verb === "create" ? createPersona(d, body) : updatePersona(d, id!, body);
776
+ what = `persona ${verb} ${p.id}`;
777
+ audit();
778
+ sendJson(res, { ok: true, persona: p });
779
+ } catch (e) {
780
+ audit(e instanceof Error ? e.message : String(e));
781
+ sendError(res, e);
782
+ }
783
+ return true;
784
+ }
785
+
786
+ /**
787
+ * POST /api/workspaces/<ws>/sessions (AG-10.1 Plain session): a person's
788
+ * login shell in the workspace's folder. A person only; audited.
789
+ */
790
+ async function plainSession(
791
+ req: http.IncomingMessage,
792
+ res: http.ServerResponse,
793
+ ws: string,
794
+ d: AgentsResolved,
795
+ ): Promise<true> {
796
+ if (req.method !== "POST") return methodNotAllowed(res, "POST");
797
+ const actor = auditActorOf(req);
798
+ let what = `plain session in ${ws}`;
799
+ const audit = (reason?: string) =>
800
+ recordAction(
801
+ actor,
802
+ {
803
+ verb: "sessions",
804
+ detail: what,
805
+ ...(reason ? { outcome: "refused" as const, reason } : {}),
806
+ },
807
+ principalOf(req),
808
+ );
809
+ try {
810
+ if (actor && !actor.unverified)
811
+ throw new AgentError("an agent can't start a session from the page", {
812
+ status: 403,
813
+ });
814
+ const { info } = await startPlainSession(d, ws);
815
+ what = `plain session ${info.name} in ${ws}`;
816
+ audit();
817
+ sendJson(res, {
818
+ ok: true,
819
+ session: { id: info.ref.id, name: info.name, workspace: ws },
820
+ });
821
+ } catch (e) {
822
+ audit(e instanceof Error ? e.message : String(e));
823
+ sendError(res, e, ws);
824
+ }
825
+ return true;
826
+ }
827
+
589
828
  /**
590
829
  * POST /api/agents/runtimes/<id>/path: a person only, never an agent (it
591
830
  * runs the path given). Audited, refused or done; never the path's output.
@@ -37,9 +37,10 @@ export function personaOptions(personas) {
37
37
  .join("");
38
38
  }
39
39
 
40
- /** The help line under the persona select: the picked persona's role. */
40
+ /** The help line under the persona select: the picked persona's description. */
41
41
  export function personaHelp(personas, id) {
42
- return (personas ?? []).find((p) => p.id === id)?.role ?? "";
42
+ const p = (personas ?? []).find((x) => x.id === id);
43
+ return p?.description ?? p?.role ?? "";
43
44
  }
44
45
 
45
46
  /** A runtime's name for people ("Claude Code"); its id when it has none. */