@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.
- package/CHANGELOG.md +134 -0
- package/README.md +14 -13
- package/docs/distributions.md +99 -24
- package/docs/reference.md +1 -1
- package/package.json +2 -1
- package/skills/install-den/SKILL.md +35 -75
- package/src/shared/agents/den-claude.ts +40 -6
- package/src/shared/agents/guardrails/stop-hook.ts +30 -4
- package/src/shared/agents/guardrails.ts +40 -4
- package/src/shared/agents/handback.ts +22 -4
- package/src/shared/agents/personas.ts +32 -5
- package/src/shared/agents/runtime.ts +8 -0
- package/src/shared/agents/server-agent.ts +57 -0
- package/src/shared/agents/server-pack/server.md +45 -0
- package/src/shared/environment-manager/workspaces.ts +4 -0
- package/src/ui/cli/agent-ops.ts +41 -6
- package/src/ui/cli/create.ts +2 -2
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/repo-command.ts +8 -1
- package/src/ui/cli/repo-detect.ts +58 -0
- package/src/ui/cli/server-agent-ops.ts +411 -0
- package/src/ui/mcp/server.ts +29 -12
- package/src/ui/mcp/tools.ts +4 -2
- package/src/ui/server/agent-routes.ts +1 -1
- package/src/ui/server/held-actions.ts +35 -7
- package/src/ui/server/index.ts +20 -5
- package/src/ui/server/public/agents-page.js +10 -1
- package/src/ui/server/public/error-page.js +39 -1
- package/src/ui/server/public/graph.css +21 -0
- package/src/ui/server/public/graph.js +49 -6
- package/src/ui/server/public/header.js +29 -7
- package/src/ui/server/public/i18n/es.js +37 -0
- package/src/ui/server/public/i18n/ja.js +39 -0
- package/src/ui/server/public/local-dev.html +22 -1
- package/src/ui/server/public/persona-ui.css +6 -0
- package/src/ui/server/public/server-agent-page.js +415 -0
- package/src/ui/server/public/server-agent.html +222 -0
- package/src/ui/server/public/shell.js +66 -6
- package/src/ui/server/public/svc-panel.js +27 -14
- package/src/ui/server/public/topbar.js +13 -1
- package/src/ui/server/route-classes.ts +21 -0
- package/src/ui/server/server-agent-routes.ts +176 -0
- package/src/ui/server/session-routes.ts +12 -1
- package/src/ui/server/ui-assets.ts +2 -0
- 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(
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
54
|
-
export
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
373
|
-
|
|
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);
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 {
|
package/src/ui/cli/create.ts
CHANGED
|
@@ -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}`);
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
? {
|