@monster-orange/den 0.4.0 → 0.4.1
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 +409 -0
- package/docs/distributions.md +204 -14
- package/package.json +3 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/den-claude.ts +99 -1
- package/src/shared/agents/runtime.ts +7 -0
- package/src/shared/agents/runtimes.ts +9 -2
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/profile.ts +15 -4
- package/src/shared/sessions/client.ts +9 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +23 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +96 -614
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +94 -10
- package/src/ui/cli/sessions-command.ts +32 -0
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +536 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +323 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +814 -66
- package/src/ui/server/public/agents-page.js +648 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- package/src/ui/server/workspace-routes.ts +13 -7
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agents tab (AG-4a-term, AG spec §5d): every session and agent of
|
|
3
|
+
* the workspaces in view, grouped by workspace (the scope bar, or the
|
|
4
|
+
* page's ?ws=), and up to four terminal panes beside the list.
|
|
5
|
+
*
|
|
6
|
+
* A pane is xterm.js (served from node_modules, /vendor/*) on a WebSocket
|
|
7
|
+
* to /api/sessions/<id>/io: the session host's attach, so the pane gets
|
|
8
|
+
* the scrollback first, then the live output; keystrokes go to the
|
|
9
|
+
* session and the pane's size follows the pane. Closing a pane (or
|
|
10
|
+
* leaving the page) detaches: the session keeps running. Stop (an agent
|
|
11
|
+
* only, behind the lock) is the Workspaces page's own agent route.
|
|
12
|
+
*
|
|
13
|
+
* The open panes are the URL (?open=<id>,<id>), so a reload, Back or a
|
|
14
|
+
* link reopens them, each reattaching with its scrollback.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here keeps a session's state: the list is GET /api/sessions,
|
|
17
|
+
* asked again every few seconds while the page is shown.
|
|
18
|
+
*/
|
|
19
|
+
import { requireUnlock } from "/topbar.js";
|
|
20
|
+
|
|
21
|
+
const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
|
|
22
|
+
const esc = (s) =>
|
|
23
|
+
String(s ?? "").replace(
|
|
24
|
+
/[&<>"']/g,
|
|
25
|
+
(c) =>
|
|
26
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
27
|
+
c
|
|
28
|
+
],
|
|
29
|
+
);
|
|
30
|
+
const api = (method, url, body) => window.denShell.api(method, url, body);
|
|
31
|
+
const CLI = "__DEN_CLI__";
|
|
32
|
+
|
|
33
|
+
/** The most panes at once (the prototype's 2x2). */
|
|
34
|
+
export const MAX_PANES = 4;
|
|
35
|
+
const REFRESH_MS = 5_000;
|
|
36
|
+
|
|
37
|
+
const st = {
|
|
38
|
+
root: null,
|
|
39
|
+
scope: "all",
|
|
40
|
+
scopeLabel: "",
|
|
41
|
+
data: null,
|
|
42
|
+
signedOut: false,
|
|
43
|
+
open: [],
|
|
44
|
+
panes: new Map(),
|
|
45
|
+
timer: null,
|
|
46
|
+
xterm: null,
|
|
47
|
+
csrf: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ── the URL ─────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** The ids ?open= names (at most MAX_PANES, in order, no repeats). */
|
|
53
|
+
export function openFromUrl(search) {
|
|
54
|
+
const raw = new URLSearchParams(search).get("open") ?? "";
|
|
55
|
+
const ids = [];
|
|
56
|
+
for (const id of raw.split(",").map((s) => s.trim())) {
|
|
57
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
58
|
+
}
|
|
59
|
+
return ids.slice(-MAX_PANES);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeUrl() {
|
|
63
|
+
const u = new URL(location.href);
|
|
64
|
+
if (st.open.length) u.searchParams.set("open", st.open.join(","));
|
|
65
|
+
else u.searchParams.delete("open");
|
|
66
|
+
u.searchParams.delete("start");
|
|
67
|
+
const s = u.searchParams.toString().replace(/%2C/gi, ",");
|
|
68
|
+
history.replaceState(history.state, "", u.pathname + (s ? `?${s}` : ""));
|
|
69
|
+
paintSub();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── the list ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** The workspaces to show: ?ws= (when active), else the scope bar's view. */
|
|
75
|
+
function viewWorkspaces() {
|
|
76
|
+
const all = st.data?.workspaces ?? [];
|
|
77
|
+
const ws = new URLSearchParams(location.search).get("ws");
|
|
78
|
+
if (ws && all.includes(ws)) return [ws];
|
|
79
|
+
if (st.scope && st.scope !== "all" && all.includes(st.scope))
|
|
80
|
+
return [st.scope];
|
|
81
|
+
return all;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stateTone(r) {
|
|
85
|
+
// The shell's dot: plain is the good one (the repo table's look).
|
|
86
|
+
if (r.state === "running") return "";
|
|
87
|
+
if (r.state === "stopping") return "pending";
|
|
88
|
+
if (/^exited \((?!0\))/.test(r.state)) return "bad";
|
|
89
|
+
return "off";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stateText(state) {
|
|
93
|
+
const m = /^exited(?: \((-?\d+)\))?$/.exec(state);
|
|
94
|
+
if (m)
|
|
95
|
+
return m[1] === undefined
|
|
96
|
+
? t("ag.state.exited")
|
|
97
|
+
: t("ag.state.exitedCode", { code: m[1] });
|
|
98
|
+
return ["running", "stopping", "stopped", "ended"].includes(state)
|
|
99
|
+
? t(`ag.state.${state}`)
|
|
100
|
+
: state;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function displayName(r) {
|
|
104
|
+
return r.kind === "agent" ? (r.persona ?? r.label) : r.label;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rowHtml(r) {
|
|
108
|
+
const open = st.open.includes(r.id);
|
|
109
|
+
const nm =
|
|
110
|
+
r.kind === "agent"
|
|
111
|
+
? `${esc(displayName(r))}${r.persona && r.persona !== r.label ? ` <span class="id">${esc(r.label)}</span>` : ""}`
|
|
112
|
+
: `${esc(r.label)} <span class="sbdg">${esc(t("ag.session"))}</span>`;
|
|
113
|
+
const right = r.waiting
|
|
114
|
+
? `<span class="wbdg">${esc(t("ag.waiting"))}</span>`
|
|
115
|
+
: `<span class="st">${esc(stateText(r.state))}</span>`;
|
|
116
|
+
const meta = [r.runtime, r.cwd].filter(Boolean).join(" · ");
|
|
117
|
+
const tags = r.tokenRevoked
|
|
118
|
+
? `<span class="agtags"><span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span></span>`
|
|
119
|
+
: "";
|
|
120
|
+
return (
|
|
121
|
+
`<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="${esc(r.kind)}" data-state="${esc(r.state)}"` +
|
|
122
|
+
` aria-pressed="${open}"${r.attachable ? "" : ' aria-disabled="true"'}` +
|
|
123
|
+
` aria-label="${esc(t("ag.openName", { id: `${r.workspace} / ${displayName(r)}` }))}">` +
|
|
124
|
+
`<span class="den-dot ${stateTone(r)}" aria-hidden="true"></span>` +
|
|
125
|
+
`<span class="nm">${nm}</span>${right}` +
|
|
126
|
+
(meta ? `<span class="meta">${esc(meta)}</span>` : "") +
|
|
127
|
+
tags +
|
|
128
|
+
`</button>`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function paintList() {
|
|
133
|
+
const el = st.root.querySelector("#agl");
|
|
134
|
+
el.removeAttribute("aria-busy");
|
|
135
|
+
const notes = [];
|
|
136
|
+
if (st.signedOut || st.data?.io?.allowed === false)
|
|
137
|
+
notes.push(
|
|
138
|
+
`<div class="signin" data-note="signin">${esc(t("ag.signin", { cli: CLI }))}</div>`,
|
|
139
|
+
);
|
|
140
|
+
else if (st.data && st.data.backend?.available === false)
|
|
141
|
+
notes.push(
|
|
142
|
+
`<div class="agnote" data-note="backend">${esc(t("ag.noBackend", { cli: CLI }))}</div>`,
|
|
143
|
+
);
|
|
144
|
+
if (!st.data) {
|
|
145
|
+
el.innerHTML = notes.join("");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const shown = viewWorkspaces();
|
|
149
|
+
const groups = shown
|
|
150
|
+
.map((ws) => ({
|
|
151
|
+
ws,
|
|
152
|
+
rows: st.data.sessions.filter((s) => s.workspace === ws),
|
|
153
|
+
}))
|
|
154
|
+
.filter((g) => g.rows.length > 0);
|
|
155
|
+
const body = groups.length
|
|
156
|
+
? groups
|
|
157
|
+
.map(
|
|
158
|
+
(g) =>
|
|
159
|
+
`<div class="wsg" data-ws="${esc(g.ws)}"><div class="wsh"><span>${esc(g.ws)}</span><span>${esc(g.rows.length)}</span></div>` +
|
|
160
|
+
g.rows.map(rowHtml).join("") +
|
|
161
|
+
`</div>`,
|
|
162
|
+
)
|
|
163
|
+
.join("")
|
|
164
|
+
: `<div class="none">${esc(t("ag.none.scope", { scope: st.scopeLabel }))}</div>`;
|
|
165
|
+
el.innerHTML = notes.join("") + body;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function visibleRows() {
|
|
169
|
+
const shown = new Set(viewWorkspaces());
|
|
170
|
+
return (st.data?.sessions ?? []).filter((s) => shown.has(s.workspace));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function paintSub() {
|
|
174
|
+
const rows = visibleRows();
|
|
175
|
+
const waiting = rows.filter((r) => r.waiting).length;
|
|
176
|
+
const n = rows.length;
|
|
177
|
+
const count = st.root.querySelector("#ag-count");
|
|
178
|
+
if (count)
|
|
179
|
+
count.textContent = t(n === 1 ? "ag.sub.one" : "ag.sub", {
|
|
180
|
+
n,
|
|
181
|
+
w: waiting,
|
|
182
|
+
scope: st.scopeLabel,
|
|
183
|
+
});
|
|
184
|
+
const url = st.root.querySelector("#ag-url");
|
|
185
|
+
if (url) url.textContent = location.pathname + location.search;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function loadScope() {
|
|
189
|
+
try {
|
|
190
|
+
const r = await fetch("/api/scope");
|
|
191
|
+
const s = r.ok ? await r.json() : null;
|
|
192
|
+
st.scope = s?.scope ?? "all";
|
|
193
|
+
} catch {
|
|
194
|
+
st.scope = "all";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function scopeLabelFor(shown) {
|
|
199
|
+
const ws = new URLSearchParams(location.search).get("ws");
|
|
200
|
+
if (ws && shown.length === 1 && shown[0] === ws) return ws;
|
|
201
|
+
return st.scope === "all" ? t("ag.all") : st.scope;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function refresh() {
|
|
205
|
+
clearTimeout(st.timer);
|
|
206
|
+
try {
|
|
207
|
+
const r = await fetch("/api/sessions", { headers: { accept: "json" } });
|
|
208
|
+
if (r.status === 401) {
|
|
209
|
+
st.signedOut = true;
|
|
210
|
+
} else if (r.ok) {
|
|
211
|
+
st.data = await r.json();
|
|
212
|
+
st.signedOut = false;
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
/* the last list stays; the next ask may answer */
|
|
216
|
+
}
|
|
217
|
+
st.scopeLabel = scopeLabelFor(viewWorkspaces());
|
|
218
|
+
paintList();
|
|
219
|
+
paintSub();
|
|
220
|
+
for (const p of st.panes.values()) paintBar(p);
|
|
221
|
+
if (!st.root.isConnected) return;
|
|
222
|
+
st.timer = setTimeout(() => {
|
|
223
|
+
if (!document.hidden) void refresh();
|
|
224
|
+
else st.timer = setTimeout(refresh, REFRESH_MS);
|
|
225
|
+
}, REFRESH_MS);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const rowOf = (id) => st.data?.sessions?.find((s) => s.id === id) ?? null;
|
|
229
|
+
|
|
230
|
+
// ── the panes ───────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
async function loadXterm() {
|
|
233
|
+
st.xterm ??= Promise.all([
|
|
234
|
+
import("/vendor/xterm.mjs"),
|
|
235
|
+
import("/vendor/addon-fit.mjs"),
|
|
236
|
+
]).then(([x, f]) => ({ Terminal: x.Terminal, FitAddon: f.FitAddon }));
|
|
237
|
+
return st.xterm;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function csrf(fresh) {
|
|
241
|
+
if (st.csrf && !fresh) return st.csrf;
|
|
242
|
+
try {
|
|
243
|
+
const r = await fetch("/api/session", { credentials: "same-origin" });
|
|
244
|
+
const b = r.ok ? await r.json() : null;
|
|
245
|
+
st.csrf = typeof b?.csrf === "string" ? b.csrf : null;
|
|
246
|
+
} catch {
|
|
247
|
+
st.csrf = null;
|
|
248
|
+
}
|
|
249
|
+
return st.csrf;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function cssVar(name) {
|
|
253
|
+
const v = getComputedStyle(document.documentElement)
|
|
254
|
+
.getPropertyValue(name)
|
|
255
|
+
.trim();
|
|
256
|
+
return v || undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The terminal's colours, from the theme's tokens (xterm's own when unset). */
|
|
260
|
+
function termTheme() {
|
|
261
|
+
const bg = cssVar("--sunken");
|
|
262
|
+
const fg = cssVar("--text");
|
|
263
|
+
const theme = {
|
|
264
|
+
background: bg,
|
|
265
|
+
foreground: fg,
|
|
266
|
+
cursor: cssVar("--accent") ?? fg,
|
|
267
|
+
cursorAccent: bg,
|
|
268
|
+
selectionBackground: cssVar("--border-strong"),
|
|
269
|
+
};
|
|
270
|
+
for (const k of Object.keys(theme))
|
|
271
|
+
if (theme[k] === undefined) delete theme[k];
|
|
272
|
+
return theme;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function paintLayout() {
|
|
276
|
+
const box = st.root.querySelector("#agr");
|
|
277
|
+
const n = st.open.length;
|
|
278
|
+
if (n === 0) {
|
|
279
|
+
box.className = "agempty";
|
|
280
|
+
box.innerHTML = `<div>${esc(t("ag.empty"))}</div>`;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
box.className = `agp n${n}`;
|
|
284
|
+
const empty = box.querySelector(":scope > div:not(.pane)");
|
|
285
|
+
if (empty) empty.remove();
|
|
286
|
+
// Panes in the URL's order.
|
|
287
|
+
for (const id of st.open) {
|
|
288
|
+
const p = st.panes.get(id);
|
|
289
|
+
if (p) box.appendChild(p.el);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function paintBar(p) {
|
|
294
|
+
const r = rowOf(p.id);
|
|
295
|
+
const bar = p.el.querySelector(".bar2");
|
|
296
|
+
const name = r ? displayName(r) : p.label || p.id;
|
|
297
|
+
const where = r
|
|
298
|
+
? [r.workspace, r.runtime].filter(Boolean).join(" · ")
|
|
299
|
+
: p.workspace || "";
|
|
300
|
+
const tone = r ? stateTone(r) : p.exited ? "off" : "pending";
|
|
301
|
+
const stop =
|
|
302
|
+
r?.kind === "agent" && (r.state === "running" || r.state === "stopping")
|
|
303
|
+
? `<button type="button" class="den-btn sm" data-act="stop" aria-label="${esc(t("ag.stopName", { id: r.agent }))}">${esc(t("ag.stop"))}</button>`
|
|
304
|
+
: "";
|
|
305
|
+
const reconnect = p.lost
|
|
306
|
+
? `<button type="button" class="den-btn sm" data-act="reconnect">${esc(t("ag.reconnect"))}</button>`
|
|
307
|
+
: "";
|
|
308
|
+
bar.innerHTML =
|
|
309
|
+
`<span class="den-dot ${tone}" aria-hidden="true"></span>` +
|
|
310
|
+
`<b>${esc(name)}</b><span class="where mono">${esc(where)}</span>` +
|
|
311
|
+
(r?.tokenRevoked
|
|
312
|
+
? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
|
|
313
|
+
: "") +
|
|
314
|
+
`<span class="x">${reconnect}${stop}<button type="button" class="den-btn sm" data-act="close" aria-label="${esc(t("ag.detachName", { id: name }))}" title="${esc(t("ag.detach"))}">×</button></span>`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function send(p, obj) {
|
|
318
|
+
if (p.sock?.readyState === WebSocket.OPEN) p.sock.send(JSON.stringify(obj));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function connect(p) {
|
|
322
|
+
p.lost = false;
|
|
323
|
+
paintBar(p);
|
|
324
|
+
const token = await csrf(false);
|
|
325
|
+
if (p.closed) return;
|
|
326
|
+
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
327
|
+
const url = `${proto}//${location.host}/api/sessions/${encodeURIComponent(p.id)}/io`;
|
|
328
|
+
let sock;
|
|
329
|
+
try {
|
|
330
|
+
sock = new WebSocket(url, ["den.io", `den.csrf.${token ?? ""}`]);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
p.term?.write(`\r\n${t("ag.lost", { why: err?.message ?? "error" })}\r\n`);
|
|
333
|
+
p.lost = true;
|
|
334
|
+
paintBar(p);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
sock.binaryType = "arraybuffer";
|
|
338
|
+
p.sock = sock;
|
|
339
|
+
let opened = false;
|
|
340
|
+
sock.onopen = () => {
|
|
341
|
+
opened = true;
|
|
342
|
+
if (p.term)
|
|
343
|
+
send(p, { type: "resize", cols: p.term.cols, rows: p.term.rows });
|
|
344
|
+
};
|
|
345
|
+
sock.onmessage = (e) => {
|
|
346
|
+
if (typeof e.data === "string") {
|
|
347
|
+
let msg = null;
|
|
348
|
+
try {
|
|
349
|
+
msg = JSON.parse(e.data);
|
|
350
|
+
} catch {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (msg?.type === "session") {
|
|
354
|
+
p.label = msg.label;
|
|
355
|
+
p.workspace = msg.workspace;
|
|
356
|
+
p.exited = !!msg.exited;
|
|
357
|
+
paintBar(p);
|
|
358
|
+
} else if (msg?.type === "exit") {
|
|
359
|
+
p.exited = true;
|
|
360
|
+
const code =
|
|
361
|
+
typeof msg.exitCode === "number" ? ` (${msg.exitCode})` : "";
|
|
362
|
+
p.term?.write(`\r\n${t("ag.exitedLine", { code })}\r\n`);
|
|
363
|
+
void refresh();
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
p.term?.write(new Uint8Array(e.data));
|
|
368
|
+
};
|
|
369
|
+
sock.onclose = (e) => {
|
|
370
|
+
if (p.sock !== sock) return;
|
|
371
|
+
p.sock = null;
|
|
372
|
+
if (p.closed) return;
|
|
373
|
+
// Refused before it opened: beyond loopback without a session, the
|
|
374
|
+
// upgrade is 401 (a browser sees only the close).
|
|
375
|
+
if (!opened) void refresh();
|
|
376
|
+
const why =
|
|
377
|
+
e.reason || (opened ? String(e.code) : t("ag.signin", { cli: CLI }));
|
|
378
|
+
p.term?.write(`\r\n${t("ag.lost", { why })}\r\n`);
|
|
379
|
+
p.lost = true;
|
|
380
|
+
paintBar(p);
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function openPane(id) {
|
|
385
|
+
if (st.panes.has(id)) {
|
|
386
|
+
st.panes.get(id).term?.focus();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (st.open.length >= MAX_PANES) {
|
|
390
|
+
const oldest = st.open[0];
|
|
391
|
+
window.denShell?.toast?.(
|
|
392
|
+
t("ag.cap", { id: rowOf(oldest)?.label ?? oldest }),
|
|
393
|
+
);
|
|
394
|
+
closePane(oldest, { quiet: true });
|
|
395
|
+
}
|
|
396
|
+
const el = document.createElement("section");
|
|
397
|
+
el.className = "pane";
|
|
398
|
+
el.dataset.id = id;
|
|
399
|
+
el.innerHTML = `<div class="bar2"></div><div class="term"></div>`;
|
|
400
|
+
const p = { id, el, term: null, fit: null, sock: null, closed: false };
|
|
401
|
+
st.panes.set(id, p);
|
|
402
|
+
st.open.push(id);
|
|
403
|
+
paintBar(p);
|
|
404
|
+
paintLayout();
|
|
405
|
+
writeUrl();
|
|
406
|
+
paintList();
|
|
407
|
+
el.setAttribute("aria-label", rowOf(id)?.label ?? id);
|
|
408
|
+
let lib;
|
|
409
|
+
try {
|
|
410
|
+
lib = await loadXterm();
|
|
411
|
+
} catch (err) {
|
|
412
|
+
el.querySelector(".term").innerHTML =
|
|
413
|
+
`<p class="note">${esc(err?.message ?? String(err))}</p>`;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (p.closed) return;
|
|
417
|
+
const term = new lib.Terminal({
|
|
418
|
+
fontFamily: cssVar("--mono") ?? "monospace",
|
|
419
|
+
fontSize: 12,
|
|
420
|
+
lineHeight: 1.25,
|
|
421
|
+
cursorBlink: false,
|
|
422
|
+
scrollback: 5000,
|
|
423
|
+
theme: termTheme(),
|
|
424
|
+
allowProposedApi: false,
|
|
425
|
+
});
|
|
426
|
+
const fit = new lib.FitAddon();
|
|
427
|
+
term.loadAddon(fit);
|
|
428
|
+
term.open(el.querySelector(".term"));
|
|
429
|
+
p.term = term;
|
|
430
|
+
p.fit = fit;
|
|
431
|
+
const enc = new TextEncoder();
|
|
432
|
+
term.onData((d) => {
|
|
433
|
+
if (p.sock?.readyState === WebSocket.OPEN) p.sock.send(enc.encode(d));
|
|
434
|
+
});
|
|
435
|
+
term.onResize(({ cols, rows }) => send(p, { type: "resize", cols, rows }));
|
|
436
|
+
const refit = () => {
|
|
437
|
+
try {
|
|
438
|
+
if (el.isConnected && el.clientWidth > 0) fit.fit();
|
|
439
|
+
} catch {
|
|
440
|
+
/* not laid out yet */
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
p.ro = new ResizeObserver(() => refit());
|
|
444
|
+
p.ro.observe(el.querySelector(".term"));
|
|
445
|
+
refit();
|
|
446
|
+
void connect(p);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function closePane(id, { quiet = false } = {}) {
|
|
450
|
+
const p = st.panes.get(id);
|
|
451
|
+
if (!p) return;
|
|
452
|
+
p.closed = true;
|
|
453
|
+
st.panes.delete(id);
|
|
454
|
+
st.open = st.open.filter((x) => x !== id);
|
|
455
|
+
try {
|
|
456
|
+
p.sock?.close(1000, "closed");
|
|
457
|
+
} catch {
|
|
458
|
+
/* already closed */
|
|
459
|
+
}
|
|
460
|
+
p.ro?.disconnect();
|
|
461
|
+
p.term?.dispose();
|
|
462
|
+
p.el.remove();
|
|
463
|
+
if (!quiet)
|
|
464
|
+
window.denShell?.toast?.(
|
|
465
|
+
t("ag.detached", { id: rowOf(id)?.label ?? p.label ?? id }),
|
|
466
|
+
);
|
|
467
|
+
paintLayout();
|
|
468
|
+
writeUrl();
|
|
469
|
+
paintList();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function stopAgent(p, button) {
|
|
473
|
+
const r = rowOf(p.id);
|
|
474
|
+
if (!r?.agent || !requireUnlock()) return;
|
|
475
|
+
button.disabled = true;
|
|
476
|
+
try {
|
|
477
|
+
await api(
|
|
478
|
+
"POST",
|
|
479
|
+
`/api/workspaces/${encodeURIComponent(r.workspace)}/agents/${encodeURIComponent(r.agent)}/stop`,
|
|
480
|
+
);
|
|
481
|
+
window.denShell?.toast?.(t("ag.stopped", { id: r.agent }));
|
|
482
|
+
} catch (err) {
|
|
483
|
+
window.denShell?.errorToast?.(err);
|
|
484
|
+
}
|
|
485
|
+
await refresh();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Start agent ─────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
let personasAsked = null;
|
|
491
|
+
|
|
492
|
+
async function openStart() {
|
|
493
|
+
const dlg = st.root.querySelector("#ag-dlg");
|
|
494
|
+
if (!dlg || dlg.open) return;
|
|
495
|
+
const shown = viewWorkspaces();
|
|
496
|
+
const wsSel = st.root.querySelector("#ag-ws");
|
|
497
|
+
wsSel.innerHTML = shown
|
|
498
|
+
.map((w) => `<option value="${esc(w)}">${esc(w)}</option>`)
|
|
499
|
+
.join("");
|
|
500
|
+
st.root.querySelector("#ag-ws-l").hidden = shown.length <= 1;
|
|
501
|
+
personasAsked ??= api("GET", "/api/personas").catch((err) => {
|
|
502
|
+
personasAsked = null;
|
|
503
|
+
throw err;
|
|
504
|
+
});
|
|
505
|
+
let data;
|
|
506
|
+
try {
|
|
507
|
+
data = await personasAsked;
|
|
508
|
+
} catch (err) {
|
|
509
|
+
window.denShell?.errorToast?.(err);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
st.root.querySelector("#ag-persona").innerHTML = (data.personas ?? [])
|
|
513
|
+
.map(
|
|
514
|
+
(p) =>
|
|
515
|
+
`<option value="${esc(p.id)}">${esc(p.id)}${p.role ? ` — ${esc(p.role)}` : ""}</option>`,
|
|
516
|
+
)
|
|
517
|
+
.join("");
|
|
518
|
+
st.root.querySelector("#ag-runtime").innerHTML =
|
|
519
|
+
`<option value="">${esc(t("ag.runtime.auto"))}</option>` +
|
|
520
|
+
(data.runtimes ?? [])
|
|
521
|
+
.map(
|
|
522
|
+
(r) =>
|
|
523
|
+
`<option value="${esc(r.id)}"${r.available ? "" : " disabled"}>${esc(r.id)}${r.available ? "" : ` — ${esc(t("ag.unavailable"))}`}</option>`,
|
|
524
|
+
)
|
|
525
|
+
.join("");
|
|
526
|
+
if (typeof dlg.showModal === "function") dlg.showModal();
|
|
527
|
+
else dlg.setAttribute("open", "");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function submitStart(form) {
|
|
531
|
+
if (!requireUnlock()) return;
|
|
532
|
+
const dlg = st.root.querySelector("#ag-dlg");
|
|
533
|
+
const go = st.root.querySelector("#ag-go");
|
|
534
|
+
const ws = form.querySelector("#ag-ws").value || viewWorkspaces()[0];
|
|
535
|
+
const persona = form.querySelector("#ag-persona").value;
|
|
536
|
+
const runtime = form.querySelector("#ag-runtime").value;
|
|
537
|
+
if (!ws || !persona) return;
|
|
538
|
+
go.disabled = true;
|
|
539
|
+
go.textContent = t("ag.starting");
|
|
540
|
+
try {
|
|
541
|
+
const r = await api(
|
|
542
|
+
"POST",
|
|
543
|
+
`/api/workspaces/${encodeURIComponent(ws)}/agents`,
|
|
544
|
+
{
|
|
545
|
+
persona,
|
|
546
|
+
...(runtime ? { runtime } : {}),
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
window.denShell?.toast?.(t("ag.started", { id: r?.agent?.id ?? persona }));
|
|
550
|
+
dlg.close?.();
|
|
551
|
+
await refresh();
|
|
552
|
+
if (r?.agent?.sessionId) await openPane(r.agent.sessionId);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
window.denShell?.errorToast?.(err);
|
|
555
|
+
} finally {
|
|
556
|
+
go.disabled = false;
|
|
557
|
+
go.textContent = t("ag.go");
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ── mount ───────────────────────────────────────────────────────────────
|
|
562
|
+
|
|
563
|
+
function translateStatic(root) {
|
|
564
|
+
const set = (sel, key) => {
|
|
565
|
+
const el = root.querySelector(sel);
|
|
566
|
+
if (el) el.textContent = t(key);
|
|
567
|
+
};
|
|
568
|
+
set("#ag-h", "n.agents");
|
|
569
|
+
set("#ag-start", "ag.start");
|
|
570
|
+
set("#ag-dlg-h", "ag.start");
|
|
571
|
+
set("#ag-ws-t", "ag.ws");
|
|
572
|
+
set("#ag-p-t", "ag.persona");
|
|
573
|
+
set("#ag-r-t", "ag.runtime");
|
|
574
|
+
set("#ag-cancel", "ag.cancel");
|
|
575
|
+
set("#ag-go", "ag.go");
|
|
576
|
+
const h = root.querySelector("#ag-dlg-h");
|
|
577
|
+
if (h) h.textContent = h.textContent.replace(/^\+\s*/, "");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Fill the page drawn by agents.html and wire it. */
|
|
581
|
+
export async function mountAgentsPage(root) {
|
|
582
|
+
if (!root) return;
|
|
583
|
+
st.root = root;
|
|
584
|
+
translateStatic(root);
|
|
585
|
+
if (window.denShell?.apiOff?.()) return;
|
|
586
|
+
st.open = [];
|
|
587
|
+
const wanted = openFromUrl(location.search);
|
|
588
|
+
const startAsked = new URLSearchParams(location.search).get("start") === "1";
|
|
589
|
+
|
|
590
|
+
root.querySelector("#agl").addEventListener("click", (e) => {
|
|
591
|
+
const b = e.target.closest(".agr");
|
|
592
|
+
if (!b) return;
|
|
593
|
+
if (b.getAttribute("aria-disabled") === "true") return;
|
|
594
|
+
const id = b.dataset.id;
|
|
595
|
+
if (st.panes.has(id)) st.panes.get(id).term?.focus();
|
|
596
|
+
else void openPane(id);
|
|
597
|
+
});
|
|
598
|
+
root.querySelector("#agr").addEventListener("click", (e) => {
|
|
599
|
+
const b = e.target.closest("button[data-act]");
|
|
600
|
+
if (!b) return;
|
|
601
|
+
const id = b.closest(".pane")?.dataset.id;
|
|
602
|
+
const p = id && st.panes.get(id);
|
|
603
|
+
if (!p) return;
|
|
604
|
+
if (b.dataset.act === "close") closePane(id);
|
|
605
|
+
else if (b.dataset.act === "stop") void stopAgent(p, b);
|
|
606
|
+
else if (b.dataset.act === "reconnect") void connect(p);
|
|
607
|
+
});
|
|
608
|
+
root.querySelector("#ag-start").addEventListener("click", () => {
|
|
609
|
+
void openStart();
|
|
610
|
+
});
|
|
611
|
+
root.querySelector("#ag-cancel").addEventListener("click", () => {
|
|
612
|
+
root.querySelector("#ag-dlg").close?.();
|
|
613
|
+
});
|
|
614
|
+
root.querySelector("#ag-form").addEventListener("submit", (e) => {
|
|
615
|
+
e.preventDefault();
|
|
616
|
+
void submitStart(e.currentTarget);
|
|
617
|
+
});
|
|
618
|
+
// The terminals follow the mode (light, dark) and the theme.
|
|
619
|
+
new MutationObserver(() => {
|
|
620
|
+
for (const p of st.panes.values())
|
|
621
|
+
if (p.term) p.term.options.theme = termTheme();
|
|
622
|
+
}).observe(document.documentElement, {
|
|
623
|
+
attributes: true,
|
|
624
|
+
attributeFilter: ["data-mode", "data-theme", "class"],
|
|
625
|
+
});
|
|
626
|
+
window.addEventListener("pagehide", () => {
|
|
627
|
+
for (const p of st.panes.values()) {
|
|
628
|
+
p.closed = true;
|
|
629
|
+
try {
|
|
630
|
+
p.sock?.close(1000, "page closed");
|
|
631
|
+
} catch {
|
|
632
|
+
/* already closed */
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
await loadScope();
|
|
638
|
+
await refresh();
|
|
639
|
+
paintLayout();
|
|
640
|
+
// An id the list no longer has (removed, another distribution's) is
|
|
641
|
+
// dropped from the URL rather than opened onto a refusal.
|
|
642
|
+
if (!st.signedOut && st.data?.io?.allowed !== false)
|
|
643
|
+
for (const id of wanted) {
|
|
644
|
+
if (!st.data || rowOf(id)) await openPane(id);
|
|
645
|
+
}
|
|
646
|
+
writeUrl();
|
|
647
|
+
if (startAsked) void openStart();
|
|
648
|
+
}
|