@monster-orange/den 0.4.17 → 0.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +14 -13
  3. package/docs/distributions.md +138 -25
  4. package/docs/reference.md +1 -1
  5. package/package.json +2 -1
  6. package/skills/install-den/SKILL.md +35 -75
  7. package/src/shared/agents/den-claude.ts +40 -6
  8. package/src/shared/agents/guardrails/stop-hook.ts +30 -4
  9. package/src/shared/agents/guardrails.ts +40 -4
  10. package/src/shared/agents/handback.ts +22 -4
  11. package/src/shared/agents/personas.ts +32 -5
  12. package/src/shared/agents/runtime.ts +8 -0
  13. package/src/shared/agents/server-agent.ts +57 -0
  14. package/src/shared/agents/server-pack/server.md +45 -0
  15. package/src/shared/environment-manager/workspaces.ts +4 -0
  16. package/src/ui/cli/agent-ops.ts +84 -6
  17. package/src/ui/cli/create.ts +2 -2
  18. package/src/ui/cli/index.ts +1 -1
  19. package/src/ui/cli/repo-command.ts +8 -1
  20. package/src/ui/cli/repo-detect.ts +58 -0
  21. package/src/ui/cli/server-agent-ops.ts +411 -0
  22. package/src/ui/mcp/server.ts +29 -12
  23. package/src/ui/mcp/tools.ts +4 -2
  24. package/src/ui/server/agent-routes.ts +10 -1
  25. package/src/ui/server/held-actions.ts +35 -7
  26. package/src/ui/server/index.ts +20 -5
  27. package/src/ui/server/public/agents-page.js +33 -1
  28. package/src/ui/server/public/agents.html +13 -0
  29. package/src/ui/server/public/error-page.js +39 -1
  30. package/src/ui/server/public/graph.css +43 -0
  31. package/src/ui/server/public/graph.js +68 -1
  32. package/src/ui/server/public/header.js +36 -7
  33. package/src/ui/server/public/i18n/es.js +72 -0
  34. package/src/ui/server/public/i18n/ja.js +74 -0
  35. package/src/ui/server/public/local-dev.html +33 -3
  36. package/src/ui/server/public/log-source.js +26 -0
  37. package/src/ui/server/public/logs.html +7 -20
  38. package/src/ui/server/public/persona-ui.css +6 -0
  39. package/src/ui/server/public/server-agent-page.js +415 -0
  40. package/src/ui/server/public/server-agent.html +222 -0
  41. package/src/ui/server/public/services.html +18 -3
  42. package/src/ui/server/public/shell.js +274 -31
  43. package/src/ui/server/public/svc-panel.js +685 -0
  44. package/src/ui/server/public/topbar.js +13 -1
  45. package/src/ui/server/route-classes.ts +21 -0
  46. package/src/ui/server/server-agent-routes.ts +176 -0
  47. package/src/ui/server/session-routes.ts +12 -1
  48. package/src/ui/server/ui-assets.ts +6 -0
  49. package/src/ui/server/workspace-routes.ts +3 -0
@@ -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]),
@@ -315,6 +315,8 @@ interface StartBody {
315
315
  instruction?: unknown;
316
316
  /** AG-10.10: a display name, checked as a rename's is. */
317
317
  name?: unknown;
318
+ /** 0.4.18: the folder it runs in (Start agent here). */
319
+ folder?: string;
318
320
  }
319
321
 
320
322
  const START_KEYS = [
@@ -327,6 +329,7 @@ const START_KEYS = [
327
329
  "allow",
328
330
  "instruction",
329
331
  "name",
332
+ "folder",
330
333
  ];
331
334
 
332
335
  async function startBody(req: http.IncomingMessage): Promise<StartBody> {
@@ -374,6 +377,11 @@ async function startBody(req: http.IncomingMessage): Promise<StartBody> {
374
377
  if (o.instruction !== undefined) out.instruction = o.instruction;
375
378
  if (o.name !== undefined && o.name !== null && o.name !== "")
376
379
  out.name = o.name;
380
+ if (o.folder !== undefined && o.folder !== null && o.folder !== "") {
381
+ if (typeof o.folder !== "string" || o.folder.length > 4096)
382
+ throw new AgentError("folder must be a path", { usage: true });
383
+ out.folder = o.folder;
384
+ }
377
385
  return out as StartBody;
378
386
  }
379
387
 
@@ -585,6 +593,7 @@ export async function handleAgentRoutes(
585
593
  : {}),
586
594
  ...(body.allow !== undefined ? { allow: body.allow } : {}),
587
595
  ...(body.name !== undefined ? { name: body.name } : {}),
596
+ ...(body.folder !== undefined ? { folder: body.folder } : {}),
588
597
  strict: true,
589
598
  });
590
599
  const ov = s.rec.overrides?.length
@@ -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: [`A new, empty workspace named "${name}".`],
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
- return { ok: true, name };
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: { service: t.address, ...(workspace ? { workspace } : {}) },
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
- return d.tryService(String(a.service), str(a.workspace));
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