@monster-orange/den 0.4.18 → 0.4.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/README.md +14 -13
- package/docs/distributions.md +99 -24
- package/docs/reference.md +1 -1
- package/package.json +2 -1
- package/skills/install-den/SKILL.md +35 -75
- package/src/shared/agents/den-claude.ts +40 -6
- package/src/shared/agents/guardrails/stop-hook.ts +30 -4
- package/src/shared/agents/guardrails.ts +40 -4
- package/src/shared/agents/handback.ts +22 -4
- package/src/shared/agents/personas.ts +32 -5
- package/src/shared/agents/runtime.ts +8 -0
- package/src/shared/agents/server-agent.ts +57 -0
- package/src/shared/agents/server-pack/server.md +45 -0
- package/src/shared/environment-manager/workspaces.ts +4 -0
- package/src/ui/cli/agent-ops.ts +41 -6
- package/src/ui/cli/create.ts +2 -2
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/repo-command.ts +8 -1
- package/src/ui/cli/repo-detect.ts +58 -0
- package/src/ui/cli/server-agent-ops.ts +411 -0
- package/src/ui/mcp/server.ts +29 -12
- package/src/ui/mcp/tools.ts +4 -2
- package/src/ui/server/agent-routes.ts +1 -1
- package/src/ui/server/held-actions.ts +35 -7
- package/src/ui/server/index.ts +20 -5
- package/src/ui/server/public/agents-page.js +10 -1
- package/src/ui/server/public/error-page.js +39 -1
- package/src/ui/server/public/graph.css +21 -0
- package/src/ui/server/public/graph.js +49 -6
- package/src/ui/server/public/header.js +29 -7
- package/src/ui/server/public/i18n/es.js +37 -0
- package/src/ui/server/public/i18n/ja.js +39 -0
- package/src/ui/server/public/local-dev.html +22 -1
- package/src/ui/server/public/persona-ui.css +6 -0
- package/src/ui/server/public/server-agent-page.js +415 -0
- package/src/ui/server/public/server-agent.html +222 -0
- package/src/ui/server/public/shell.js +66 -6
- package/src/ui/server/public/svc-panel.js +27 -14
- package/src/ui/server/public/topbar.js +13 -1
- package/src/ui/server/route-classes.ts +21 -0
- package/src/ui/server/server-agent-routes.ts +176 -0
- package/src/ui/server/session-routes.ts +12 -1
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +3 -0
|
@@ -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",
|
|
@@ -691,6 +697,8 @@
|
|
|
691
697
|
"ap.do.repo_add.new":
|
|
692
698
|
'Add the repo at {path} to a new workspace "{workspace}"',
|
|
693
699
|
"ap.do.service_try": "Start {service}",
|
|
700
|
+
"ap.do.service_try.activates":
|
|
701
|
+
"Start {service} (this activates {workspace})",
|
|
694
702
|
"ap.says": "Agent says:",
|
|
695
703
|
"ap.review": "Review",
|
|
696
704
|
"ap.hide": "Hide details",
|
|
@@ -833,6 +841,34 @@
|
|
|
833
841
|
"ag.ws": "Workspace",
|
|
834
842
|
"ag.pal": "Agents › {ws} / {name}",
|
|
835
843
|
"ag.palStart": "Start agent…",
|
|
844
|
+
// ON-6: the server agent (server-agent-page.js, the rail, ⌘K).
|
|
845
|
+
"n.serverAgent": "__DEN_TITLE__ agent",
|
|
846
|
+
"ag.server": "Server",
|
|
847
|
+
"pal.askServer": "Ask __DEN_TITLE__",
|
|
848
|
+
"sa.sub.fresh":
|
|
849
|
+
"Let's set up __DEN_TITLE__. It reads only until you allow a write.",
|
|
850
|
+
"sa.sub":
|
|
851
|
+
"For anything about this server: add a repo, fix a failing service, change a setting, or explain what's running.",
|
|
852
|
+
"sa.login":
|
|
853
|
+
"If Claude asks you to log in, log in here. Then {name} starts.",
|
|
854
|
+
"sa.trust": "Claude asks whether to trust this folder. Choose Yes.",
|
|
855
|
+
"sa.model": "Model",
|
|
856
|
+
"sa.readsOnly":
|
|
857
|
+
"It acts only through __DEN_TITLE__'s own tools. Each change asks you first, in this browser.",
|
|
858
|
+
"sa.starting": "Starting the agent…",
|
|
859
|
+
"sa.idle": "{name} isn't running.",
|
|
860
|
+
"sa.start": "Start",
|
|
861
|
+
"sa.failed": "It didn't start: {why}. {cli} agents doctor says more.",
|
|
862
|
+
"sa.unread": "Couldn't read the agent's status. Is the web server running?",
|
|
863
|
+
"sa.none.h": "No runtime can run the agent yet",
|
|
864
|
+
"sa.none":
|
|
865
|
+
"The agent runs on an agent CLI from the runtime catalog. Install one, log in to it once, then reload this page.",
|
|
866
|
+
"sa.noTools": "can't use __DEN_TITLE__'s tools yet",
|
|
867
|
+
"sa.manual.h": "Or set it up yourself",
|
|
868
|
+
"sa.manual": "Every step the agent takes is also a command. In a terminal:",
|
|
869
|
+
"sa.runsOn": "Runs on",
|
|
870
|
+
"sa.openAgents": "Open in Agents",
|
|
871
|
+
"sa.saved": "Saved. The agent uses it from its next start.",
|
|
836
872
|
"ag.cap": "Up to 4 panes: {id}'s closed to make room (it keeps running)",
|
|
837
873
|
// AG-4b: the Agents tab's layouts and named tabs.
|
|
838
874
|
"ag.layout": "Layout",
|
|
@@ -953,6 +989,9 @@
|
|
|
953
989
|
agents:
|
|
954
990
|
'<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 10l3 2-3 2M12 15h5"/>',
|
|
955
991
|
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"/>',
|
|
992
|
+
// ON-6: the server agent, a speech bubble with a spark.
|
|
993
|
+
assistant:
|
|
994
|
+
'<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
995
|
// SEC-2a: who this browser is (the rail footer's identity chip).
|
|
957
996
|
person:
|
|
958
997
|
'<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 +1852,16 @@
|
|
|
1813
1852
|
href: "/agents",
|
|
1814
1853
|
keywords: ["sessions", "terminal"],
|
|
1815
1854
|
},
|
|
1855
|
+
// ON-6: the server agent, this Den's own: one click away, after Agents.
|
|
1856
|
+
{
|
|
1857
|
+
id: "server-agent",
|
|
1858
|
+
label: "n.serverAgent",
|
|
1859
|
+
icon: "assistant",
|
|
1860
|
+
group: "develop",
|
|
1861
|
+
order: 18,
|
|
1862
|
+
href: "/server-agent",
|
|
1863
|
+
keywords: ["server agent", "ask", "setup", "onboarding"],
|
|
1864
|
+
},
|
|
1816
1865
|
{
|
|
1817
1866
|
id: "services",
|
|
1818
1867
|
label: "n.services",
|
|
@@ -1966,11 +2015,16 @@
|
|
|
1966
2015
|
"</div>" +
|
|
1967
2016
|
chips +
|
|
1968
2017
|
"</header>" +
|
|
2018
|
+
// The rail's box: no box of its own (display: contents). As ☰'s
|
|
2019
|
+
// drawer it is the modal dialog the rail sits in, so the rail stays a
|
|
2020
|
+
// nav (a <nav> may not be a dialog).
|
|
2021
|
+
'<div class="den-rail-box" id="den-rail-box">' +
|
|
1969
2022
|
'<nav class="den-rail" id="den-rail" aria-label="' +
|
|
1970
2023
|
esc(t("pages")) +
|
|
1971
|
-
'"></nav>';
|
|
2024
|
+
'"></nav></div>';
|
|
1972
2025
|
|
|
1973
2026
|
var nav = host.querySelector(".den-rail");
|
|
2027
|
+
var railBox = host.querySelector(".den-rail-box");
|
|
1974
2028
|
|
|
1975
2029
|
// ── the scope parts (R4) ───────────────────────────────────────────────
|
|
1976
2030
|
// Each part's menu comes from its model, set by header.js: setOrgs (the
|
|
@@ -2593,8 +2647,9 @@
|
|
|
2593
2647
|
function openDrawer() {
|
|
2594
2648
|
document.body.classList.add("den-rail-open");
|
|
2595
2649
|
if (menu) menu.setAttribute("aria-expanded", "true");
|
|
2596
|
-
|
|
2597
|
-
|
|
2650
|
+
railBox.setAttribute("role", "dialog");
|
|
2651
|
+
railBox.setAttribute("aria-modal", "true");
|
|
2652
|
+
railBox.setAttribute("aria-label", t("pages"));
|
|
2598
2653
|
if (!scrim) {
|
|
2599
2654
|
scrim = document.createElement("div");
|
|
2600
2655
|
scrim.className = "den-scrim";
|
|
@@ -2614,8 +2669,9 @@
|
|
|
2614
2669
|
if (!drawerOpen()) return;
|
|
2615
2670
|
document.body.classList.remove("den-rail-open");
|
|
2616
2671
|
if (menu) menu.setAttribute("aria-expanded", "false");
|
|
2617
|
-
|
|
2618
|
-
|
|
2672
|
+
railBox.removeAttribute("role");
|
|
2673
|
+
railBox.removeAttribute("aria-modal");
|
|
2674
|
+
railBox.removeAttribute("aria-label");
|
|
2619
2675
|
if (scrim && scrim.remove) scrim.remove();
|
|
2620
2676
|
setInert(false);
|
|
2621
2677
|
if (refocus && menu) menu.focus();
|
|
@@ -4334,7 +4390,10 @@
|
|
|
4334
4390
|
}
|
|
4335
4391
|
function apWhat(a) {
|
|
4336
4392
|
var p = a.params || {};
|
|
4337
|
-
var key =
|
|
4393
|
+
var key =
|
|
4394
|
+
"ap.do." +
|
|
4395
|
+
a.action +
|
|
4396
|
+
(p.isNew ? ".new" : p.activates ? ".activates" : "");
|
|
4338
4397
|
var s = t(key, p);
|
|
4339
4398
|
if (s !== key) return s;
|
|
4340
4399
|
s = String(a.summary || a.action);
|
|
@@ -4966,6 +5025,7 @@
|
|
|
4966
5025
|
// ◐ ▾ (the prototype's user menu, an .ib: appearance.js)
|
|
4967
5026
|
".den-top .den-ib[aria-expanded='true'] { background: var(--sunken); color: var(--text); }",
|
|
4968
5027
|
// Rail (.rail)
|
|
5028
|
+
".den-rail-box { display: contents; }",
|
|
4969
5029
|
".den-rail { position: fixed; left: 0; top: var(--den-top-h); bottom: 0; width: var(--den-rail-cw);",
|
|
4970
5030
|
" box-sizing: content-box; display: flex; flex-direction: column; padding: 10px 8px;",
|
|
4971
5031
|
" 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
|
|
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
|
|
95
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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) ||
|
|
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;
|