@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
|
@@ -157,6 +157,64 @@ export function needsDocker(manifest: unknown, repoPath?: string): boolean {
|
|
|
157
157
|
return repoPath ? composeFileOf(repoPath) !== undefined : false;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Dev servers' own default ports, by the command a package script runs.
|
|
162
|
+
* Only tools whose default is fixed and documented; anything else gets no
|
|
163
|
+
* port, and so no health URL.
|
|
164
|
+
*/
|
|
165
|
+
const DEV_SERVER_PORTS: ReadonlyArray<[RegExp, number]> = [
|
|
166
|
+
[/^next(\s+dev)?\b/, 3000],
|
|
167
|
+
[/^(vite|vite\s+dev|vite\s+serve)\b/, 5173],
|
|
168
|
+
[/^(nuxt|nuxi)\s+dev\b/, 3000],
|
|
169
|
+
[/^react-scripts\s+start\b/, 3000],
|
|
170
|
+
[/^ng\s+serve\b/, 4200],
|
|
171
|
+
[/^gatsby\s+develop\b/, 8000],
|
|
172
|
+
[/^remix\s+vite:dev\b/, 5173],
|
|
173
|
+
[/^webpack(-dev-server|\s+serve)\b/, 8080],
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
/** A port the command names itself: --port N, -p N, PORT=N. */
|
|
177
|
+
function explicitPort(cmd: string): number | undefined {
|
|
178
|
+
const m =
|
|
179
|
+
/(?:^|\s)--port[=\s]+(\d{2,5})\b/.exec(cmd) ??
|
|
180
|
+
/(?:^|\s)-p\s+(\d{2,5})\b/.exec(cmd) ??
|
|
181
|
+
/(?:^|\s)PORT=(\d{2,5})\b/.exec(cmd);
|
|
182
|
+
const n = m ? Number(m[1]) : NaN;
|
|
183
|
+
return n > 0 && n < 65536 ? n : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The port a repo's dev server listens on, when its start command is a
|
|
188
|
+
* package script that names one or runs a dev server with a known default.
|
|
189
|
+
* Read-only: the script's text, never the running server.
|
|
190
|
+
*/
|
|
191
|
+
export function detectDevPort(
|
|
192
|
+
repoPath: string,
|
|
193
|
+
c: Pick<DetectedCommands, "up">,
|
|
194
|
+
): number | undefined {
|
|
195
|
+
const scripts = readScripts(repoPath);
|
|
196
|
+
const m = /^npm\s+(?:run\s+(\S+)|(start))$/.exec(c.up.trim());
|
|
197
|
+
const script = m ? scripts[m[1] ?? "start"] : c.up;
|
|
198
|
+
if (!script) return undefined;
|
|
199
|
+
const own = explicitPort(script);
|
|
200
|
+
if (own) return own;
|
|
201
|
+
// The tool the script runs, after any VAR=value prefixes and npx.
|
|
202
|
+
const tool = script
|
|
203
|
+
.trim()
|
|
204
|
+
.replace(/^(?:[A-Z_][A-Z0-9_]*=\S*\s+)+/, "")
|
|
205
|
+
.replace(/^npx\s+(?:--yes\s+)?/, "");
|
|
206
|
+
return DEV_SERVER_PORTS.find(([re]) => re.test(tool))?.[1];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The health URL to draft: the dev server's own port, at `/`. */
|
|
210
|
+
export function draftHealthUrl(
|
|
211
|
+
repoPath: string,
|
|
212
|
+
c: Pick<DetectedCommands, "up">,
|
|
213
|
+
): string | undefined {
|
|
214
|
+
const port = detectDevPort(repoPath, c);
|
|
215
|
+
return port ? `http://localhost:${port}/` : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
160
218
|
/** The side-car manifest for a repo Den detected. Validate before writing. */
|
|
161
219
|
export function sidecarManifest(
|
|
162
220
|
name: string,
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server agent's operations (ON-6), on top of the agent ones: what it
|
|
3
|
+
* is and whether it runs (status), and starting it (start). It is a normal
|
|
4
|
+
* Den agent in the reserved server scope (shared/agents/server-agent.ts),
|
|
5
|
+
* id `server`, persona `server`, named "<Product> agent".
|
|
6
|
+
*
|
|
7
|
+
* Its runtime: `agents.server_runtime` in user.yml when that one is
|
|
8
|
+
* installed, else the first installed catalog runtime that carries Den's
|
|
9
|
+
* tools. Den can tell a runtime is installed, not that it is logged in: a
|
|
10
|
+
* runtime that isn't asks the person to log in in its own terminal.
|
|
11
|
+
*
|
|
12
|
+
* Its model: `agents.server_model` when the runtime offers it, else Sonnet
|
|
13
|
+
* when the runtime offers Sonnet (DEFAULT_SERVER_MODEL), else the
|
|
14
|
+
* runtime's own default. Only the runtime's own models are offered.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
AgentError,
|
|
18
|
+
listAgents,
|
|
19
|
+
listRuntimes,
|
|
20
|
+
relaunchAgent,
|
|
21
|
+
resumeAgent,
|
|
22
|
+
resumeRefusal,
|
|
23
|
+
startAgent,
|
|
24
|
+
type AgentRow,
|
|
25
|
+
type AgentsResolved,
|
|
26
|
+
type RuntimeChoice,
|
|
27
|
+
type Started,
|
|
28
|
+
} from "./agent-ops.js";
|
|
29
|
+
import { readRecord } from "../../shared/agents/records.js";
|
|
30
|
+
import { rotationInfo } from "../../shared/agents/rotation.js";
|
|
31
|
+
import {
|
|
32
|
+
SERVER_AGENT_ID,
|
|
33
|
+
SERVER_PERSONA_ID,
|
|
34
|
+
SERVER_SCOPE,
|
|
35
|
+
serverAgentName,
|
|
36
|
+
} from "../../shared/agents/server-agent.js";
|
|
37
|
+
import { getRuntime } from "../../shared/agents/runtime.js";
|
|
38
|
+
import {
|
|
39
|
+
mergeUserConfig,
|
|
40
|
+
readUserConfig,
|
|
41
|
+
type UserConfig,
|
|
42
|
+
} from "../../shared/den-config.js";
|
|
43
|
+
import { product } from "../../shared/product.js";
|
|
44
|
+
import { getDistRoot } from "../../shared/paths.js";
|
|
45
|
+
import * as os from "os";
|
|
46
|
+
import * as path from "path";
|
|
47
|
+
|
|
48
|
+
export interface ServerAgentDeps {
|
|
49
|
+
/** user.yml's `agents.server_runtime` (the person's choice), if set. */
|
|
50
|
+
readSetting?: () => string | undefined;
|
|
51
|
+
/** Save (or, with null, clear) that choice. */
|
|
52
|
+
saveSetting?: (id: string | null) => void;
|
|
53
|
+
/** user.yml's `agents.server_model`, if set. */
|
|
54
|
+
readModel?: () => string | undefined;
|
|
55
|
+
/** Save (or, with null, clear) it. */
|
|
56
|
+
saveModel?: (id: string | null) => void;
|
|
57
|
+
/** A fresh Den: no repo in any workspace yet. */
|
|
58
|
+
fresh?: () => boolean | Promise<boolean>;
|
|
59
|
+
/** The product's name for people ("Acme"). */
|
|
60
|
+
title?: () => string;
|
|
61
|
+
/** This Den's folder (its distribution root). */
|
|
62
|
+
root?: () => string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Resolved {
|
|
66
|
+
readSetting: () => string | undefined;
|
|
67
|
+
saveSetting: (id: string | null) => void;
|
|
68
|
+
readModel: () => string | undefined;
|
|
69
|
+
saveModel: (id: string | null) => void;
|
|
70
|
+
fresh: () => boolean | Promise<boolean>;
|
|
71
|
+
title: () => string;
|
|
72
|
+
root: () => string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolve(s: ServerAgentDeps): Resolved {
|
|
76
|
+
return {
|
|
77
|
+
readSetting: s.readSetting ?? readServerRuntimeSetting,
|
|
78
|
+
saveSetting: s.saveSetting ?? saveServerRuntimeSetting,
|
|
79
|
+
readModel: s.readModel ?? (() => readAgentsKey("server_model")),
|
|
80
|
+
saveModel: s.saveModel ?? ((id) => saveAgentsKey("server_model", id)),
|
|
81
|
+
fresh: s.fresh ?? freshDen,
|
|
82
|
+
title: s.title ?? (() => product().title),
|
|
83
|
+
root: s.root ?? getDistRoot,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A string key of user.yml's `agents`, if set. */
|
|
88
|
+
function readAgentsKey(
|
|
89
|
+
key: "server_runtime" | "server_model",
|
|
90
|
+
): string | undefined {
|
|
91
|
+
const v = (readUserConfig().agents as Record<string, unknown> | undefined)?.[
|
|
92
|
+
key
|
|
93
|
+
];
|
|
94
|
+
return typeof v === "string" && v ? v : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function saveAgentsKey(
|
|
98
|
+
key: "server_runtime" | "server_model",
|
|
99
|
+
id: string | null,
|
|
100
|
+
): void {
|
|
101
|
+
const agents = { ...(readUserConfig().agents ?? {}) } as Record<
|
|
102
|
+
string,
|
|
103
|
+
unknown
|
|
104
|
+
>;
|
|
105
|
+
if (id === null) delete agents[key];
|
|
106
|
+
else agents[key] = id;
|
|
107
|
+
mergeUserConfig({
|
|
108
|
+
agents: Object.keys(agents).length
|
|
109
|
+
? (agents as UserConfig["agents"])
|
|
110
|
+
: undefined,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** user.yml's `agents.server_runtime`. */
|
|
115
|
+
export function readServerRuntimeSetting(): string | undefined {
|
|
116
|
+
return readAgentsKey("server_runtime");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function saveServerRuntimeSetting(id: string | null): void {
|
|
120
|
+
saveAgentsKey("server_runtime", id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The server agent's model when the person picked none and its runtime offers it. */
|
|
124
|
+
export const DEFAULT_SERVER_MODEL = "sonnet";
|
|
125
|
+
|
|
126
|
+
/** The model to run on: the saved one when the runtime offers it, else Sonnet if offered, else none. */
|
|
127
|
+
export function pickModel(
|
|
128
|
+
models: readonly { id: string }[],
|
|
129
|
+
saved: string | undefined,
|
|
130
|
+
): string | undefined {
|
|
131
|
+
if (saved && models.some((m) => m.id === saved)) return saved;
|
|
132
|
+
return models.some((m) => m.id === DEFAULT_SERVER_MODEL)
|
|
133
|
+
? DEFAULT_SERVER_MODEL
|
|
134
|
+
: undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** No repo in any workspace yet (workspaces.yml), or no workspaces file. */
|
|
138
|
+
export async function freshDen(): Promise<boolean> {
|
|
139
|
+
// Imported when asked, like loadAgentWorkspaces: the CLI starts light.
|
|
140
|
+
const ws = await import("../../shared/environment-manager/workspaces.js");
|
|
141
|
+
let file: ReturnType<typeof ws.readWorkspacesFile>;
|
|
142
|
+
try {
|
|
143
|
+
file = ws.readWorkspacesFile();
|
|
144
|
+
} catch {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return Object.values(file.workspaces ?? {}).every(
|
|
148
|
+
(w) => Object.keys(w?.services ?? {}).length === 0,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A runtime the server agent can run on: installed, with Den's tools. */
|
|
153
|
+
function usable(r: RuntimeChoice): boolean {
|
|
154
|
+
return r.available && r.denTools === true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** How to install a catalog runtime, when its catalog says (npm). */
|
|
158
|
+
function installHint(id: string): string | undefined {
|
|
159
|
+
const pkg = getRuntime(id)?.catalog?.version?.identity?.package;
|
|
160
|
+
return pkg ? `npm i -g ${pkg}` : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface ServerAgentStatus {
|
|
164
|
+
/** "<Product> agent". */
|
|
165
|
+
name: string;
|
|
166
|
+
id: string;
|
|
167
|
+
/** The reserved scope it lives in (never a workspace). */
|
|
168
|
+
scope: string;
|
|
169
|
+
persona: string;
|
|
170
|
+
/** No repo in any workspace yet: the first page is this agent. */
|
|
171
|
+
fresh: boolean;
|
|
172
|
+
/** The person's choice (user.yml agents.server_runtime), if any. */
|
|
173
|
+
setting?: string;
|
|
174
|
+
/** The runtime it runs (or would run) on, or null when none can. */
|
|
175
|
+
runtime: { id: string; label?: string } | null;
|
|
176
|
+
/** The runtime it is running on now (its record's), when it runs. */
|
|
177
|
+
runningRuntime?: string;
|
|
178
|
+
/** The model it runs (or would run) on, when its runtime names models. */
|
|
179
|
+
model?: string;
|
|
180
|
+
/** The runtime's own models, for the picker. */
|
|
181
|
+
models: { id: string; label?: string }[];
|
|
182
|
+
/** Every catalog runtime, and whether the server agent can use it. */
|
|
183
|
+
runtimes: {
|
|
184
|
+
id: string;
|
|
185
|
+
label?: string;
|
|
186
|
+
available: boolean;
|
|
187
|
+
denTools: boolean;
|
|
188
|
+
usable: boolean;
|
|
189
|
+
install?: string;
|
|
190
|
+
}[];
|
|
191
|
+
/** none (never started), running, stopping, stopped, exited, interrupted. */
|
|
192
|
+
state: string;
|
|
193
|
+
/** Its session, when it has one to open. */
|
|
194
|
+
sessionId?: string;
|
|
195
|
+
/** The way without any agent (Humans can do everything agents can). */
|
|
196
|
+
manual: string[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The manual path for this Den, which already exists: from its folder,
|
|
201
|
+
* add a repo and start it, with the Den's own command (`./bin/<cli>`, as
|
|
202
|
+
* the install skill runs it). `dir` is the Den's folder, shown `~/…`
|
|
203
|
+
* inside `home`.
|
|
204
|
+
*/
|
|
205
|
+
/** A folder as the person reads it: `~/…` inside `home`. */
|
|
206
|
+
function shownDir(dir: string, home?: string): string {
|
|
207
|
+
const rel = home ? path.relative(home, dir) : "";
|
|
208
|
+
if (home && rel === "") return "~";
|
|
209
|
+
return home && rel && !rel.startsWith("..") && !path.isAbsolute(rel)
|
|
210
|
+
? `~/${rel}`
|
|
211
|
+
: dir;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function manualPath(cli: string, dir: string, home?: string): string[] {
|
|
215
|
+
const shown = shownDir(dir, home);
|
|
216
|
+
const cd = /^[A-Za-z0-9_./~+-]+$/.test(shown)
|
|
217
|
+
? shown
|
|
218
|
+
: `'${shown.replace(/'/g, `'\\''`)}'`;
|
|
219
|
+
return [`cd ${cd}`, `./bin/${cli} repo add <path>`, `./bin/${cli} up`];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The runtime to run on: the person's choice when usable, else the first. */
|
|
223
|
+
export function pickRuntime(
|
|
224
|
+
rows: RuntimeChoice[],
|
|
225
|
+
setting: string | undefined,
|
|
226
|
+
): RuntimeChoice | null {
|
|
227
|
+
const chosen = setting ? rows.find((r) => r.id === setting) : undefined;
|
|
228
|
+
if (chosen && usable(chosen)) return chosen;
|
|
229
|
+
return rows.find(usable) ?? null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function serverAgentStatus(
|
|
233
|
+
d: AgentsResolved,
|
|
234
|
+
s: ServerAgentDeps = {},
|
|
235
|
+
): Promise<ServerAgentStatus> {
|
|
236
|
+
const r = resolve(s);
|
|
237
|
+
const rows = (await listRuntimes(d)).filter((x) => x.denTools !== undefined);
|
|
238
|
+
const setting = r.readSetting();
|
|
239
|
+
const rt = pickRuntime(rows, setting);
|
|
240
|
+
const models = (rt?.models ?? []).map((m) => ({
|
|
241
|
+
id: m.id,
|
|
242
|
+
...(m.label ? { label: m.label } : {}),
|
|
243
|
+
}));
|
|
244
|
+
const model = pickModel(models, r.readModel());
|
|
245
|
+
let row: AgentRow | undefined;
|
|
246
|
+
try {
|
|
247
|
+
row = (await listAgents(d, SERVER_SCOPE, { startHost: false })).find(
|
|
248
|
+
(x) => x.agent === SERVER_AGENT_ID,
|
|
249
|
+
);
|
|
250
|
+
} catch {
|
|
251
|
+
row = undefined;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
name: serverAgentName(r.title()),
|
|
255
|
+
id: SERVER_AGENT_ID,
|
|
256
|
+
scope: SERVER_SCOPE,
|
|
257
|
+
persona: SERVER_PERSONA_ID,
|
|
258
|
+
fresh: await r.fresh(),
|
|
259
|
+
...(setting ? { setting } : {}),
|
|
260
|
+
runtime: rt
|
|
261
|
+
? { id: rt.id, ...(rt.label ? { label: rt.label } : {}) }
|
|
262
|
+
: null,
|
|
263
|
+
...(row &&
|
|
264
|
+
(row.state === "running" || row.state === "stopping") &&
|
|
265
|
+
row.runtime
|
|
266
|
+
? { runningRuntime: row.runtime }
|
|
267
|
+
: {}),
|
|
268
|
+
...(model ? { model } : {}),
|
|
269
|
+
models,
|
|
270
|
+
runtimes: rows.map((x) => {
|
|
271
|
+
const install = x.available ? undefined : installHint(x.id);
|
|
272
|
+
return {
|
|
273
|
+
id: x.id,
|
|
274
|
+
...(x.label ? { label: x.label } : {}),
|
|
275
|
+
available: x.available,
|
|
276
|
+
denTools: x.denTools === true,
|
|
277
|
+
usable: usable(x),
|
|
278
|
+
...(install ? { install } : {}),
|
|
279
|
+
};
|
|
280
|
+
}),
|
|
281
|
+
state: row?.state ?? "none",
|
|
282
|
+
...(row?.session ? { sessionId: row.session.ref.id } : {}),
|
|
283
|
+
manual: manualPath(d.cli, r.root(), d.env.HOME ?? os.homedir()),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The first thing the server agent hears on a fresh Den: it greets and
|
|
289
|
+
* asks where the repos are in its first turn, with the folder above the
|
|
290
|
+
* Den's as the default (`reposDefault`), so the person can just say yes.
|
|
291
|
+
*/
|
|
292
|
+
export function onboardingPrompt(title: string, reposDefault?: string): string {
|
|
293
|
+
const ask = reposDefault
|
|
294
|
+
? `"Let's set up ${title}. Where are your repos? I'll look in ${reposDefault} unless you name another folder."`
|
|
295
|
+
: `"Let's set up ${title}. Where are your repos?"`;
|
|
296
|
+
return (
|
|
297
|
+
`Set up ${title} with me. Start with onboard_status, then say ` +
|
|
298
|
+
`${ask} and wait for my answer.`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface ServerAgentStarted {
|
|
303
|
+
/** started, resumed, relaunched, or running (it already was). */
|
|
304
|
+
how: "started" | "resumed" | "relaunched" | "running";
|
|
305
|
+
sessionId: string;
|
|
306
|
+
runtime: string;
|
|
307
|
+
warnings: string[];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Start the server agent, or bring it back: running, it is left as it is;
|
|
312
|
+
* stopped with a conversation to continue, it resumes (AG-14); rotated, it
|
|
313
|
+
* relaunches from its handoff; else it starts fresh, and on a fresh Den
|
|
314
|
+
* with the onboarding prompt.
|
|
315
|
+
*/
|
|
316
|
+
export async function startServerAgent(
|
|
317
|
+
d: AgentsResolved,
|
|
318
|
+
req: { runtime?: string } = {},
|
|
319
|
+
s: ServerAgentDeps = {},
|
|
320
|
+
): Promise<ServerAgentStarted> {
|
|
321
|
+
const r = resolve(s);
|
|
322
|
+
const status = await serverAgentStatus(d, s);
|
|
323
|
+
if (
|
|
324
|
+
(status.state === "running" || status.state === "stopping") &&
|
|
325
|
+
status.sessionId
|
|
326
|
+
)
|
|
327
|
+
return {
|
|
328
|
+
how: "running",
|
|
329
|
+
sessionId: status.sessionId,
|
|
330
|
+
runtime: readRecord(d.stateDir(), SERVER_AGENT_ID)?.runtime ?? "",
|
|
331
|
+
warnings: [],
|
|
332
|
+
};
|
|
333
|
+
const rec = readRecord(d.stateDir(), SERVER_AGENT_ID);
|
|
334
|
+
const runtime = req.runtime ?? status.runtime?.id;
|
|
335
|
+
const model =
|
|
336
|
+
runtime === status.runtime?.id
|
|
337
|
+
? status.model
|
|
338
|
+
: pickModel(getRuntime(runtime ?? "")?.models?.() ?? [], r.readModel());
|
|
339
|
+
if (!runtime)
|
|
340
|
+
throw new AgentError(
|
|
341
|
+
"no runtime can run the server agent: install one from the runtime catalog",
|
|
342
|
+
{ status: 503, code: "no_runtime", hints: status.manual },
|
|
343
|
+
);
|
|
344
|
+
const done = (how: ServerAgentStarted["how"], st: Started) => ({
|
|
345
|
+
how,
|
|
346
|
+
sessionId: st.info.ref.id,
|
|
347
|
+
runtime: st.rec.runtime,
|
|
348
|
+
warnings: st.warnings,
|
|
349
|
+
});
|
|
350
|
+
// The same runtime and model: continue its conversation. Another model
|
|
351
|
+
// picked since: a fresh start on it.
|
|
352
|
+
if (
|
|
353
|
+
rec &&
|
|
354
|
+
rec.workspace === SERVER_SCOPE &&
|
|
355
|
+
rec.runtime === runtime &&
|
|
356
|
+
rec.model === model
|
|
357
|
+
) {
|
|
358
|
+
if (rotationInfo(d.stateDir(), rec)?.ready)
|
|
359
|
+
return done("relaunched", await relaunchAgent(d, rec.id));
|
|
360
|
+
if (resumeRefusal(rec) === null)
|
|
361
|
+
return done("resumed", await resumeAgent(d, rec.id, SERVER_SCOPE));
|
|
362
|
+
}
|
|
363
|
+
const title = r.title();
|
|
364
|
+
const st = await startAgent(
|
|
365
|
+
d,
|
|
366
|
+
{
|
|
367
|
+
workspace: SERVER_SCOPE,
|
|
368
|
+
persona: SERVER_PERSONA_ID,
|
|
369
|
+
id: SERVER_AGENT_ID,
|
|
370
|
+
runtime,
|
|
371
|
+
...(model ? { model } : {}),
|
|
372
|
+
name: serverAgentName(title),
|
|
373
|
+
},
|
|
374
|
+
status.fresh
|
|
375
|
+
? {
|
|
376
|
+
firstPrompt: onboardingPrompt(
|
|
377
|
+
title,
|
|
378
|
+
shownDir(path.dirname(r.root()), d.env.HOME ?? os.homedir()),
|
|
379
|
+
),
|
|
380
|
+
}
|
|
381
|
+
: {},
|
|
382
|
+
);
|
|
383
|
+
return done("started", st);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Save the person's model choice; null clears it. Only the runtime's own. */
|
|
387
|
+
export async function setServerModel(
|
|
388
|
+
d: AgentsResolved,
|
|
389
|
+
id: string | null,
|
|
390
|
+
s: ServerAgentDeps = {},
|
|
391
|
+
): Promise<void> {
|
|
392
|
+
if (id !== null) {
|
|
393
|
+
const st = await serverAgentStatus(d, s);
|
|
394
|
+
if (!st.models.some((m) => m.id === id))
|
|
395
|
+
throw new AgentError(
|
|
396
|
+
`${st.runtime?.label ?? st.runtime?.id ?? "this runtime"} has no model "${id}"`,
|
|
397
|
+
{ usage: true },
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
resolve(s).saveModel(id);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Save the person's runtime choice; null clears it. */
|
|
404
|
+
export function setServerRuntime(
|
|
405
|
+
id: string | null,
|
|
406
|
+
s: ServerAgentDeps = {},
|
|
407
|
+
): void {
|
|
408
|
+
if (id !== null && !getRuntime(id))
|
|
409
|
+
throw new AgentError(`no runtime "${id}"`, { usage: true });
|
|
410
|
+
resolve(s).saveSetting(id);
|
|
411
|
+
}
|
package/src/ui/mcp/server.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* stdout carries the protocol only: diagnostics go to stderr.
|
|
7
7
|
*/
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import { isServerScope } from "../../shared/agents/server-agent.js";
|
|
9
10
|
import { randomUUID } from "crypto";
|
|
10
11
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -56,7 +57,9 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
|
56
57
|
const log = opts.log ?? ((l: string) => process.stderr.write(`${l}\n`));
|
|
57
58
|
// Scoped, each workspace tool is registered pinned (MCP-2.2): "this
|
|
58
59
|
// workspace only", and no workspace parameter unless there are grants.
|
|
59
|
-
|
|
60
|
+
// ON-6: the server agent's scope is the whole Den (its tools unpinned).
|
|
61
|
+
const toolScope = workspaceScope(opts.scope);
|
|
62
|
+
const tools = toolsFor(opts.cfg, toolScope);
|
|
60
63
|
const server = new McpServer(
|
|
61
64
|
{ name: p.name, version: engineVersion() ?? "0.0.0" },
|
|
62
65
|
{
|
|
@@ -67,13 +70,17 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
|
67
70
|
? `Actions enabled: ${opts.cfg.actions.join(", ")}.`
|
|
68
71
|
: "The org has enabled no actions (mcp.actions).") +
|
|
69
72
|
` The ${approvalsState(opts.cfg)}.` +
|
|
70
|
-
(opts.scope
|
|
71
|
-
? `
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
(opts.scope && !toolScope
|
|
74
|
+
? ` You are this ${p.title}'s own server agent (${opts.scope.actor}): the whole ${p.title}, ` +
|
|
75
|
+
"no workspace. Each write is held until the person allows it in the browser. " +
|
|
76
|
+
"onboard_status says what is set up: start there."
|
|
77
|
+
: opts.scope
|
|
78
|
+
? ` Scoped to workspace "${opts.scope.workspace}" as ${opts.scope.actor}: ` +
|
|
79
|
+
"a call naming another workspace is refused, and lists show this one only, " +
|
|
80
|
+
"each with a `scope` block saying how much is hidden. org_summary has " +
|
|
81
|
+
"every workspace and repo (names and status counts), the graph, the " +
|
|
82
|
+
"edge and the environments: start there for the whole picture."
|
|
83
|
+
: " org_summary has the whole org at a glance: start there."),
|
|
77
84
|
},
|
|
78
85
|
);
|
|
79
86
|
|
|
@@ -99,7 +106,7 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
|
99
106
|
api,
|
|
100
107
|
cfg: opts.cfg,
|
|
101
108
|
cli: opts.cli,
|
|
102
|
-
...(
|
|
109
|
+
...(toolScope ? { scope: toolScope } : {}),
|
|
103
110
|
};
|
|
104
111
|
|
|
105
112
|
for (const t of tools) {
|
|
@@ -138,6 +145,15 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
|
138
145
|
return server;
|
|
139
146
|
}
|
|
140
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The scope the tools are pinned to: a workspace's, or none for the
|
|
150
|
+
* server agent's (ON-6), whose calls still carry its actor and scope in
|
|
151
|
+
* the audit headers.
|
|
152
|
+
*/
|
|
153
|
+
function workspaceScope(scope: McpScope | undefined): McpScope | undefined {
|
|
154
|
+
return scope && !isServerScope(scope.workspace) ? scope : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
141
157
|
/** The audit fields a scope adds to every call's headers (none unscoped). */
|
|
142
158
|
function scopeInfo(scope: McpScope | undefined): {
|
|
143
159
|
agent?: string;
|
|
@@ -260,7 +276,8 @@ export async function runOneShot(
|
|
|
260
276
|
): Promise<number> {
|
|
261
277
|
const out = opts.out ?? ((t: string) => process.stdout.write(t));
|
|
262
278
|
// The tools exactly as the stdio server registers them: pinned when scoped.
|
|
263
|
-
const
|
|
279
|
+
const toolScope = workspaceScope(opts.scope);
|
|
280
|
+
const tools = toolsFor(opts.cfg, toolScope);
|
|
264
281
|
if (shot.kind === "list") {
|
|
265
282
|
const names = tools.map((t) => t.name);
|
|
266
283
|
out(names.length ? `${names.join("\n")}\n` : "");
|
|
@@ -274,7 +291,7 @@ export async function runOneShot(
|
|
|
274
291
|
api: opts.api,
|
|
275
292
|
cfg: opts.cfg,
|
|
276
293
|
cli: opts.cli,
|
|
277
|
-
...(
|
|
294
|
+
...(toolScope ? { scope: toolScope } : {}),
|
|
278
295
|
};
|
|
279
296
|
// The stdio server validates arguments against each tool's schema (the
|
|
280
297
|
// SDK does it); a one-shot must too, and strictly: it is typed by hand, so
|
|
@@ -286,7 +303,7 @@ export async function runOneShot(
|
|
|
286
303
|
const tool = tools.find((t) => t.name === shot.tool);
|
|
287
304
|
if (tool) {
|
|
288
305
|
const shape =
|
|
289
|
-
|
|
306
|
+
toolScope && !("workspace" in tool.shape) && "workspace" in args
|
|
290
307
|
? { ...tool.shape, workspace: z.string().optional() }
|
|
291
308
|
: tool.shape;
|
|
292
309
|
const parsed = z.object(shape).strict().safeParse(args);
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -1171,7 +1171,9 @@ const APPROVAL_TOOLS: DenTool[] = [
|
|
|
1171
1171
|
{
|
|
1172
1172
|
name: "workspace_create",
|
|
1173
1173
|
title: "Create a workspace",
|
|
1174
|
-
description:
|
|
1174
|
+
description:
|
|
1175
|
+
"Create an empty Den workspace named `name` and make it active, so service_try can start its services." +
|
|
1176
|
+
HELD,
|
|
1175
1177
|
action: true,
|
|
1176
1178
|
shape: {
|
|
1177
1179
|
name: z
|
|
@@ -1245,7 +1247,7 @@ const APPROVAL_TOOLS: DenTool[] = [
|
|
|
1245
1247
|
name: "service_try",
|
|
1246
1248
|
title: "Start one service",
|
|
1247
1249
|
description:
|
|
1248
|
-
"Start one service locally (`up`, as the Services page does) and report how it went: the exit code and the last lines of output." +
|
|
1250
|
+
"Start one service locally (`up`, as the Services page does) and report how it went: the exit code and the last lines of output. A `workspace` that is not active is activated on the same Allow." +
|
|
1249
1251
|
HELD,
|
|
1250
1252
|
action: true,
|
|
1251
1253
|
shape: {
|
|
@@ -183,7 +183,7 @@ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
|
183
183
|
/** Never a TTY: a web start's terminal is 80×24. */
|
|
184
184
|
const NO_TTY = { isTTY: false } as NodeJS.WriteStream;
|
|
185
185
|
|
|
186
|
-
function webDeps(over: AgentsDeps = {}): AgentsResolved {
|
|
186
|
+
export function webDeps(over: AgentsDeps = {}): AgentsResolved {
|
|
187
187
|
return resolveAgentsDeps({
|
|
188
188
|
stateDir: () => getStateDir(),
|
|
189
189
|
workspaceExists: (ws) => Boolean(readWorkspacesFile().workspaces[ws]),
|