@monster-orange/den 0.4.18 → 0.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +14 -13
  3. package/docs/distributions.md +99 -24
  4. package/docs/reference.md +1 -1
  5. package/package.json +2 -1
  6. package/skills/install-den/SKILL.md +35 -75
  7. package/src/shared/agents/den-claude.ts +40 -6
  8. package/src/shared/agents/guardrails/stop-hook.ts +30 -4
  9. package/src/shared/agents/guardrails.ts +40 -4
  10. package/src/shared/agents/handback.ts +22 -4
  11. package/src/shared/agents/personas.ts +32 -5
  12. package/src/shared/agents/runtime.ts +8 -0
  13. package/src/shared/agents/server-agent.ts +57 -0
  14. package/src/shared/agents/server-pack/server.md +45 -0
  15. package/src/shared/environment-manager/workspaces.ts +4 -0
  16. package/src/ui/cli/agent-ops.ts +41 -6
  17. package/src/ui/cli/create.ts +2 -2
  18. package/src/ui/cli/index.ts +1 -1
  19. package/src/ui/cli/repo-command.ts +8 -1
  20. package/src/ui/cli/repo-detect.ts +58 -0
  21. package/src/ui/cli/server-agent-ops.ts +411 -0
  22. package/src/ui/mcp/server.ts +29 -12
  23. package/src/ui/mcp/tools.ts +4 -2
  24. package/src/ui/server/agent-routes.ts +1 -1
  25. package/src/ui/server/held-actions.ts +35 -7
  26. package/src/ui/server/index.ts +20 -5
  27. package/src/ui/server/public/agents-page.js +10 -1
  28. package/src/ui/server/public/error-page.js +39 -1
  29. package/src/ui/server/public/graph.css +21 -0
  30. package/src/ui/server/public/graph.js +49 -6
  31. package/src/ui/server/public/header.js +29 -7
  32. package/src/ui/server/public/i18n/es.js +37 -0
  33. package/src/ui/server/public/i18n/ja.js +39 -0
  34. package/src/ui/server/public/local-dev.html +22 -1
  35. package/src/ui/server/public/persona-ui.css +6 -0
  36. package/src/ui/server/public/server-agent-page.js +415 -0
  37. package/src/ui/server/public/server-agent.html +222 -0
  38. package/src/ui/server/public/shell.js +66 -6
  39. package/src/ui/server/public/svc-panel.js +27 -14
  40. package/src/ui/server/public/topbar.js +13 -1
  41. package/src/ui/server/route-classes.ts +21 -0
  42. package/src/ui/server/server-agent-routes.ts +176 -0
  43. package/src/ui/server/session-routes.ts +12 -1
  44. package/src/ui/server/ui-assets.ts +2 -0
  45. package/src/ui/server/workspace-routes.ts +3 -0
@@ -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
+ }
@@ -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
- const tools = toolsFor(opts.cfg, opts.scope);
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
- ? ` Scoped to workspace "${opts.scope.workspace}" as ${opts.scope.actor}: ` +
72
- "a call naming another workspace is refused, and lists show this one only, " +
73
- "each with a `scope` block saying how much is hidden. org_summary has " +
74
- "every workspace and repo (names and status counts), the graph, the " +
75
- "edge and the environments: start there for the whole picture."
76
- : " org_summary has the whole org at a glance: start there."),
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
- ...(opts.scope ? { scope: opts.scope } : {}),
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 tools = toolsFor(opts.cfg, opts.scope);
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
- ...(opts.scope ? { scope: opts.scope } : {}),
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
- opts.scope && !("workspace" in tool.shape) && "workspace" in args
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);
@@ -1171,7 +1171,9 @@ const APPROVAL_TOOLS: DenTool[] = [
1171
1171
  {
1172
1172
  name: "workspace_create",
1173
1173
  title: "Create a workspace",
1174
- description: "Create an empty Den workspace named `name`." + HELD,
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]),