@monster-orange/den 0.4.18 → 0.4.20
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 +160 -0
- package/README.md +14 -13
- package/docs/distributions.md +148 -30
- 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/shared/secrets/index.ts +71 -3
- package/src/shared/secrets/onepassword.ts +87 -14
- package/src/shared/secrets/op-token.ts +270 -0
- package/src/shared/secrets/secret-env.ts +19 -6
- package/src/shared/secrets/strategies.ts +6 -0
- package/src/shared/secrets/types.ts +8 -0
- package/src/ui/cli/agent-ops.ts +41 -6
- package/src/ui/cli/create.ts +2 -2
- package/src/ui/cli/doctor-command.ts +54 -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 +25 -6
- 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 +38 -0
- package/src/ui/server/public/i18n/ja.js +40 -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/settings-registry.js +22 -0
- package/src/ui/server/public/shell.js +67 -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
|
@@ -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]),
|
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
* copies:
|
|
6
6
|
*
|
|
7
7
|
* workspace_create the Workspaces page's create (workspaceCreateRefusal,
|
|
8
|
-
* createWorkspace)
|
|
8
|
+
* createWorkspace), then activates it: nothing an
|
|
9
|
+
* agent calls activates a workspace, and service_try
|
|
10
|
+
* needs an active one
|
|
9
11
|
* repo_add `repo add --json` (repo-command.ts repoAdd), a local
|
|
10
12
|
* folder inside home only, as manifest_draft reads; its
|
|
11
13
|
* dry run is the Review
|
|
12
14
|
* service_try `up` through the service runner, as
|
|
13
15
|
* /api/service/exec and service_up run it
|
|
14
|
-
* (lifecycle-exec.ts), until it is ready or exits
|
|
16
|
+
* (lifecycle-exec.ts), until it is ready or exits;
|
|
17
|
+
* an inactive workspace is activated first, on the
|
|
18
|
+
* same Allow, and the Review says so
|
|
15
19
|
*/
|
|
16
20
|
import * as path from "path";
|
|
17
21
|
import { checkOnboardPath, type PathRules } from "../cli/onboard-reads.js";
|
|
@@ -35,6 +39,8 @@ export interface HeldActionDeps {
|
|
|
35
39
|
validate: ManifestValidator;
|
|
36
40
|
workspaceCreateRefusal: (name: string | undefined) => string | null;
|
|
37
41
|
createWorkspace: (name: string) => void;
|
|
42
|
+
activateWorkspace: (name: string) => void;
|
|
43
|
+
workspaceActive: (name: string) => boolean;
|
|
38
44
|
workspaceExists: (name: string) => boolean;
|
|
39
45
|
/** Where `repo add` puts a repo with no -w: the focused one, else `default`. */
|
|
40
46
|
defaultWorkspace: () => string;
|
|
@@ -84,13 +90,16 @@ export function heldActions(d: HeldActionDeps): HeldActions {
|
|
|
84
90
|
args: { name },
|
|
85
91
|
summary: `create the workspace "${name}"`,
|
|
86
92
|
params: { name },
|
|
87
|
-
details: [
|
|
93
|
+
details: [
|
|
94
|
+
`A new, empty workspace named "${name}", made active so its services can start.`,
|
|
95
|
+
],
|
|
88
96
|
};
|
|
89
97
|
},
|
|
90
98
|
async run(a) {
|
|
91
99
|
const name = String(a.name);
|
|
92
100
|
d.createWorkspace(name);
|
|
93
|
-
|
|
101
|
+
d.activateWorkspace(name);
|
|
102
|
+
return { ok: true, name, active: true };
|
|
94
103
|
},
|
|
95
104
|
};
|
|
96
105
|
|
|
@@ -177,21 +186,40 @@ export function heldActions(d: HeldActionDeps): HeldActions {
|
|
|
177
186
|
if (!service)
|
|
178
187
|
return { ok: false, error: "give service: <repo> or <repo>/<service>" };
|
|
179
188
|
const workspace = str(a.workspace);
|
|
189
|
+
if (workspace !== undefined && !NAME_RE.test(workspace))
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error: `workspace "${workspace}" must use only letters, digits, ".", "_" and "-"`,
|
|
193
|
+
};
|
|
194
|
+
if (workspace !== undefined && !d.workspaceExists(workspace))
|
|
195
|
+
return { ok: false, error: `there is no workspace "${workspace}"` };
|
|
180
196
|
const t = d.serviceTarget(service, workspace);
|
|
181
197
|
if (!t.ok) return { ok: false, error: t.error };
|
|
198
|
+
const activates =
|
|
199
|
+
workspace !== undefined && !d.workspaceActive(workspace);
|
|
182
200
|
return {
|
|
183
201
|
ok: true,
|
|
184
202
|
args: { service: t.address, ...(workspace ? { workspace } : {}) },
|
|
185
|
-
summary: `start ${t.address}${workspace ? ` in workspace "${workspace}"` : ""}`,
|
|
186
|
-
params: {
|
|
203
|
+
summary: `start ${t.address}${workspace ? ` in workspace "${workspace}"` : ""}${activates ? ` (this activates ${workspace})` : ""}`,
|
|
204
|
+
params: {
|
|
205
|
+
service: t.address,
|
|
206
|
+
...(workspace ? { workspace } : {}),
|
|
207
|
+
...(activates ? { activates: "1" } : {}),
|
|
208
|
+
},
|
|
187
209
|
details: [
|
|
210
|
+
...(activates
|
|
211
|
+
? [`Activates the workspace "${workspace}" first, so it can run.`]
|
|
212
|
+
: []),
|
|
188
213
|
`Runs the service's local "up", as the Services page's Start does.`,
|
|
189
214
|
`Den reports the exit code and the last lines of its output.`,
|
|
190
215
|
],
|
|
191
216
|
};
|
|
192
217
|
},
|
|
193
218
|
async run(a) {
|
|
194
|
-
|
|
219
|
+
const workspace = str(a.workspace);
|
|
220
|
+
if (workspace && !d.workspaceActive(workspace))
|
|
221
|
+
d.activateWorkspace(workspace);
|
|
222
|
+
return d.tryService(String(a.service), workspace);
|
|
195
223
|
},
|
|
196
224
|
};
|
|
197
225
|
|