@monster-orange/den 0.4.18 → 0.4.19

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 (45) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +14 -13
  3. package/docs/distributions.md +99 -24
  4. package/docs/reference.md +1 -1
  5. package/package.json +2 -1
  6. package/skills/install-den/SKILL.md +35 -75
  7. package/src/shared/agents/den-claude.ts +40 -6
  8. package/src/shared/agents/guardrails/stop-hook.ts +30 -4
  9. package/src/shared/agents/guardrails.ts +40 -4
  10. package/src/shared/agents/handback.ts +22 -4
  11. package/src/shared/agents/personas.ts +32 -5
  12. package/src/shared/agents/runtime.ts +8 -0
  13. package/src/shared/agents/server-agent.ts +57 -0
  14. package/src/shared/agents/server-pack/server.md +45 -0
  15. package/src/shared/environment-manager/workspaces.ts +4 -0
  16. package/src/ui/cli/agent-ops.ts +41 -6
  17. package/src/ui/cli/create.ts +2 -2
  18. package/src/ui/cli/index.ts +1 -1
  19. package/src/ui/cli/repo-command.ts +8 -1
  20. package/src/ui/cli/repo-detect.ts +58 -0
  21. package/src/ui/cli/server-agent-ops.ts +411 -0
  22. package/src/ui/mcp/server.ts +29 -12
  23. package/src/ui/mcp/tools.ts +4 -2
  24. package/src/ui/server/agent-routes.ts +1 -1
  25. package/src/ui/server/held-actions.ts +35 -7
  26. package/src/ui/server/index.ts +20 -5
  27. package/src/ui/server/public/agents-page.js +10 -1
  28. package/src/ui/server/public/error-page.js +39 -1
  29. package/src/ui/server/public/graph.css +21 -0
  30. package/src/ui/server/public/graph.js +49 -6
  31. package/src/ui/server/public/header.js +29 -7
  32. package/src/ui/server/public/i18n/es.js +37 -0
  33. package/src/ui/server/public/i18n/ja.js +39 -0
  34. package/src/ui/server/public/local-dev.html +22 -1
  35. package/src/ui/server/public/persona-ui.css +6 -0
  36. package/src/ui/server/public/server-agent-page.js +415 -0
  37. package/src/ui/server/public/server-agent.html +222 -0
  38. package/src/ui/server/public/shell.js +66 -6
  39. package/src/ui/server/public/svc-panel.js +27 -14
  40. package/src/ui/server/public/topbar.js +13 -1
  41. package/src/ui/server/route-classes.ts +21 -0
  42. package/src/ui/server/server-agent-routes.ts +176 -0
  43. package/src/ui/server/session-routes.ts +12 -1
  44. package/src/ui/server/ui-assets.ts +2 -0
  45. package/src/ui/server/workspace-routes.ts +3 -0
@@ -48,10 +48,13 @@
48
48
  import { randomUUID } from "crypto";
49
49
  import * as fs from "fs";
50
50
  import * as path from "path";
51
- import { configuredRuntimeBin } from "../den-config.js";
51
+ import { MCP_ACTIONS, configuredRuntimeBin } from "../den-config.js";
52
52
  import { rolePrompt } from "./personas.js";
53
53
  import { GUARDRAILS_DIR, guardrailHooks, hookCommands } from "./guardrails.js";
54
54
  import { DEFAULT_ROTATE } from "./rotation.js";
55
+ import { idsInNames } from "./handback.js";
56
+ import { isServerScope } from "./server-agent.js";
57
+ import { product } from "../product.js";
55
58
  import {
56
59
  LaunchError,
57
60
  mcpConfigJson,
@@ -140,6 +143,25 @@ export const DEN_CLAUDE_PERMISSIONS: RuntimePermissions = {
140
143
  commandRules: COMMAND_RULES,
141
144
  };
142
145
 
146
+ /**
147
+ * ON-6, the server agent: Den's own MCP server's tools as Claude Code
148
+ * permission rules (https://code.claude.com/docs/en/iam, MCP rules:
149
+ * `mcp__<server>` matches every tool of that server, `mcp__<server>__*`
150
+ * the same with a wildcard). `ask` keeps the org's immediate actions
151
+ * (mcp.actions: they run at once, with no browser Allow) asking in the
152
+ * terminal: Claude Code checks ask before allow. `server` is the name in
153
+ * the --mcp-config file.
154
+ */
155
+ export function denToolRules(server: string): {
156
+ allow: string[];
157
+ ask: string[];
158
+ } {
159
+ return {
160
+ allow: [`mcp__${server}`, `mcp__${server}__*`],
161
+ ask: MCP_ACTIONS.map((a) => `mcp__${server}__${a}`),
162
+ };
163
+ }
164
+
143
165
  /** A command prefix's allow rules (permissions.ts checked it). */
144
166
  export function allowRules(commands: readonly string[]): string[] {
145
167
  return commands.flatMap((c) =>
@@ -336,13 +358,24 @@ function common(input: LaunchInput): {
336
358
  const hooks = input.persona.guardrails?.hooks
337
359
  ? guardrailHooks(
338
360
  GUARDRAILS_DIR,
339
- hookCommands(GUARDRAILS_DIR, process.execPath, {
340
- files: input.filesDir,
341
- after: input.rotateAfter ?? DEFAULT_ROTATE.afterCompactions,
342
- }),
361
+ hookCommands(
362
+ GUARDRAILS_DIR,
363
+ process.execPath,
364
+ {
365
+ files: input.filesDir,
366
+ after: input.rotateAfter ?? DEFAULT_ROTATE.afterCompactions,
367
+ },
368
+ // A Den named "den-t2" says "Den T2": a name, not an ID.
369
+ idsInNames([product().title, input.workspace.name]),
370
+ // ON-6: the server agent talks with the person; it hands nothing
371
+ // back, so its messages get the plain-writing check only.
372
+ !isServerScope(input.workspace.name),
373
+ ),
343
374
  )
344
375
  : null;
345
- const allow = allowRules(input.allow ?? []);
376
+ const den = input.denToolsAllowed ? denToolRules(input.mcp.name) : null;
377
+ const allow = [...allowRules(input.allow ?? []), ...(den?.allow ?? [])];
378
+ const ask = den?.ask ?? [];
346
379
  const settings =
347
380
  input.stateDir || hooks || allow.length ? settingsPath(input) : null;
348
381
  return {
@@ -372,6 +405,7 @@ function common(input: LaunchInput): {
372
405
  ? {
373
406
  permissions: {
374
407
  ...(allow.length ? { allow } : {}),
408
+ ...(ask.length ? { ask } : {}),
375
409
  ...(input.stateDir
376
410
  ? {
377
411
  deny: stateDirDenyRules(
@@ -14,6 +14,14 @@
14
14
  * (stop_hook_active), it lets the turn end, so it never loops. Any error
15
15
  * lets the turn end too.
16
16
  *
17
+ * `--no-handback` (the server agent, ON-6): it talks with the person and
18
+ * hands no work back, so even a message with the Hand-back heading gets
19
+ * only the plain-writing check.
20
+ *
21
+ * `--known <word>` (repeated): ID-shaped words in the names Den gives
22
+ * (its title, the workspace), say "T2" in "Den T2". They are names, never
23
+ * flagged as bare IDs.
24
+ *
17
25
  * Session rotation (den#4): run with `--files <agent files dir> --after
18
26
  * <n>`, while a rotation is due (rotation.ts) and handoff.md is missing or
19
27
  * fails the handoff check, it blocks once with what to write. Once the
@@ -54,11 +62,23 @@ function rotationBlock(
54
62
  });
55
63
  }
56
64
 
65
+ /** The `--known <word>` values in the hook's arguments. */
66
+ export function knownArgs(argv: readonly string[]): string[] {
67
+ const out: string[] = [];
68
+ argv.forEach((a, i) => {
69
+ if (a === "--known" && argv[i + 1]) out.push(argv[i + 1]);
70
+ });
71
+ return out;
72
+ }
73
+
57
74
  /** The hook's stdout for one Stop input ("" lets the turn end). */
58
75
  export function stopDecision(
59
76
  input: unknown,
60
77
  rotation?: RotationHookArgs | null,
78
+ knownNames: readonly string[] = [],
79
+ handbackCheck: boolean = true,
61
80
  ): string {
81
+ const known = new Set(knownNames);
62
82
  if (!input || typeof input !== "object") return "";
63
83
  const i = input as {
64
84
  stop_hook_active?: unknown;
@@ -74,10 +94,10 @@ export function stopDecision(
74
94
  if (typeof msg !== "string") return "";
75
95
  // A hand-back is checked whole. Any other final message is what the
76
96
  // person reads too, so it gets the plain-writing check: no bare IDs.
77
- const handback = looksLikeHandback(msg);
97
+ const handback = handbackCheck && looksLikeHandback(msg);
78
98
  const problems = handback
79
- ? checkHandback(msg).problems
80
- : bareIds(msg).map(
99
+ ? checkHandback(msg, known).problems
100
+ : bareIds(msg, new Set(), known).map(
81
101
  (id) =>
82
102
  `"${id}" is not explained. Say what it is, e.g. "${id} (what it is)".`,
83
103
  );
@@ -98,7 +118,13 @@ async function main(): Promise<void> {
98
118
  for await (const chunk of process.stdin) text += String(chunk);
99
119
  let out = "";
100
120
  try {
101
- out = stopDecision(JSON.parse(text), parseHookArgs(process.argv.slice(2)));
121
+ const argv = process.argv.slice(2);
122
+ out = stopDecision(
123
+ JSON.parse(text),
124
+ parseHookArgs(argv),
125
+ knownArgs(argv),
126
+ !argv.includes("--no-handback"),
127
+ );
102
128
  } catch {
103
129
  out = "";
104
130
  }
@@ -50,9 +50,34 @@ export const GUARDRAILS_DIR = enginePath(
50
50
  "guardrails",
51
51
  );
52
52
 
53
- /** The shared persona instructions, as Den ships them. */
54
- export function sharedInstructions(dir: string = GUARDRAILS_DIR): string {
55
- return fs.readFileSync(path.join(dir, "instructions.md"), "utf-8").trim();
53
+ /** What the server agent reads in place of "Handing work back" (ON-6). */
54
+ export const NO_HANDBACK_SECTION = `### Talking with the person
55
+
56
+ You talk with the person as you go; there is no work to hand back, so don't
57
+ write a hand-back. End each turn with a plain sentence or one question.
58
+ Every message you end a turn with is read by a person: explain each ID in it.`;
59
+
60
+ /**
61
+ * The shared persona instructions, as Den ships them. `handback: false`
62
+ * (the server agent, ON-6) swaps the "Handing work back" section, a build
63
+ * agent's template (URL, Branch, yes/no decisions), for NO_HANDBACK_SECTION.
64
+ */
65
+ export function sharedInstructions(
66
+ dir: string = GUARDRAILS_DIR,
67
+ opts: { handback?: boolean } = {},
68
+ ): string {
69
+ const text = fs
70
+ .readFileSync(path.join(dir, "instructions.md"), "utf-8")
71
+ .trim();
72
+ if (opts.handback !== false) return text;
73
+ const at = text.indexOf("### Handing work back");
74
+ if (at < 0) return text;
75
+ const next = text.indexOf("\n### ", at + 1);
76
+ return (
77
+ text.slice(0, at) +
78
+ NO_HANDBACK_SECTION +
79
+ (next < 0 ? "" : `\n${text.slice(next)}`)
80
+ ).trim();
56
81
  }
57
82
 
58
83
  /** One argument for a POSIX shell, single-quoted when it needs it. */
@@ -88,6 +113,10 @@ export function hookCommands(
88
113
  dir: string = GUARDRAILS_DIR,
89
114
  node: string = process.execPath,
90
115
  rotation?: RotationArgs,
116
+ /** ID-shaped words in Den's own names, for the Stop hook (stop-hook.ts). */
117
+ known: readonly string[] = [],
118
+ /** false: the Stop hook never runs the hand-back check (the server agent). */
119
+ handback: boolean = true,
91
120
  ): HookCommands {
92
121
  const more = (c: EngineCommand): EngineCommand =>
93
122
  rotation
@@ -108,7 +137,14 @@ export function hookCommands(
108
137
  args: [path.join(dir, "pretooluse.mjs")],
109
138
  }),
110
139
  stop: commandLine(
111
- more(scriptEntrypoint("src/shared/agents/guardrails/stop-hook.ts")),
140
+ ((c: EngineCommand): EngineCommand => ({
141
+ command: c.command,
142
+ args: [
143
+ ...c.args,
144
+ ...known.flatMap((k) => ["--known", k]),
145
+ ...(handback ? [] : ["--no-handback"]),
146
+ ],
147
+ }))(more(scriptEntrypoint("src/shared/agents/guardrails/stop-hook.ts"))),
112
148
  ),
113
149
  rotation: commandLine(
114
150
  more(scriptEntrypoint("src/shared/agents/guardrails/rotation-hook.ts")),
@@ -151,10 +151,25 @@ export function isGlossed(line: string, start: number, end: number): boolean {
151
151
  return false;
152
152
  }
153
153
 
154
- /** Bare IDs in the text, in order, each once. */
154
+ /**
155
+ * The ID-shaped words in names Den itself gives (the product's title, the
156
+ * workspace): a Den named "den-t2" is "Den T2", and its agent says so.
157
+ * They are names, not IDs to explain.
158
+ */
159
+ export function idsInNames(names: readonly (string | undefined)[]): string[] {
160
+ const out = new Set<string>();
161
+ for (const n of names)
162
+ for (const re of ID_PATTERNS)
163
+ for (const m of String(n ?? "").matchAll(new RegExp(re.source, "g")))
164
+ out.add(m[0]);
165
+ return [...out];
166
+ }
167
+
168
+ /** Bare IDs in the text, in order, each once. `known` are names, never bare. */
155
169
  export function bareIds(
156
170
  text: string,
157
171
  skipLines: Set<number> = new Set(),
172
+ known: ReadonlySet<string> = new Set(),
158
173
  ): string[] {
159
174
  const glossed = new Set<string>();
160
175
  const bare: string[] = [];
@@ -172,7 +187,7 @@ export function bareIds(
172
187
  hits.push({ id: m[0], start: m.index!, end: m.index! + m[0].length });
173
188
  hits.sort((a, b) => a.start - b.start);
174
189
  for (const h of hits) {
175
- if (glossed.has(h.id)) continue;
190
+ if (glossed.has(h.id) || known.has(h.id)) continue;
176
191
  if (isGlossed(line, h.start, h.end)) glossed.add(h.id);
177
192
  else if (!bare.includes(h.id)) bare.push(h.id);
178
193
  }
@@ -208,7 +223,10 @@ function decisionProblems(items: string[]): string[] {
208
223
  }
209
224
 
210
225
  /** Check a hand-back. Pure: the text in, the problems out. */
211
- export function checkHandback(text: string): HandbackResult {
226
+ export function checkHandback(
227
+ text: string,
228
+ known: ReadonlySet<string> = new Set(),
229
+ ): HandbackResult {
212
230
  const lines = String(text ?? "").split(/\r?\n/);
213
231
  const { found, literal } = splitParts(lines);
214
232
  const problems: string[] = [];
@@ -233,7 +251,7 @@ export function checkHandback(text: string): HandbackResult {
233
251
  `"URL:" has no link. Give the full address, with http:// or https://, or say none if this work has no page.`,
234
252
  );
235
253
  }
236
- for (const id of bareIds(lines.join("\n"), literal))
254
+ for (const id of bareIds(lines.join("\n"), literal, known))
237
255
  problems.push(
238
256
  `"${id}" is not explained. Say what it is, e.g. "${id} (what it is)".`,
239
257
  );
@@ -42,6 +42,11 @@ import { WORKSPACE_NAME_RE } from "../agent-actor.js";
42
42
  import { checkCommands } from "./permissions.js";
43
43
  import { DEN_ROLES, REGISTRY_ID_RE } from "./roles.js";
44
44
  import { enginePath } from "../engine.js";
45
+ import {
46
+ SERVER_PACK_DIR,
47
+ SERVER_PERSONA_ID,
48
+ isServerScope,
49
+ } from "./server-agent.js";
45
50
  import {
46
51
  DEFAULT_PACK_GUARDRAILS,
47
52
  PACK_GUARDRAILS,
@@ -356,6 +361,23 @@ export function loadPersonas(
356
361
  return all;
357
362
  }
358
363
 
364
+ /**
365
+ * The server agent's persona (ON-6), from Den's own server pack, with the
366
+ * default pack's guardrails (the plain-writing rule among them). Only the
367
+ * server scope loads it, so it is never offered for a workspace.
368
+ */
369
+ export function serverPersona(): Persona {
370
+ const p = readPack(SERVER_PACK_DIR, "builtin").find(
371
+ (x) => x.id === SERVER_PERSONA_ID,
372
+ );
373
+ if (!p)
374
+ throw new Error(`${SERVER_PACK_DIR}: no "${SERVER_PERSONA_ID}" persona`);
375
+ return {
376
+ ...p,
377
+ guardrails: { ...DEFAULT_PACK_GUARDRAILS, ...p.guardrailsSet },
378
+ };
379
+ }
380
+
359
381
  /**
360
382
  * The role prompt as an agent gets it: who and where it is, what to read
361
383
  * first, the persona's own prompt, then Den's shared instructions
@@ -368,14 +390,19 @@ export function rolePrompt(
368
390
  workspace: string,
369
391
  mcpName: string,
370
392
  ): string {
371
- const lines = [
372
- `You are agent ${agentId} in workspace ${workspace}, with the ${persona.id} persona.`,
373
- `Act on services, logs and tests only through the ${mcpName} MCP server; it serves this workspace.`,
374
- ];
393
+ const lines = isServerScope(workspace)
394
+ ? [
395
+ `You are agent ${agentId}, this Den's own server agent, with the ${persona.id} persona. You belong to no workspace.`,
396
+ `Act only through the ${mcpName} MCP server; it serves the whole Den, and each write waits for the person's Allow.`,
397
+ ]
398
+ : [
399
+ `You are agent ${agentId} in workspace ${workspace}, with the ${persona.id} persona.`,
400
+ `Act on services, logs and tests only through the ${mcpName} MCP server; it serves this workspace.`,
401
+ ];
375
402
  if (persona.context.length)
376
403
  lines.push(`Read first: ${persona.context.join("; ")}.`);
377
404
  const shared = persona.guardrails?.instructions
378
- ? `\n\n${sharedInstructions()}`
405
+ ? `\n\n${sharedInstructions(undefined, { handback: !isServerScope(workspace) })}`
379
406
  : "";
380
407
  return `${lines.join("\n")}\n\n${persona.prompt}${shared}`;
381
408
  }
@@ -107,6 +107,14 @@ export interface LaunchInput {
107
107
  * role's preset and the extra commands, checked (permissions.ts).
108
108
  */
109
109
  allow?: string[];
110
+ /**
111
+ * ON-6: the server agent's own: Den's MCP tools run without the
112
+ * runtime's own prompt. Den's reads only read, and its writes are held
113
+ * for the person's Allow in the browser, so each write asks once there.
114
+ * The org's immediate actions (mcp.actions) still ask in the terminal.
115
+ * Nothing outside Den's MCP server is allowed by it.
116
+ */
117
+ denToolsAllowed?: boolean;
110
118
  /**
111
119
  * The new conversation's first prompt (a relaunch from handoff.md).
112
120
  * Only a runtime with `firstPrompt: true` is given one.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The server agent (ON-6): the Den server's own agent, named after the
3
+ * distribution ("<Product> agent"). It belongs to no workspace. It runs
4
+ * under a reserved scope, `den-server`, so it is a normal Den agent
5
+ * underneath (records, sessions, resume, rotation, hooks) while its MCP
6
+ * server sees the whole Den. It acts only through Den's MCP actions, and
7
+ * each write is held for the person's Allow (ON-3).
8
+ *
9
+ * No workspace may take the reserved name: `workspace create` and the
10
+ * MCP's workspace_create refuse it.
11
+ */
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import { enginePath } from "../engine.js";
15
+
16
+ /** The reserved scope the server agent's records and session live in. */
17
+ export const SERVER_SCOPE = "den-server";
18
+
19
+ /** The server agent's id, and its persona's. */
20
+ export const SERVER_AGENT_ID = "server";
21
+ export const SERVER_PERSONA_ID = "server";
22
+
23
+ /** Den's own pack for it, shipped in the package (src/ ships whole). */
24
+ export const SERVER_PACK_DIR = enginePath(
25
+ "src",
26
+ "shared",
27
+ "agents",
28
+ "server-pack",
29
+ );
30
+
31
+ /** Whether `ws` is the server agent's scope (never a workspace). */
32
+ export function isServerScope(ws: unknown): boolean {
33
+ return ws === SERVER_SCOPE;
34
+ }
35
+
36
+ /**
37
+ * The server agent's folder under the state dir: an empty folder it
38
+ * starts in, so it has no checkout to edit and acts through Den's MCP.
39
+ * Made when asked for.
40
+ */
41
+ export function serverAgentDir(stateDir: string): string {
42
+ const dir = path.join(stateDir, "server-agent");
43
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
44
+ return dir;
45
+ }
46
+
47
+ /** Its name: the distribution's, then "agent" ("Acme agent"). */
48
+ export function serverAgentName(title: string): string {
49
+ return `${title} agent`;
50
+ }
51
+
52
+ /** The refusal for a workspace that would take the reserved name. */
53
+ export function reservedWorkspaceError(ws: string): string | null {
54
+ return isServerScope(ws)
55
+ ? `"${ws}" is reserved for the server agent; pick another workspace name`
56
+ : null;
57
+ }
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: server
3
+ category: operations
4
+ description: This Den's own agent. Sets it up and looks after it, through Den's MCP actions.
5
+ permission_mode: ask
6
+ resume_on_restart: true
7
+ context: [onboard_status, the doctor's checks]
8
+ ---
9
+
10
+ You are this Den's own agent. You belong to no workspace: your scope is
11
+ the whole Den. You act only through Den's MCP tools. You have no checkout:
12
+ don't edit files or run shell commands to change Den or the person's repos.
13
+
14
+ Every write (workspace_create, repo_add, service_try) waits for the person
15
+ to Allow it in Den's browser dialog. Until they do, nothing changes, and
16
+ you only read. Say so the first time you ask for one.
17
+
18
+ ## On a fresh Den, set it up
19
+
20
+ 1. Call onboard_status to see what is there.
21
+ 2. Ask where their repos are, with the folder above the one Den lives in as
22
+ the default. Call repos_detect on it and suggest workspaces. A workspace
23
+ is a set of repos that run together: say so the first time.
24
+ 3. Call workspace_create for each workspace the person agrees to. It also
25
+ makes the workspace active, so its services can start.
26
+ 4. For each repo, call manifest_draft, then repo_add. Den brings the repo in
27
+ unchanged, with a side-car manifest. If Den can't tell how a repo starts,
28
+ ask the person one question.
29
+ 5. Call service_try on one service. If it fails, call doctor and read the
30
+ service's logs. Fix what you can through Den's tools; otherwise tell the
31
+ person the one command to run.
32
+ 6. Finish with "Here's what's running, and three things to try next.", then
33
+ the services and their health, then three numbered things to try.
34
+
35
+ ## After setup
36
+
37
+ The person comes back to you for anything about the server itself: add a
38
+ repo, fix a failing service, change a setting, explain what's running.
39
+
40
+ ## Never
41
+
42
+ - Never ask for a token, password or secret in chat. Name the command the
43
+ person runs themselves.
44
+ - Never run sudo. When Den needs the password, it asks in its own prompt.
45
+ - Never edit the person's repos.
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import * as fs from "fs";
13
+ import { reservedWorkspaceError } from "../agents/server-agent.js";
13
14
  import * as path from "path";
14
15
  import * as yaml from "yaml";
15
16
  import type {
@@ -508,6 +509,9 @@ export function setActiveWorkspace(name: string): void {
508
509
  }
509
510
 
510
511
  export function createWorkspace(name: string, config: WorkspaceConfig): void {
512
+ // ON-6: the server agent's scope is never a workspace.
513
+ const reserved = reservedWorkspaceError(name);
514
+ if (reserved) throw new Error(reserved);
511
515
  const file = readWorkspacesFile();
512
516
  file.workspaces[name] = config;
513
517
  writeWorkspacesFile(file);
@@ -78,6 +78,7 @@ import { envKey } from "../../shared/product.js";
78
78
  import { redactSecrets } from "../../shared/redact.js";
79
79
  import {
80
80
  loadPersonas,
81
+ serverPersona,
81
82
  resolvePackDirs,
82
83
  type Persona,
83
84
  } from "../../shared/agents/personas.js";
@@ -167,6 +168,11 @@ import {
167
168
  type BinSource,
168
169
  } from "../../shared/agents/runtime-search.js";
169
170
  import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
171
+ import {
172
+ SERVER_PERSONA_ID,
173
+ isServerScope,
174
+ serverAgentDir,
175
+ } from "../../shared/agents/server-agent.js";
170
176
  import {
171
177
  bindActorSession,
172
178
  mintActorToken,
@@ -333,7 +339,7 @@ function defaultMcpServer(cli: string): {
333
339
 
334
340
  export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
335
341
  const base = resolveDeps(d);
336
- return {
342
+ return withServerScope({
337
343
  ...base,
338
344
  agentsConfig: d.agentsConfig ?? (() => readAgentsConfig()),
339
345
  distRoot: d.distRoot ?? getDistRoot,
@@ -365,6 +371,26 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
365
371
  ? setWorkspaceRuntimeBin(ws, id, bin)
366
372
  : setUserRuntimeBin(id, bin)),
367
373
  userConfigFile: d.userConfigFile ?? userConfigPath,
374
+ });
375
+ }
376
+
377
+ /**
378
+ * ON-6: the server agent's scope is no workspace, but its agent is a
379
+ * normal one: it "exists", and its folder is an empty one under the state
380
+ * dir (serverAgentDir), with no workspace runtime paths.
381
+ */
382
+ function withServerScope(r: AgentsResolved): AgentsResolved {
383
+ return {
384
+ ...r,
385
+ workspaceExists: (ws) => isServerScope(ws) || r.workspaceExists(ws),
386
+ workspaceDir: (ws) =>
387
+ isServerScope(ws) ? serverAgentDir(r.stateDir()) : r.workspaceDir(ws),
388
+ workspaceFolders: (ws) =>
389
+ isServerScope(ws)
390
+ ? { under: [serverAgentDir(r.stateDir())] }
391
+ : r.workspaceFolders(ws),
392
+ workspaceRuntimeBin: (ws, id) =>
393
+ isServerScope(ws) ? undefined : r.workspaceRuntimeBin(ws, id),
368
394
  };
369
395
  }
370
396
 
@@ -685,14 +711,18 @@ function agentsConfig(d: AgentsResolved): ResolvedAgentsConfig {
685
711
  function personas(
686
712
  d: AgentsResolved,
687
713
  cfg: ResolvedAgentsConfig,
714
+ /** ON-6: the server scope adds Den's own server persona (only there). */
715
+ ws?: string,
688
716
  ): Map<string, Persona> {
689
717
  try {
690
- return loadPersonas(
718
+ const all = loadPersonas(
691
719
  resolvePackDirs(cfg.personaPacks, d.distRoot()),
692
720
  undefined,
693
721
  { defaultPack: cfg.defaultPackGuardrails, packs: cfg.packGuardrails },
694
722
  localPersonasDir(d.stateDir()),
695
723
  );
724
+ if (isServerScope(ws)) all.set(SERVER_PERSONA_ID, serverPersona());
725
+ return all;
696
726
  } catch (e) {
697
727
  throw refused((e as Error).message, [], 422);
698
728
  }
@@ -1337,6 +1367,9 @@ function launchInput(
1337
1367
  rotateAfter: after,
1338
1368
  ...(perms.permission ? { permission: perms.permission } : {}),
1339
1369
  ...(perms.allow?.length ? { allow: [...perms.allow] } : {}),
1370
+ // ON-6: the server agent runs Den's own tools without the runtime's
1371
+ // prompt; its writes wait for the person's Allow in the browser.
1372
+ ...(isServerScope(ws) ? { denToolsAllowed: true } : {}),
1340
1373
  };
1341
1374
  }
1342
1375
 
@@ -1573,7 +1606,7 @@ export async function startAgent(
1573
1606
  if (!d.workspaceExists(ws))
1574
1607
  throw usage(`${where}: workspace "${ws}" does not exist`, 404);
1575
1608
  const cfg = agentsConfig(d);
1576
- const all = personas(d, cfg);
1609
+ const all = personas(d, cfg, ws);
1577
1610
  const persona = all.get(req.persona);
1578
1611
  if (!persona)
1579
1612
  throw usage(
@@ -2108,7 +2141,7 @@ export async function resumeAgent(
2108
2141
  );
2109
2142
  const cfg = agentsConfig(d);
2110
2143
  const grants = resumeGrants(rec, cfg);
2111
- const persona = personas(d, cfg).get(rec.persona);
2144
+ const persona = personas(d, cfg, rec.workspace).get(rec.persona);
2112
2145
  if (!persona)
2113
2146
  throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
2114
2147
  const det = await availableOrRefuse(d, rt, rec.workspace);
@@ -2312,7 +2345,7 @@ export async function changePersona(
2312
2345
  const where = "agents persona";
2313
2346
  const rec = agentRecord(d, id, ws);
2314
2347
  const cfg = agentsConfig(d);
2315
- const all = personas(d, cfg);
2348
+ const all = personas(d, cfg, rec.workspace);
2316
2349
  if (!all.get(req.persona))
2317
2350
  throw usage(
2318
2351
  `${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
@@ -2747,7 +2780,9 @@ export async function markInterrupted(
2747
2780
  return found;
2748
2781
  }
2749
2782
  for (const rec of marked) {
2750
- const p = all.get(rec.persona);
2783
+ const p = isServerScope(rec.workspace)
2784
+ ? serverPersona()
2785
+ : all.get(rec.persona);
2751
2786
  if (!p || !(p.standing || p.resumeOnRestart)) continue;
2752
2787
  if (resumeRefusal(rec)) continue;
2753
2788
  try {
@@ -545,7 +545,7 @@ export async function createResult(
545
545
  return {
546
546
  ok: false,
547
547
  error,
548
- fix: `npx ${ENGINE_PACKAGE} create ${args.name} ${path.join(path.dirname(args.dir), `${args.name}-den`)} --yes --json`,
548
+ fix: `npx ${ENGINE_PACKAGE}@latest create ${args.name} ${path.join(path.dirname(args.dir), `${args.name}-den`)} --yes --json`,
549
549
  ask: `${args.dir} already has files that are not a ${args.name} distribution. Which new or empty folder should the distribution go in?`,
550
550
  };
551
551
  return {
@@ -576,7 +576,7 @@ export async function runCreate(
576
576
  JSON.stringify({
577
577
  ok: false,
578
578
  error: (e as Error).message.split("\n")[0],
579
- fix: `npx ${ENGINE_PACKAGE} create <name> [dir] --yes --json`,
579
+ fix: `npx ${ENGINE_PACKAGE}@latest create <name> [dir] --yes --json`,
580
580
  } satisfies CreateJson),
581
581
  );
582
582
  } else console.error(`create: ${(e as Error).message}`);
@@ -446,7 +446,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
446
446
  "create <name> [dir] [--title <title>] [--cli <cli>] [--env-prefix <PREFIX>]",
447
447
  lines: [
448
448
  "Scaffold a new distribution (den.yml, config, bin shim, Makefile) in [dir].",
449
- "Also: npx @monster-orange/den create <name>. See docs/distributions.md.",
449
+ "Also: npx @monster-orange/den@latest create <name>. See docs/distributions.md.",
450
450
  ],
451
451
  },
452
452
  validate: {
@@ -60,6 +60,7 @@ import { ensureManagedServiceClone } from "../../shared/environment-manager/work
60
60
  import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
61
61
  import {
62
62
  detectRunCommands,
63
+ draftHealthUrl,
63
64
  repoNameFrom,
64
65
  sidecarManifest,
65
66
  type DetectedCommands,
@@ -358,7 +359,13 @@ export function repoAdd(
358
359
  error: `cannot name a repo from the folder "${key}"`,
359
360
  fix: `${bin} repo add ${a.spec} --name <repo> --json`,
360
361
  };
361
- const manifest = sidecarManifest(mName, d.commands, a.health);
362
+ // No --health: the dev server's own port, when its script names one,
363
+ // so the drafted service has a health check and a Visit link.
364
+ const manifest = sidecarManifest(
365
+ mName,
366
+ d.commands,
367
+ a.health ?? draftHealthUrl(repoPath, d.commands),
368
+ );
362
369
  const v = validate(manifest);
363
370
  const dry = a.dryRun
364
371
  ? {