@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +14 -13
  3. package/docs/distributions.md +148 -30
  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/shared/secrets/index.ts +71 -3
  17. package/src/shared/secrets/onepassword.ts +87 -14
  18. package/src/shared/secrets/op-token.ts +270 -0
  19. package/src/shared/secrets/secret-env.ts +19 -6
  20. package/src/shared/secrets/strategies.ts +6 -0
  21. package/src/shared/secrets/types.ts +8 -0
  22. package/src/ui/cli/agent-ops.ts +41 -6
  23. package/src/ui/cli/create.ts +2 -2
  24. package/src/ui/cli/doctor-command.ts +54 -2
  25. package/src/ui/cli/index.ts +1 -1
  26. package/src/ui/cli/repo-command.ts +8 -1
  27. package/src/ui/cli/repo-detect.ts +58 -0
  28. package/src/ui/cli/server-agent-ops.ts +411 -0
  29. package/src/ui/mcp/server.ts +29 -12
  30. package/src/ui/mcp/tools.ts +4 -2
  31. package/src/ui/server/agent-routes.ts +1 -1
  32. package/src/ui/server/held-actions.ts +35 -7
  33. package/src/ui/server/index.ts +25 -6
  34. package/src/ui/server/public/agents-page.js +10 -1
  35. package/src/ui/server/public/error-page.js +39 -1
  36. package/src/ui/server/public/graph.css +21 -0
  37. package/src/ui/server/public/graph.js +49 -6
  38. package/src/ui/server/public/header.js +29 -7
  39. package/src/ui/server/public/i18n/es.js +38 -0
  40. package/src/ui/server/public/i18n/ja.js +40 -0
  41. package/src/ui/server/public/local-dev.html +22 -1
  42. package/src/ui/server/public/persona-ui.css +6 -0
  43. package/src/ui/server/public/server-agent-page.js +415 -0
  44. package/src/ui/server/public/server-agent.html +222 -0
  45. package/src/ui/server/public/settings-registry.js +22 -0
  46. package/src/ui/server/public/shell.js +67 -6
  47. package/src/ui/server/public/svc-panel.js +27 -14
  48. package/src/ui/server/public/topbar.js +13 -1
  49. package/src/ui/server/route-classes.ts +21 -0
  50. package/src/ui/server/server-agent-routes.ts +176 -0
  51. package/src/ui/server/session-routes.ts +12 -1
  52. package/src/ui/server/ui-assets.ts +2 -0
  53. package/src/ui/server/workspace-routes.ts +3 -0
@@ -200,6 +200,8 @@
200
200
  "svp.startHere": "Start agent here",
201
201
  "svp.openSvc": "Open in Services",
202
202
  "svp.openGraph": "Open in Graph",
203
+ "graph.outside": "{s} is in {ws}, outside this View.",
204
+ "graph.showWs": "Show {ws}",
203
205
  "svp.close": "Close",
204
206
  "ag.new.folder": "Folder (optional)",
205
207
  "ag.new.folderHelp":
@@ -428,6 +430,10 @@
428
430
  "err.signin.step2":
429
431
  "This page signs you in by itself once approved. The code expires in 10 minutes.",
430
432
  "err.signin.waiting": "Waiting for approval…",
433
+ "err.signin.windowsSteps":
434
+ "Open Windows Terminal or PowerShell, paste this, and press Enter.",
435
+ "err.signin.nothingOpened":
436
+ "Nothing opened? Copy the command below and paste it into your terminal app.",
431
437
  "err.signin.noTerminal":
432
438
  "Don't have a terminal on {host}? From this computer, run:",
433
439
  "err.signin.openTerminal": "Open Terminal",
@@ -510,6 +516,7 @@
510
516
  "set.cli.web_port": "Your web port",
511
517
  "set.cli.signin": "Browser sign-in",
512
518
  "set.cli.sudo": "Admin password",
519
+ "set.cli.op_token": "1Password token source",
513
520
  "set.access.owner": "Owner",
514
521
  "set.access.devices": "Linked devices",
515
522
  "ident.local": "Local",
@@ -691,6 +698,8 @@
691
698
  "ap.do.repo_add.new":
692
699
  'Add the repo at {path} to a new workspace "{workspace}"',
693
700
  "ap.do.service_try": "Start {service}",
701
+ "ap.do.service_try.activates":
702
+ "Start {service} (this activates {workspace})",
694
703
  "ap.says": "Agent says:",
695
704
  "ap.review": "Review",
696
705
  "ap.hide": "Hide details",
@@ -833,6 +842,34 @@
833
842
  "ag.ws": "Workspace",
834
843
  "ag.pal": "Agents › {ws} / {name}",
835
844
  "ag.palStart": "Start agent…",
845
+ // ON-6: the server agent (server-agent-page.js, the rail, ⌘K).
846
+ "n.serverAgent": "__DEN_TITLE__ agent",
847
+ "ag.server": "Server",
848
+ "pal.askServer": "Ask __DEN_TITLE__",
849
+ "sa.sub.fresh":
850
+ "Let's set up __DEN_TITLE__. It reads only until you allow a write.",
851
+ "sa.sub":
852
+ "For anything about this server: add a repo, fix a failing service, change a setting, or explain what's running.",
853
+ "sa.login":
854
+ "If Claude asks you to log in, log in here. Then {name} starts.",
855
+ "sa.trust": "Claude asks whether to trust this folder. Choose Yes.",
856
+ "sa.model": "Model",
857
+ "sa.readsOnly":
858
+ "It acts only through __DEN_TITLE__'s own tools. Each change asks you first, in this browser.",
859
+ "sa.starting": "Starting the agent…",
860
+ "sa.idle": "{name} isn't running.",
861
+ "sa.start": "Start",
862
+ "sa.failed": "It didn't start: {why}. {cli} agents doctor says more.",
863
+ "sa.unread": "Couldn't read the agent's status. Is the web server running?",
864
+ "sa.none.h": "No runtime can run the agent yet",
865
+ "sa.none":
866
+ "The agent runs on an agent CLI from the runtime catalog. Install one, log in to it once, then reload this page.",
867
+ "sa.noTools": "can't use __DEN_TITLE__'s tools yet",
868
+ "sa.manual.h": "Or set it up yourself",
869
+ "sa.manual": "Every step the agent takes is also a command. In a terminal:",
870
+ "sa.runsOn": "Runs on",
871
+ "sa.openAgents": "Open in Agents",
872
+ "sa.saved": "Saved. The agent uses it from its next start.",
836
873
  "ag.cap": "Up to 4 panes: {id}'s closed to make room (it keeps running)",
837
874
  // AG-4b: the Agents tab's layouts and named tabs.
838
875
  "ag.layout": "Layout",
@@ -953,6 +990,9 @@
953
990
  agents:
954
991
  '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 10l3 2-3 2M12 15h5"/>',
955
992
  edge: '<path d="M12 3l8 4v5c0 4.5-3.4 8.3-8 9-4.6-.7-8-4.5-8-9V7z"/><path d="M8 12h8M12 8v8"/>',
993
+ // ON-6: the server agent, a speech bubble with a spark.
994
+ assistant:
995
+ '<path d="M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-8l-4 3v-3H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/><path d="M12 8v4M10 10h4"/>',
956
996
  // SEC-2a: who this browser is (the rail footer's identity chip).
957
997
  person:
958
998
  '<circle cx="12" cy="8" r="3.5"/><path d="M5 20c.8-3.6 3.6-5.5 7-5.5s6.2 1.9 7 5.5"/>',
@@ -1813,6 +1853,16 @@
1813
1853
  href: "/agents",
1814
1854
  keywords: ["sessions", "terminal"],
1815
1855
  },
1856
+ // ON-6: the server agent, this Den's own: one click away, after Agents.
1857
+ {
1858
+ id: "server-agent",
1859
+ label: "n.serverAgent",
1860
+ icon: "assistant",
1861
+ group: "develop",
1862
+ order: 18,
1863
+ href: "/server-agent",
1864
+ keywords: ["server agent", "ask", "setup", "onboarding"],
1865
+ },
1816
1866
  {
1817
1867
  id: "services",
1818
1868
  label: "n.services",
@@ -1966,11 +2016,16 @@
1966
2016
  "</div>" +
1967
2017
  chips +
1968
2018
  "</header>" +
2019
+ // The rail's box: no box of its own (display: contents). As ☰'s
2020
+ // drawer it is the modal dialog the rail sits in, so the rail stays a
2021
+ // nav (a <nav> may not be a dialog).
2022
+ '<div class="den-rail-box" id="den-rail-box">' +
1969
2023
  '<nav class="den-rail" id="den-rail" aria-label="' +
1970
2024
  esc(t("pages")) +
1971
- '"></nav>';
2025
+ '"></nav></div>';
1972
2026
 
1973
2027
  var nav = host.querySelector(".den-rail");
2028
+ var railBox = host.querySelector(".den-rail-box");
1974
2029
 
1975
2030
  // ── the scope parts (R4) ───────────────────────────────────────────────
1976
2031
  // Each part's menu comes from its model, set by header.js: setOrgs (the
@@ -2593,8 +2648,9 @@
2593
2648
  function openDrawer() {
2594
2649
  document.body.classList.add("den-rail-open");
2595
2650
  if (menu) menu.setAttribute("aria-expanded", "true");
2596
- nav.setAttribute("role", "dialog");
2597
- nav.setAttribute("aria-modal", "true");
2651
+ railBox.setAttribute("role", "dialog");
2652
+ railBox.setAttribute("aria-modal", "true");
2653
+ railBox.setAttribute("aria-label", t("pages"));
2598
2654
  if (!scrim) {
2599
2655
  scrim = document.createElement("div");
2600
2656
  scrim.className = "den-scrim";
@@ -2614,8 +2670,9 @@
2614
2670
  if (!drawerOpen()) return;
2615
2671
  document.body.classList.remove("den-rail-open");
2616
2672
  if (menu) menu.setAttribute("aria-expanded", "false");
2617
- nav.removeAttribute("role");
2618
- nav.removeAttribute("aria-modal");
2673
+ railBox.removeAttribute("role");
2674
+ railBox.removeAttribute("aria-modal");
2675
+ railBox.removeAttribute("aria-label");
2619
2676
  if (scrim && scrim.remove) scrim.remove();
2620
2677
  setInert(false);
2621
2678
  if (refocus && menu) menu.focus();
@@ -4334,7 +4391,10 @@
4334
4391
  }
4335
4392
  function apWhat(a) {
4336
4393
  var p = a.params || {};
4337
- var key = "ap.do." + a.action + (p.isNew ? ".new" : "");
4394
+ var key =
4395
+ "ap.do." +
4396
+ a.action +
4397
+ (p.isNew ? ".new" : p.activates ? ".activates" : "");
4338
4398
  var s = t(key, p);
4339
4399
  if (s !== key) return s;
4340
4400
  s = String(a.summary || a.action);
@@ -4966,6 +5026,7 @@
4966
5026
  // ◐ ▾ (the prototype's user menu, an .ib: appearance.js)
4967
5027
  ".den-top .den-ib[aria-expanded='true'] { background: var(--sunken); color: var(--text); }",
4968
5028
  // Rail (.rail)
5029
+ ".den-rail-box { display: contents; }",
4969
5030
  ".den-rail { position: fixed; left: 0; top: var(--den-top-h); bottom: 0; width: var(--den-rail-cw);",
4970
5031
  " box-sizing: content-box; display: flex; flex-direction: column; padding: 10px 8px;",
4971
5032
  " background: var(--surface); border-right: 1px solid var(--border); overflow-y: auto; z-index: 15;",
@@ -43,8 +43,6 @@ const REFRESH_MS = 5_000;
43
43
  * does (lifecycle-exec.ts, readyTimeoutMs).
44
44
  */
45
45
  const START_TIMEOUT_MS = 120_000;
46
- /** A down check this soon after Start is the service still coming up. */
47
- const START_GRACE_MS = 10_000;
48
46
  /** Restart: an up check counts after a down one, or after this long. */
49
47
  const RESTART_MIN_MS = 5_000;
50
48
  /** Phone width: the panel is a bottom sheet. */
@@ -60,8 +58,9 @@ const S = {
60
58
  sessions: [],
61
59
  /**
62
60
  * Pin ids starting from here (Start or Restart): id → { at, cmd, sawDown,
63
- * timer }. "starting…" follows the health feed, not the exec stream: a
64
- * dev server's `up` never ends.
61
+ * ready, timer }. "starting…" follows the health feed, not the exec
62
+ * stream: a dev server's `up` never ends. While it is set, a down from
63
+ * health reads "starting…" too.
65
64
  */
66
65
  busy: new Map(),
67
66
  /** Pin ids with tests in flight from here. */
@@ -77,6 +76,7 @@ function startBusy(id, cmd) {
77
76
  at: Date.now(),
78
77
  cmd,
79
78
  sawDown: false,
79
+ ready: false,
80
80
  timer: setTimeout(() => {
81
81
  endBusy(id);
82
82
  sync();
@@ -91,23 +91,35 @@ function endBusy(id) {
91
91
  }
92
92
  /**
93
93
  * A health update for the pins starting from here: up ends "starting…"
94
- * (after Restart, once the old instance went down or after a moment);
95
- * down ends it once the service has had a moment to come up.
94
+ * (after Restart, once the old instance went down, after a moment, or once
95
+ * the server's ready watch said so). A down never ends it: the service is
96
+ * still coming up until the first up or the timeout.
96
97
  */
97
98
  function settleBusy() {
98
99
  for (const [id, b] of [...S.busy]) {
99
100
  const s = serviceOf(id);
100
101
  const age = Date.now() - b.at;
101
- if (s.ok === false) {
102
- b.sawDown = true;
103
- if (age >= START_GRACE_MS) endBusy(id);
104
- } else if (
102
+ if (s.ok === false) b.sawDown = true;
103
+ else if (
105
104
  s.ok === true &&
106
- (b.cmd !== "restart" || b.sawDown || age >= RESTART_MIN_MS)
105
+ (b.cmd !== "restart" || b.ready || b.sawDown || age >= RESTART_MIN_MS)
107
106
  )
108
107
  endBusy(id);
109
108
  }
110
109
  }
110
+ /**
111
+ * The exec stream's ready (the server's own health check passed) or its
112
+ * end. This page's health results can still hold a down from before (the
113
+ * forced refresh hasn't answered yet), so ready ends "starting…" only when
114
+ * they already say up, or there is no check; otherwise the next up does.
115
+ * An exec that ended in failure ends it now.
116
+ */
117
+ function readyBusy(id, ok = true) {
118
+ const b = S.busy.get(id);
119
+ if (!b) return;
120
+ if (!ok || serviceOf(id).ok !== false) endBusy(id);
121
+ else b.ready = true;
122
+ }
111
123
 
112
124
  /** The primary service of a repo from the health results (entrypoint first). */
113
125
  function primaryOf(repo) {
@@ -501,8 +513,9 @@ function exec(s, cmd) {
501
513
  if (cmd !== "down") startBusy(s.id, cmd);
502
514
  if (cmd === "down") window.denShell?.toast?.(t("svp.stopping", { s: s.id }));
503
515
  sync();
504
- const settled = () => {
505
- endBusy(s.id);
516
+ const settled = (ok = true) => {
517
+ readyBusy(s.id, ok);
518
+ sync();
506
519
  S.deps.refreshHealth?.(env(), { force: true });
507
520
  void refresh();
508
521
  };
@@ -510,7 +523,7 @@ function exec(s, cmd) {
510
523
  `/api/service/exec?${params}`,
511
524
  `${s.repo}/${s.service}`,
512
525
  `${cmd} ${s.repo}/${s.service}`,
513
- { again: () => exec(s, cmd), ready: settled },
526
+ { again: () => exec(s, cmd), ready: () => settled(true) },
514
527
  settled,
515
528
  );
516
529
  }
@@ -885,6 +885,8 @@ function paletteItems() {
885
885
  });
886
886
  }
887
887
  for (const x of palSessions) {
888
+ // ON-6: the server agent is "Ask <name>" below, not a workspace's.
889
+ if (x.server) continue;
888
890
  // AG-4b: the name a person gave the agent, else its persona.
889
891
  const base =
890
892
  x.kind === "agent" ? x.displayName || (x.persona ?? x.label) : x.label;
@@ -923,7 +925,7 @@ function paletteItems() {
923
925
  for (const ws of s?.workspaceNames?.() ?? [])
924
926
  if (!byWs.has(ws)) byWs.set(ws, []);
925
927
  for (const x of palSessions) {
926
- if (!x.workspace) continue;
928
+ if (!x.workspace || x.server) continue;
927
929
  const names = byWs.get(x.workspace) ?? [];
928
930
  names.push(x.label, x.displayName ?? "", x.persona ?? "");
929
931
  byWs.set(x.workspace, names);
@@ -947,6 +949,16 @@ function paletteItems() {
947
949
  keywords: ["agents", "start", "persona"],
948
950
  run: () => go("/agents?start=1"),
949
951
  });
952
+ // ON-6: Ask <name>, the server agent's page.
953
+ const ask = s?.nav?.().find((n) => n.id === "server-agent");
954
+ if (ask)
955
+ items.push({
956
+ key: "agent:ask-server",
957
+ kind: "action",
958
+ label: t("pal.askServer"),
959
+ keywords: ["ask", "server agent", "agent", "help", "setup"],
960
+ run: () => go(ask.href),
961
+ });
950
962
  for (const c of s?.commands?.() ?? [])
951
963
  items.push({
952
964
  key: `cmd:${c.id}`,
@@ -73,6 +73,8 @@ export const ROUTES: readonly RouteEntry[] = [
73
73
  "/graph.html",
74
74
  "/agents",
75
75
  "/agents.html",
76
+ "/server-agent",
77
+ "/server-agent.html",
76
78
  "/tests",
77
79
  "/index.html",
78
80
  "/services",
@@ -174,6 +176,10 @@ export const ROUTES: readonly RouteEntry[] = [
174
176
  "/api/sessions",
175
177
  "lists the sessions and agents (names and states, never their output)",
176
178
  ),
179
+ R(
180
+ "/api/server-agent",
181
+ "the server agent's name, runtime and state (ON-6): reads only",
182
+ ),
177
183
  R("/api/health", "runs the manifests' health probes: read-only checks"),
178
184
  R("/api/test-sources", "git rev-parse in each repo: read-only"),
179
185
  R("/api/manifest/validate", "GET validates a file at a path: read-only"),
@@ -389,6 +395,21 @@ export const ROUTES: readonly RouteEntry[] = [
389
395
  "/api/workspaces/:ws/agents",
390
396
  "starts an agent: a runtime in a PTY",
391
397
  ),
398
+ S(
399
+ ["POST"],
400
+ "/api/server-agent/start",
401
+ "starts the server agent (ON-6): a runtime in a PTY; never an agent's token",
402
+ ),
403
+ M(
404
+ ["POST"],
405
+ "/api/server-agent/runtime",
406
+ "the person's choice of the server agent's runtime (user.yml); never an agent's token",
407
+ ),
408
+ M(
409
+ ["POST"],
410
+ "/api/server-agent/model",
411
+ "the person's choice of the server agent's model (user.yml); never an agent's token",
412
+ ),
392
413
  S(
393
414
  ["POST"],
394
415
  "/api/workspaces/:ws/conversations/:id/import",
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The server agent's routes (ON-6):
3
+ *
4
+ * GET /api/server-agent what it is, its runtime (or why none),
5
+ * whether it runs, whether this Den is
6
+ * fresh, and the manual path
7
+ * POST /api/server-agent/start { runtime? }: start it, or bring it
8
+ * back (resume, relaunch); a person only
9
+ * POST /api/server-agent/runtime { runtime: id | null }: the person's
10
+ * choice (user.yml agents.server_runtime)
11
+ * POST /api/server-agent/model { model: id | null }: the person's
12
+ * model, one of the runtime's own
13
+ * (user.yml agents.server_model)
14
+ *
15
+ * An agent never starts the server agent or picks its runtime: any agent
16
+ * credential or claim is refused (403).
17
+ */
18
+ import type * as http from "http";
19
+ import { HostError } from "../../shared/sessions/client.js";
20
+ import { AgentError, type AgentsDeps } from "../cli/agent-ops.js";
21
+ import {
22
+ serverAgentStatus,
23
+ setServerModel,
24
+ setServerRuntime,
25
+ startServerAgent,
26
+ type ServerAgentDeps,
27
+ } from "../cli/server-agent-ops.js";
28
+ import { webDeps } from "./agent-routes.js";
29
+ import { recordAction } from "./audit.js";
30
+ import { readBody, sendJson } from "./http-helpers.js";
31
+ import {
32
+ agentPrincipalOf,
33
+ auditActorOf,
34
+ principalOf,
35
+ } from "./request-guard.js";
36
+
37
+ export interface ServerAgentRouteDeps {
38
+ agents?: AgentsDeps;
39
+ server?: ServerAgentDeps;
40
+ }
41
+
42
+ const MAX_BODY = 4 * 1024;
43
+
44
+ function sendError(res: http.ServerResponse, e: unknown): void {
45
+ if (e instanceof AgentError) {
46
+ sendJson(
47
+ res,
48
+ {
49
+ ok: false,
50
+ error: e.message,
51
+ ...(e.code ? { code: e.code } : {}),
52
+ ...(e.hints.length ? { hints: e.hints } : {}),
53
+ },
54
+ e.status,
55
+ );
56
+ return;
57
+ }
58
+ if (e instanceof HostError) {
59
+ sendJson(res, { ok: false, error: e.message, code: "session_host" }, 503);
60
+ return;
61
+ }
62
+ sendJson(res, { ok: false, error: (e as Error).message }, 500);
63
+ }
64
+
65
+ async function body(
66
+ req: http.IncomingMessage,
67
+ ): Promise<{ runtime?: unknown; model?: unknown }> {
68
+ const raw = await readBody(req);
69
+ if (raw.length > MAX_BODY)
70
+ throw new AgentError("body too large", { status: 413 });
71
+ if (!raw.trim()) return {};
72
+ let b: unknown;
73
+ try {
74
+ b = JSON.parse(raw);
75
+ } catch {
76
+ throw new AgentError("body must be JSON", { usage: true });
77
+ }
78
+ if (!b || typeof b !== "object" || Array.isArray(b))
79
+ throw new AgentError("body must be an object", { usage: true });
80
+ return b as { runtime?: unknown; model?: unknown };
81
+ }
82
+
83
+ export async function handleServerAgentRoutes(
84
+ req: http.IncomingMessage,
85
+ res: http.ServerResponse,
86
+ pathname: string,
87
+ deps: ServerAgentRouteDeps = {},
88
+ ): Promise<boolean> {
89
+ if (
90
+ pathname !== "/api/server-agent" &&
91
+ pathname !== "/api/server-agent/start" &&
92
+ pathname !== "/api/server-agent/runtime" &&
93
+ pathname !== "/api/server-agent/model"
94
+ )
95
+ return false;
96
+ const method = req.method ?? "GET";
97
+ const d = webDeps(deps.agents);
98
+ if (pathname === "/api/server-agent") {
99
+ if (method !== "GET" && method !== "HEAD") {
100
+ res.setHeader("Allow", "GET");
101
+ sendJson(res, { ok: false, error: "method not allowed" }, 405);
102
+ return true;
103
+ }
104
+ try {
105
+ sendJson(res, await serverAgentStatus(d, deps.server));
106
+ } catch (e) {
107
+ sendError(res, e);
108
+ }
109
+ return true;
110
+ }
111
+ if (method !== "POST") {
112
+ res.setHeader("Allow", "POST");
113
+ sendJson(res, { ok: false, error: "method not allowed" }, 405);
114
+ return true;
115
+ }
116
+ const actor = auditActorOf(req);
117
+ const verb = pathname.endsWith("/start")
118
+ ? "start"
119
+ : pathname.endsWith("/model")
120
+ ? "model"
121
+ : "runtime";
122
+ const audit = (detail: string, reason?: string) =>
123
+ recordAction(
124
+ actor,
125
+ {
126
+ verb: "server-agent",
127
+ detail,
128
+ ...(reason ? { outcome: "refused" as const, reason } : {}),
129
+ },
130
+ principalOf(req),
131
+ );
132
+ try {
133
+ // The person's: an agent's credential, or a claim to be one, never.
134
+ if (agentPrincipalOf(req) || actor)
135
+ throw new AgentError(
136
+ "only a person can start the server agent or pick its runtime or model",
137
+ { status: 403 },
138
+ );
139
+ const b = await body(req);
140
+ if (
141
+ b.runtime !== undefined &&
142
+ b.runtime !== null &&
143
+ typeof b.runtime !== "string"
144
+ )
145
+ throw new AgentError("runtime must be a runtime id", { usage: true });
146
+ if (verb === "model") {
147
+ if (b.model !== null && typeof b.model !== "string")
148
+ throw new AgentError("model must be a model id or null", {
149
+ usage: true,
150
+ });
151
+ const id = (b.model as string | null) ?? null;
152
+ await setServerModel(d, id, deps.server);
153
+ audit(id ? `model ${id}` : "model cleared");
154
+ sendJson(res, { ok: true, ...(await serverAgentStatus(d, deps.server)) });
155
+ return true;
156
+ }
157
+ if (verb === "runtime") {
158
+ const id = (b.runtime as string | null | undefined) ?? null;
159
+ setServerRuntime(id, deps.server);
160
+ audit(id ? `runtime ${id}` : "runtime cleared");
161
+ sendJson(res, { ok: true, ...(await serverAgentStatus(d, deps.server)) });
162
+ return true;
163
+ }
164
+ const s = await startServerAgent(
165
+ d,
166
+ typeof b.runtime === "string" ? { runtime: b.runtime } : {},
167
+ deps.server,
168
+ );
169
+ audit(`${s.how} on ${s.runtime}`);
170
+ sendJson(res, { ok: true, ...s });
171
+ } catch (e) {
172
+ audit(verb, (e as Error).message);
173
+ sendError(res, e);
174
+ }
175
+ return true;
176
+ }
@@ -35,6 +35,10 @@
35
35
  * with the actor from the credential.
36
36
  */
37
37
  import type * as http from "http";
38
+ import {
39
+ SERVER_SCOPE,
40
+ isServerScope,
41
+ } from "../../shared/agents/server-agent.js";
38
42
  import type { Duplex } from "stream";
39
43
  import {
40
44
  getActiveWorkspaces,
@@ -136,6 +140,8 @@ export interface ListedSession {
136
140
  /** `<dist>-<ws>:<name>`. */
137
141
  name: string;
138
142
  workspace: string;
143
+ /** ON-6: the server agent's row (its workspace is the server scope). */
144
+ server?: boolean;
139
145
  kind: "agent" | "session";
140
146
  /** The `<name>` part: an agent's id, a plain session's name. */
141
147
  label: string;
@@ -204,6 +210,8 @@ export function listedSessions(
204
210
  const dist = d.dist();
205
211
  const active = new Set(d.activeWorkspaces());
206
212
  if (also) active.add(also);
213
+ // ON-6: the server agent belongs to no workspace, and is always listed.
214
+ active.add(SERVER_SCOPE);
207
215
  const mine = sessions.filter((s) => {
208
216
  const p = parseSessionName(s.name, dist);
209
217
  return p !== null && active.has(p.workspace);
@@ -224,6 +232,7 @@ export function listedSessions(
224
232
  id: s?.ref.id ?? rec.session.id,
225
233
  name: s?.name ?? rec.sessionName,
226
234
  workspace: rec.workspace,
235
+ ...(isServerScope(rec.workspace) ? { server: true } : {}),
227
236
  kind: "agent",
228
237
  label: rec.id,
229
238
  agent: rec.id,
@@ -376,7 +385,9 @@ function servedSession(
376
385
  const p = parseSessionName(s.name, d.dist());
377
386
  return (
378
387
  p !== null &&
379
- (active.has(p.workspace) || d.workspaceExists(p.workspace)) &&
388
+ (active.has(p.workspace) ||
389
+ isServerScope(p.workspace) ||
390
+ d.workspaceExists(p.workspace)) &&
380
391
  (s.ref.id === target || s.name === target)
381
392
  );
382
393
  }
@@ -48,6 +48,8 @@ export const UI_MODULES: readonly string[] = [
48
48
  "/i18n/es.js",
49
49
  // 0.4.17: one org and workspace rule for Environments and Releases.
50
50
  "/view-scope.js",
51
+ // ON-6: the server agent's page.
52
+ "/server-agent-page.js",
51
53
  // 0.4.18: the service panel (rail pins, Graph ⋯, Services names).
52
54
  "/svc-panel.js",
53
55
  // 0.4.18: where a service's live log comes from (Logs page and panel).
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
10
+ import { reservedWorkspaceError } from "../../shared/agents/server-agent.js";
10
11
  import { loadApplicationTree } from "../../shared/environment-manager/load-applications.js";
11
12
  import { localCommandsFor } from "../../shared/stacks/manifest-commands.js";
12
13
  import * as http from "http";
@@ -358,6 +359,8 @@ export function workspaceCreateRefusal(
358
359
  if (/["'\\/<>\x00-\x1f]/.test(name))
359
360
  return "name must not contain quotes, slashes, or control characters";
360
361
  if (name.length > 64) return "name must be 64 characters or fewer";
362
+ const reserved = reservedWorkspaceError(name);
363
+ if (reserved) return reserved;
361
364
  if (readWorkspacesFile().workspaces[name])
362
365
  return `workspace "${name}" already exists`;
363
366
  return null;