@monster-orange/den 0.4.17 → 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 (49) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +14 -13
  3. package/docs/distributions.md +138 -25
  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 +84 -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 +10 -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 +33 -1
  28. package/src/ui/server/public/agents.html +13 -0
  29. package/src/ui/server/public/error-page.js +39 -1
  30. package/src/ui/server/public/graph.css +43 -0
  31. package/src/ui/server/public/graph.js +68 -1
  32. package/src/ui/server/public/header.js +36 -7
  33. package/src/ui/server/public/i18n/es.js +72 -0
  34. package/src/ui/server/public/i18n/ja.js +74 -0
  35. package/src/ui/server/public/local-dev.html +33 -3
  36. package/src/ui/server/public/log-source.js +26 -0
  37. package/src/ui/server/public/logs.html +7 -20
  38. package/src/ui/server/public/persona-ui.css +6 -0
  39. package/src/ui/server/public/server-agent-page.js +415 -0
  40. package/src/ui/server/public/server-agent.html +222 -0
  41. package/src/ui/server/public/services.html +18 -3
  42. package/src/ui/server/public/shell.js +274 -31
  43. package/src/ui/server/public/svc-panel.js +685 -0
  44. package/src/ui/server/public/topbar.js +13 -1
  45. package/src/ui/server/route-classes.ts +21 -0
  46. package/src/ui/server/server-agent-routes.ts +176 -0
  47. package/src/ui/server/session-routes.ts +12 -1
  48. package/src/ui/server/ui-assets.ts +6 -0
  49. package/src/ui/server/workspace-routes.ts +3 -0
@@ -1,25 +1,28 @@
1
1
  ---
2
2
  name: install-den
3
- description: Install and set up Den, Monster Orange's local dev orchestrator, for a developer in five minutes. Use when the person says "set up Den", "install Den", "get me started with Den", or pastes a link to this file.
3
+ description: Install Den, Monster Orange's local dev orchestrator, and hand the person over to Den's own agent, in five minutes. Use when the person says "set up Den", "install Den", "get me started with Den", or pastes a link to this file.
4
4
  ---
5
5
 
6
6
  # Install Den
7
7
 
8
- You are setting up Den on this person's machine. Den runs their repos locally, shows them in a browser, and gives you
9
- (the agent) an MCP server to work with. Aim to be done in five minutes. Keep your messages short and plain.
8
+ You are installing Den on this person's machine. Den runs their repos locally and shows them in a browser. Your job
9
+ is short: install it, name it, start it, and hand over the link. **Den's own agent takes it from there.** It adds
10
+ their repos and starts their services, with the person allowing each change in the browser.
11
+
12
+ You do three things only. You don't add repos or create workspaces: those are Den's own agent's job. Keep your
13
+ messages short and plain.
10
14
 
11
15
  ## Hard rules
12
16
 
13
17
  - **Never ask for a token, password or secret in chat.** If a step needs one, tell the person which command to run
14
18
  themselves.
15
- - **Never edit the person's repos.** Den's files go in the Den folder only. Read the repo; don't write to it.
16
- - **Send nothing off this machine.** No uploads, no telemetry, no pasting their code anywhere. The only downloads are
17
- Den from npm and a repo the person gave you as a git URL.
18
- - **Sudo is the person's job.** Never run `sudo` yourself. When a Den step needs it, Den asks for the password in its
19
- own prompt (in the terminal or the browser), and the person types it there.
19
+ - **Never edit the person's repos.** Don't touch them at all.
20
+ - **Send nothing off this machine.** No uploads, no telemetry. The only download is Den from npm.
21
+ - **Sudo is the person's job.** Never run `sudo` yourself.
22
+ - **Never open the setup link yourself**, and never fetch it. It signs in one browser, once. Give it to the person.
20
23
  - Every step below is safe to re-run. If you are unsure whether a step ran, run it again.
21
24
 
22
- ## 1. Check the prerequisites
25
+ ## 1. Install Den, after checking Node and git
23
26
 
24
27
  Run these and read the results. Don't install anything without saying so first.
25
28
 
@@ -27,88 +30,45 @@ Run these and read the results. Don't install anything without saying so first.
27
30
  (for example `brew install node`, or `nvm install 22`).
28
31
  2. `npm --version`: needs 9 or later. It comes with Node.
29
32
  3. `git --version`: needs to be there.
30
- 4. `docker info`: only matters if the repo runs in containers (it has a `docker-compose.yml` or `compose.yaml`).
31
- If Docker is needed and not running, ask them to start it.
32
33
 
33
34
  If anything is missing, stop here, tell them what and how to fix it, and wait.
34
35
 
35
- ## 2. Ask at most three questions
36
-
37
- Ask them together, each with a default, so the person can just say "yes":
36
+ ## 2. Pick the folder and the name
38
37
 
39
- 1. **Where should your Den live?** Default: `~/den`. It must be a new or empty folder.
40
- 2. **Which repo should we start with?** Default: the folder you're in now. A local path or a git URL.
41
- 3. **What should we call your Den?** Default: `den`. Lowercase letters, numbers and dashes. This also becomes the
42
- command name, so `acme` gives you `./bin/acme`.
38
+ Ask both together, each with a default, so the person can just say "yes":
43
39
 
44
- Below, `<dir>` is the Den folder, `<repo>` the repo path, and `<name>` the name.
40
+ 1. **What should we call your Den?** Default: `den`. Lowercase letters, numbers and dashes. This also becomes its
41
+ command, so `acme` gives you `./bin/acme`.
42
+ 2. **Where should it live?** Default: `~/<name>`. It must be a new or empty folder.
45
43
 
46
- ## 3. Create the Den
44
+ Below, `<name>` is the name and `<dir>` the folder.
47
45
 
48
46
  Every Den command below takes `--json`. It then prints one JSON object on stdout and its log on stderr. Read `ok`
49
47
  first. When `ok` is false, the object says why (`error`) and either a command to run (`fix`) or a question for the
50
- person (`ask`, sometimes with `choices`).
48
+ person (`ask`).
51
49
 
52
- 1. `npx @monster-orange/den create <name> <dir> --yes --json`. It answers `{ok, dir, cli, existed, port, next}`. If
53
- the folder already holds this Den, it answers `ok` with `existed: true` and changes nothing. `port` is the browser
54
- UI's port. When another Den already uses the usual one on this machine, Den picks the next free port and says so
55
- (`portPicked`). Remember the port for the hand-back.
50
+ 1. `npx @monster-orange/den@latest create <name> <dir> --yes --json`. It answers `{ok, dir, cli, existed, port}`. If the
51
+ folder already holds this Den, it answers `ok` with `existed: true` and changes nothing. When another Den already
52
+ uses the usual port, it picks the next free one and says so (`portPicked`).
56
53
  2. `cd <dir> && npm install`
57
54
  3. From here on, the Den command is `./bin/<name>`, run from `<dir>`.
58
55
 
59
- ## 4. Add the repo without changing it
60
-
61
- 1. `./bin/<name> repo add <repo> --json`. `<repo>` can be a path or a git URL. Den clones a URL into its own folder.
62
- - If the repo has its own `.<name>.yml`, Den uses it.
63
- - If it doesn't, Den works out how the repo starts and writes a side-car manifest into `<dir>/manifests/`. It never
64
- writes into the repo. It looks for a Makefile `up` target, then a compose file, then the `dev` or `start` script
65
- in `package.json`. `--sidecar` asks for this explicitly, and fails if the repo has its own manifest.
66
- - If you can see which port the app listens on, add `--health http://localhost:<port>/`. Den then checks that it
67
- answers.
68
- 2. If the answer has `ask`, Den couldn't tell how the repo starts, and it wrote nothing. Ask the person **one**
69
- question: the `ask` text, with any `choices` numbered (there may be none). Then re-run with their answer:
70
- `./bin/<name> repo add <repo> --run "<their command>" --json`.
71
- 3. The answer names the repo (`repo`), the workspace it joined (`workspace`) and where its manifest is (`manifest`).
72
- A workspace is Den's name for a set of repos that run together. The first one is called `default`.
73
-
74
- ## 5. Start it and check it
75
-
76
- 1. `./bin/<name> up <repo> --json`. `<repo>` is the `repo` value from step 4. Each service comes back with a
77
- `result` (`started` or `already-running`) and a `health` (`ok`, or `unknown` when it has no health check).
78
- 2. `./bin/<name> web up --no-open --json`. This starts the browser UI, and answers `{ok, url, port}`. The MCP server
79
- reads through it.
80
- 3. `./bin/<name> doctor --json`. Each check comes back as `ok`, `warn` or `fail`. The checks are Node, git, Docker
81
- (only if a repo needs it), the web server (with its `url` and `port`) and Den's MCP. Fix every `fail` before you hand back.
82
-
83
- ## 6. Connect yourself to Den's MCP
84
-
85
- 1. `./bin/<name> mcp connect --runtime <you> --json`. `<you>` is `claude`, `codex`, `gemini` or `grok`. For any other
86
- agent, `./bin/<name> mcp config` prints the JSON to add by hand.
87
- 2. `./bin/<name> mcp clients` shows whether you are connected.
88
- 3. If the answer has `restart: true`, you load the new MCP server only when you start again. Tell the person to
89
- restart you once.
90
-
91
- ## 7. Hand back
92
-
93
- Tell the person, in a few short sentences. Explain every name you use the first time (for example, "a workspace is a
94
- set of repos that run together").
95
-
96
- - **Open:** the `url` from `web up` or from the doctor's `web` check, for example `http://localhost:<port>`. Never
97
- assume a port: use the one Den gave you.
98
- - **What's running:** each service from `up`, and whether it's healthy.
99
- - **Your Den:** where it lives (`<dir>`) and its command (`./bin/<name>`).
100
- - **Decisions, if any:** as numbered yes/no questions, for example "1. Restart me now so I can use Den's tools?"
101
- - **Three next things we can do together:**
102
- 1. Add another repo.
103
- 2. Look at a service's logs, or run its tests.
104
- 3. Start an agent session on this repo from the browser.
56
+ ## 3. Start the web server and hand over the link
57
+
58
+ 1. `./bin/<name> web up --no-open --json`. It answers `{ok, url, port}`.
59
+ 2. `./bin/<name> web claim-link --json`. It answers `{url, expiresAt}`: a one-time setup link that signs the
60
+ person's browser in, so they can allow Den's agent's changes. It lasts 30 minutes and works once.
61
+ 3. Hand over, in a few short sentences:
62
+ - **Open** the `url` from `claim-link`, in your browser on this machine. "Den's own agent takes it from there."
63
+ - **Your Den** lives in `<dir>`, and its command is `./bin/<name>`.
64
+ - If the link has expired, run `./bin/<name> web claim-link` yourself for a new one.
65
+
66
+ That's all. Don't go on to add repos.
105
67
 
106
68
  ## When something fails
107
69
 
108
70
  1. Read the JSON. If it has `fix`, run that command, then re-run the step that failed.
109
- 2. If it has `ask`, the person has to decide or act (sudo, a password, starting Docker, signing in). Ask them the
110
- question, show any `choices` numbered, and wait.
71
+ 2. If it has `ask`, the person has to decide or act. Ask them the question, show any `choices` numbered, and wait.
111
72
  3. If there is neither, ask the person **one** clear question, with your best guess as the default.
112
- 4. Never work around a failure by editing their repo, turning off a check, or skipping a step without saying so.
113
- 5. If you're stuck after two tries, stop. Tell them what you ran, what it said, and where Den writes its logs
73
+ 4. If you're stuck after two tries, stop. Tell them what you ran, what it said, and where Den writes its logs
114
74
  (`~/.<name>/`), so they can get help.
@@ -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);