@monster-orange/den 0.4.2 → 0.4.4
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 +273 -0
- package/README.md +9 -9
- package/docs/distributions.md +313 -50
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +53 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +543 -0
- package/src/shared/agents/runtime.ts +43 -1
- package/src/shared/agents/runtimes.ts +53 -4
- package/src/shared/auth/sudo-vault.ts +16 -0
- package/src/shared/den-config.ts +92 -2
- package/src/shared/edge/render.ts +31 -0
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/ui/cli/agent-ops.ts +298 -19
- package/src/ui/cli/agents-command.ts +129 -4
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/sessions-command.ts +9 -4
- package/src/ui/cli/web-command.ts +53 -8
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +123 -26
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +171 -25
- package/src/ui/server/public/agent-start.js +241 -0
- package/src/ui/server/public/agents-page.js +246 -45
- package/src/ui/server/public/agents-section.js +7 -21
- package/src/ui/server/public/agents.html +55 -1
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +26 -13
- package/src/ui/server/public/i18n/ja.js +25 -13
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +62 -22
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +3 -2
- package/src/ui/server/public/settings-registry.js +226 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +67 -22
- package/src/ui/server/public/topbar.js +217 -158
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +123 -8
- package/src/ui/server/route-classes.ts +14 -2
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Start agent form's choices (AG-10.0), shared by the Agents tab's
|
|
3
|
+
* dialog and the Workspaces page's agents section. DOM-free.
|
|
4
|
+
*
|
|
5
|
+
* A persona option shows its id only: its role sentence is the help line
|
|
6
|
+
* under the select (personaHelp), so a long role can't widen the select
|
|
7
|
+
* past the dialog. The runtime's first option names what it resolves to,
|
|
8
|
+
* "Claude Code (persona default)", from the persona picked; a runtime that
|
|
9
|
+
* can't run here says why, and one that can shows its version.
|
|
10
|
+
*
|
|
11
|
+
* AG-0.2, readiness before Start: the line under the runtime names the CLI
|
|
12
|
+
* it will run (its version and path); when that runtime can't run here,
|
|
13
|
+
* Start is disabled and the line says why and how to fix it (install it,
|
|
14
|
+
* or set agents.runtimes.<id>.bin in the user.yml it names, or run
|
|
15
|
+
* `<cli> agents doctor`). Nothing fails only after Start.
|
|
16
|
+
*
|
|
17
|
+
* AG-0.5: the runtimes come installed first (the server's order). One
|
|
18
|
+
* that runs as its own CLI, without Den's tools yet, says "Den tools:
|
|
19
|
+
* coming"; one not installed is disabled; generic is startable only for a
|
|
20
|
+
* persona with an argv template. Nothing is offered that can't start.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
function escHtml(s) {
|
|
24
|
+
return String(s ?? "").replace(
|
|
25
|
+
/[&<>"']/g,
|
|
26
|
+
(c) =>
|
|
27
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
28
|
+
c
|
|
29
|
+
],
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The persona select's options: the id only. */
|
|
34
|
+
export function personaOptions(personas) {
|
|
35
|
+
return (personas ?? [])
|
|
36
|
+
.map((p) => `<option value="${escHtml(p.id)}">${escHtml(p.id)}</option>`)
|
|
37
|
+
.join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The help line under the persona select: the picked persona's role. */
|
|
41
|
+
export function personaHelp(personas, id) {
|
|
42
|
+
return (personas ?? []).find((p) => p.id === id)?.role ?? "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A runtime's name for people ("Claude Code"); its id when it has none. */
|
|
46
|
+
export function runtimeName(runtimes, id) {
|
|
47
|
+
const r = (runtimes ?? []).find((x) => x.id === id);
|
|
48
|
+
return r?.label || r?.id || id || "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The runtime a persona runs on when the agent names none: the persona's
|
|
53
|
+
* own, else the default runtime.
|
|
54
|
+
*/
|
|
55
|
+
export function defaultRuntimeOf(personas, runtimes, personaId) {
|
|
56
|
+
const p = (personas ?? []).find((x) => x.id === personaId);
|
|
57
|
+
if (p?.runtime) return p.runtime;
|
|
58
|
+
return (runtimes ?? []).find((r) => r.default)?.id ?? "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A catalog runtime the search didn't find (not a path someone set). */
|
|
62
|
+
const notFound = (r) =>
|
|
63
|
+
!r.available &&
|
|
64
|
+
r.denTools !== undefined &&
|
|
65
|
+
r.source !== "setting" &&
|
|
66
|
+
r.source !== "workspace";
|
|
67
|
+
|
|
68
|
+
/** Why runtime `r` can't start for persona `personaId`, or "" when it can. */
|
|
69
|
+
export function cantStart(personas, r, personaId, t) {
|
|
70
|
+
if (!r.available) return r.why || t("ag.unavailable");
|
|
71
|
+
// generic runs the persona's own argv: none, nothing to run.
|
|
72
|
+
if (r.id === "generic") {
|
|
73
|
+
const p = (personas ?? []).find((x) => x.id === personaId);
|
|
74
|
+
if (!p?.argv?.length) return t("ag.rt.needsArgv");
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The runtime select's options. The first ("" = the persona's) reads
|
|
81
|
+
* "<name> (persona default)"; one that can't run here is disabled and
|
|
82
|
+
* says why; one without Den's tools yet says so.
|
|
83
|
+
*/
|
|
84
|
+
export function runtimeOptions(personas, runtimes, personaId, t) {
|
|
85
|
+
const name = runtimeName(
|
|
86
|
+
runtimes,
|
|
87
|
+
defaultRuntimeOf(personas, runtimes, personaId),
|
|
88
|
+
);
|
|
89
|
+
const first = name ? t("ag.runtime.default", { name }) : t("ag.runtime.auto");
|
|
90
|
+
return (
|
|
91
|
+
`<option value="">${escHtml(first)}</option>` +
|
|
92
|
+
(runtimes ?? [])
|
|
93
|
+
.map((r) => {
|
|
94
|
+
const label = runtimeName(runtimes, r.id);
|
|
95
|
+
const no = cantStart(personas, r, personaId, t);
|
|
96
|
+
const parts = [
|
|
97
|
+
label,
|
|
98
|
+
r.version,
|
|
99
|
+
r.denTools === false ? t("ag.rt.denTools") : "",
|
|
100
|
+
];
|
|
101
|
+
return no
|
|
102
|
+
? `<option value="${escHtml(r.id)}" disabled>${escHtml(
|
|
103
|
+
`${label} — ${notFound(r) ? t("ag.rt.notInstalled") : no}`,
|
|
104
|
+
)}</option>`
|
|
105
|
+
: `<option value="${escHtml(r.id)}">${escHtml(
|
|
106
|
+
parts.filter(Boolean).join(" · "),
|
|
107
|
+
)}</option>`;
|
|
108
|
+
})
|
|
109
|
+
.join("")
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The most searched directories named inline; the rest are counted. */
|
|
114
|
+
const SHOWN_DIRS = 4;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether Start can run, and the line that says so (AG-0.2): for the
|
|
118
|
+
* runtime picked (`runtimeId`, "" = the persona's), { ok, tone, html }.
|
|
119
|
+
* `tone` is "" (ready), "warn" (can't start) or "none" (nothing to say).
|
|
120
|
+
*/
|
|
121
|
+
export function readiness(data, personaId, runtimeId, t) {
|
|
122
|
+
const personas = data?.personas ?? [];
|
|
123
|
+
const runtimes = data?.runtimes ?? [];
|
|
124
|
+
const id = runtimeId || defaultRuntimeOf(personas, runtimes, personaId);
|
|
125
|
+
const r = runtimes.find((x) => x.id === id);
|
|
126
|
+
if (!r)
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
tone: "warn",
|
|
130
|
+
html: escHtml(t("ag.rt.unknown", { id: id || "?" })),
|
|
131
|
+
};
|
|
132
|
+
const name = runtimeName(runtimes, r.id);
|
|
133
|
+
const mono = (x) => `<span class="mono">${escHtml(x)}</span>`;
|
|
134
|
+
if (r.available) {
|
|
135
|
+
const no = cantStart(personas, r, personaId, t);
|
|
136
|
+
if (no)
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
tone: "warn",
|
|
140
|
+
html: escHtml(t("ag.rt.refused", { name, why: no })),
|
|
141
|
+
};
|
|
142
|
+
if (!r.path) return { ok: true, tone: "none", html: "" };
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
tone: "",
|
|
146
|
+
html:
|
|
147
|
+
escHtml(
|
|
148
|
+
r.version
|
|
149
|
+
? t("ag.rt.ready", { name, version: r.version })
|
|
150
|
+
: t("ag.rt.readyNoVer", { name }),
|
|
151
|
+
) +
|
|
152
|
+
" " +
|
|
153
|
+
mono(r.path) +
|
|
154
|
+
(r.denTools === false
|
|
155
|
+
? ` <span class="tagx" data-den-tools="coming">${escHtml(t("ag.rt.denTools"))}</span> ${escHtml(t("ag.rt.ownCli"))}`
|
|
156
|
+
: ""),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const file = data?.userConfig || "config/local/user.yml";
|
|
160
|
+
const fix = t("ag.rt.fix", {
|
|
161
|
+
key: "\u0000k",
|
|
162
|
+
file: "\u0000f",
|
|
163
|
+
cmd: "\u0000c",
|
|
164
|
+
});
|
|
165
|
+
const fixHtml = escHtml(fix)
|
|
166
|
+
.replace("\u0000k", mono(r.setting || `agents.runtimes.${r.id}.bin`))
|
|
167
|
+
.replace("\u0000f", mono(file))
|
|
168
|
+
.replace("\u0000c", mono("__DEN_CLI__ agents doctor"));
|
|
169
|
+
let what;
|
|
170
|
+
if (r.source === "setting" || !r.searched)
|
|
171
|
+
what = escHtml(t("ag.rt.refused", { name, why: r.why || "" }));
|
|
172
|
+
else {
|
|
173
|
+
const dirs = r.searched;
|
|
174
|
+
const shown = dirs.slice(0, SHOWN_DIRS).map(mono).join(", ");
|
|
175
|
+
const more =
|
|
176
|
+
dirs.length > SHOWN_DIRS
|
|
177
|
+
? " " + escHtml(t("ag.rt.more", { n: dirs.length - SHOWN_DIRS }))
|
|
178
|
+
: "";
|
|
179
|
+
what =
|
|
180
|
+
escHtml(t("ag.rt.notFound", { name })) +
|
|
181
|
+
" " +
|
|
182
|
+
escHtml(t("ag.rt.searched")) +
|
|
183
|
+
" " +
|
|
184
|
+
(shown || escHtml(t("ag.rt.nowhere"))) +
|
|
185
|
+
more +
|
|
186
|
+
".";
|
|
187
|
+
}
|
|
188
|
+
return { ok: false, tone: "warn", html: `${what} ${fixHtml}` };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fill a form's persona and runtime selects, its help line and its
|
|
193
|
+
* readiness line ([data-runtime-ready]), keep them in step when the
|
|
194
|
+
* persona or the runtime changes, and disable its submit button while
|
|
195
|
+
* the runtime can't run. Returns nothing; idempotent per form (the
|
|
196
|
+
* change listeners are bound once).
|
|
197
|
+
*/
|
|
198
|
+
export function fillStartForm(form, data, t) {
|
|
199
|
+
const personas = data?.personas ?? [];
|
|
200
|
+
const runtimes = data?.runtimes ?? [];
|
|
201
|
+
const ps = form.querySelector('select[name="persona"]');
|
|
202
|
+
const rs = form.querySelector('select[name="runtime"]');
|
|
203
|
+
const help = form.querySelector("[data-persona-help]");
|
|
204
|
+
const ready = form.querySelector("[data-runtime-ready]");
|
|
205
|
+
const go = form.querySelector('button[type="submit"]');
|
|
206
|
+
ps.innerHTML = personaOptions(personas);
|
|
207
|
+
const check = () => {
|
|
208
|
+
const r = readiness(data, ps.value, rs.value, t);
|
|
209
|
+
if (ready) {
|
|
210
|
+
ready.innerHTML = r.html;
|
|
211
|
+
ready.hidden = !r.html;
|
|
212
|
+
ready.dataset.tone = r.tone;
|
|
213
|
+
}
|
|
214
|
+
if (go) {
|
|
215
|
+
go.disabled = !r.ok;
|
|
216
|
+
go.dataset.blocked = r.ok ? "" : "runtime";
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const sync = () => {
|
|
220
|
+
const keep = rs.value;
|
|
221
|
+
rs.innerHTML = runtimeOptions(personas, runtimes, ps.value, t);
|
|
222
|
+
if ([...rs.options].some((o) => o.value === keep && !o.disabled))
|
|
223
|
+
rs.value = keep;
|
|
224
|
+
if (help) {
|
|
225
|
+
const role = personaHelp(personas, ps.value);
|
|
226
|
+
help.textContent = role;
|
|
227
|
+
help.hidden = !role;
|
|
228
|
+
}
|
|
229
|
+
check();
|
|
230
|
+
};
|
|
231
|
+
ps.onchange = sync;
|
|
232
|
+
rs.onchange = check;
|
|
233
|
+
sync();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Whether a form's Start is held back by its runtime (readiness). */
|
|
237
|
+
export function blockedByRuntime(form) {
|
|
238
|
+
return (
|
|
239
|
+
form.querySelector('button[type="submit"]')?.dataset.blocked === "runtime"
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -8,15 +8,31 @@
|
|
|
8
8
|
* the scrollback first, then the live output; keystrokes go to the
|
|
9
9
|
* session and the pane's size follows the pane. Closing a pane (or
|
|
10
10
|
* leaving the page) detaches: the session keeps running. Stop (an agent
|
|
11
|
-
* only
|
|
11
|
+
* only) is the Workspaces page's own agent route.
|
|
12
12
|
*
|
|
13
13
|
* The open panes are the URL (?open=<id>,<id>), so a reload, Back or a
|
|
14
14
|
* link reopens them, each reattaching with its scrollback.
|
|
15
15
|
*
|
|
16
|
+
* The size. A session has one PTY size and every viewer shares it (the
|
|
17
|
+
* last resize wins: another browser, a CLI attach). The server says the
|
|
18
|
+
* PTY's size on attach and whenever anyone changes it (a `size` frame), so
|
|
19
|
+
* a pane knows when it is out of step, and the pane in use wins: it says
|
|
20
|
+
* its own size when it is focused, when the page is shown again, and
|
|
21
|
+
* before the first keystroke after the PTY's size changed under it. A
|
|
22
|
+
* hidden pane never resizes the PTY, nor does one under 2x2. The cells
|
|
23
|
+
* are measured again once the theme's fonts have loaded (a web font
|
|
24
|
+
* arriving after the first fit would leave the cell width the fallback
|
|
25
|
+
* font's) and after the theme changes.
|
|
26
|
+
*
|
|
16
27
|
* Nothing here keeps a session's state: the list is GET /api/sessions,
|
|
17
28
|
* asked again every few seconds while the page is shown.
|
|
29
|
+
*
|
|
30
|
+
* Start agent (AG-0.2): the runtime line says what will run, or why it
|
|
31
|
+
* can't, before Start (agent-start.js readiness). Starting, stopping and
|
|
32
|
+
* resuming an agent never ask for the admin password: it is for sudo, and
|
|
33
|
+
* no agent action runs sudo (LK-0).
|
|
18
34
|
*/
|
|
19
|
-
import {
|
|
35
|
+
import { fillStartForm } from "/agent-start.js";
|
|
20
36
|
|
|
21
37
|
const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
|
|
22
38
|
const esc = (s) =>
|
|
@@ -45,6 +61,7 @@ const st = {
|
|
|
45
61
|
timer: null,
|
|
46
62
|
xterm: null,
|
|
47
63
|
csrf: null,
|
|
64
|
+
docWired: false,
|
|
48
65
|
};
|
|
49
66
|
|
|
50
67
|
// ── the URL ─────────────────────────────────────────────────────────────
|
|
@@ -324,6 +341,100 @@ function send(p, obj) {
|
|
|
324
341
|
if (p.sock?.readyState === WebSocket.OPEN) p.sock.send(JSON.stringify(obj));
|
|
325
342
|
}
|
|
326
343
|
|
|
344
|
+
/** The smallest size a pane ever asks for (a pane not laid out yet). */
|
|
345
|
+
const MIN_COLS = 2;
|
|
346
|
+
const MIN_ROWS = 2;
|
|
347
|
+
|
|
348
|
+
/** Shown: the page is visible and the pane has a box. */
|
|
349
|
+
function paneShown(p) {
|
|
350
|
+
return (
|
|
351
|
+
!document.hidden &&
|
|
352
|
+
p.el.isConnected &&
|
|
353
|
+
p.el.offsetWidth > 0 &&
|
|
354
|
+
p.el.offsetHeight > 0
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Fit the terminal to its pane (its own cols and rows; nothing is sent). */
|
|
359
|
+
function refit(p) {
|
|
360
|
+
const box = p.el.querySelector(".term");
|
|
361
|
+
if (!p.fit || !p.el.isConnected || !box || box.clientWidth <= 0) return;
|
|
362
|
+
try {
|
|
363
|
+
p.fit.fit();
|
|
364
|
+
} catch {
|
|
365
|
+
/* not laid out yet */
|
|
366
|
+
}
|
|
367
|
+
markSize(p);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The pane's size and the PTY's, on the pane (data-cols, data-pty-cols). */
|
|
371
|
+
function markSize(p) {
|
|
372
|
+
if (!p.term) return;
|
|
373
|
+
p.el.dataset.cols = String(p.term.cols);
|
|
374
|
+
p.el.dataset.rows = String(p.term.rows);
|
|
375
|
+
if (p.pty) {
|
|
376
|
+
p.el.dataset.ptyCols = String(p.pty.cols);
|
|
377
|
+
p.el.dataset.ptyRows = String(p.pty.rows);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Whether this pane must say its size before a keystroke: the PTY is known
|
|
383
|
+
* to have another size, or the host never says the PTY's size (an older
|
|
384
|
+
* session host that outlived an upgrade sends no size frames), so another
|
|
385
|
+
* viewer may have resized it unseen. Saying the same size again is free:
|
|
386
|
+
* the PTY only redraws when the size really changes.
|
|
387
|
+
*/
|
|
388
|
+
function outOfStep(p) {
|
|
389
|
+
if (!p.term) return false;
|
|
390
|
+
if (!p.heardSize) return true;
|
|
391
|
+
return !!p.pty && (p.pty.cols !== p.term.cols || p.pty.rows !== p.term.rows);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Say this pane's size to the session (the PTY takes it: last writer
|
|
396
|
+
* wins). Never from a hidden pane, never under 2x2.
|
|
397
|
+
*/
|
|
398
|
+
function sendSize(p) {
|
|
399
|
+
const term = p.term;
|
|
400
|
+
if (!term || p.sock?.readyState !== WebSocket.OPEN || !paneShown(p))
|
|
401
|
+
return false;
|
|
402
|
+
if (term.cols < MIN_COLS || term.rows < MIN_ROWS) return false;
|
|
403
|
+
send(p, { type: "resize", cols: term.cols, rows: term.rows });
|
|
404
|
+
// What the PTY will be: the server's frame follows (or another
|
|
405
|
+
// viewer's, if it resized after).
|
|
406
|
+
p.pty = { cols: term.cols, rows: term.rows };
|
|
407
|
+
markSize(p);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** This pane is in use: fit it and make the PTY its size. */
|
|
412
|
+
function claimSize(p) {
|
|
413
|
+
refit(p);
|
|
414
|
+
sendSize(p);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Measure the cells again (xterm measures them once, when the font is
|
|
419
|
+
* set) and refit: a web font that arrived after the first fit, or a theme
|
|
420
|
+
* whose --mono is another font. A change the PTY hears via onResize.
|
|
421
|
+
*/
|
|
422
|
+
function remeasure(p) {
|
|
423
|
+
const term = p.term;
|
|
424
|
+
if (!term) return;
|
|
425
|
+
const family = cssVar("--mono") ?? "monospace";
|
|
426
|
+
// Setting the same family is a no-op in xterm: set another first (never
|
|
427
|
+
// drawn: the theme's family is back before the refit).
|
|
428
|
+
if (term.options["fontFamily"] === family)
|
|
429
|
+
Object.assign(term.options, { fontFamily: `${family}, monospace` });
|
|
430
|
+
Object.assign(term.options, { fontFamily: family });
|
|
431
|
+
refit(p);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function remeasureAll() {
|
|
435
|
+
for (const p of st.panes.values()) remeasure(p);
|
|
436
|
+
}
|
|
437
|
+
|
|
327
438
|
async function connect(p) {
|
|
328
439
|
p.lost = false;
|
|
329
440
|
paintBar(p);
|
|
@@ -345,8 +456,10 @@ async function connect(p) {
|
|
|
345
456
|
let opened = false;
|
|
346
457
|
sock.onopen = () => {
|
|
347
458
|
opened = true;
|
|
348
|
-
|
|
349
|
-
|
|
459
|
+
p.pty = null;
|
|
460
|
+
p.heardSize = false;
|
|
461
|
+
refit(p);
|
|
462
|
+
sendSize(p);
|
|
350
463
|
};
|
|
351
464
|
sock.onmessage = (e) => {
|
|
352
465
|
if (typeof e.data === "string") {
|
|
@@ -356,7 +469,13 @@ async function connect(p) {
|
|
|
356
469
|
} catch {
|
|
357
470
|
return;
|
|
358
471
|
}
|
|
359
|
-
if (msg?.type === "
|
|
472
|
+
if (msg?.type === "size") {
|
|
473
|
+
if (Number.isInteger(msg.cols) && Number.isInteger(msg.rows)) {
|
|
474
|
+
p.heardSize = true;
|
|
475
|
+
p.pty = { cols: msg.cols, rows: msg.rows };
|
|
476
|
+
markSize(p);
|
|
477
|
+
}
|
|
478
|
+
} else if (msg?.type === "session") {
|
|
360
479
|
p.label = msg.label;
|
|
361
480
|
p.workspace = msg.workspace;
|
|
362
481
|
p.exited = !!msg.exited;
|
|
@@ -403,7 +522,16 @@ async function openPane(id) {
|
|
|
403
522
|
el.className = "pane";
|
|
404
523
|
el.dataset.id = id;
|
|
405
524
|
el.innerHTML = `<div class="bar2"></div><div class="term"></div>`;
|
|
406
|
-
const p = {
|
|
525
|
+
const p = {
|
|
526
|
+
id,
|
|
527
|
+
el,
|
|
528
|
+
term: null,
|
|
529
|
+
fit: null,
|
|
530
|
+
sock: null,
|
|
531
|
+
closed: false,
|
|
532
|
+
// The PTY's size as last heard (the server's size frame), or null.
|
|
533
|
+
pty: null,
|
|
534
|
+
};
|
|
407
535
|
st.panes.set(id, p);
|
|
408
536
|
st.open.push(id);
|
|
409
537
|
paintBar(p);
|
|
@@ -436,19 +564,21 @@ async function openPane(id) {
|
|
|
436
564
|
p.fit = fit;
|
|
437
565
|
const enc = new TextEncoder();
|
|
438
566
|
term.onData((d) => {
|
|
439
|
-
if (p.sock?.readyState
|
|
567
|
+
if (p.sock?.readyState !== WebSocket.OPEN) return;
|
|
568
|
+
// The one typing wins: another viewer resized the PTY since.
|
|
569
|
+
if (outOfStep(p)) claimSize(p);
|
|
570
|
+
p.sock.send(enc.encode(d));
|
|
440
571
|
});
|
|
441
|
-
term.onResize((
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
} catch {
|
|
446
|
-
/* not laid out yet */
|
|
447
|
-
}
|
|
448
|
-
};
|
|
449
|
-
p.ro = new ResizeObserver(() => refit());
|
|
572
|
+
term.onResize(() => sendSize(p));
|
|
573
|
+
// Focused (a click, the window coming back): this pane's size.
|
|
574
|
+
el.addEventListener("focusin", () => claimSize(p));
|
|
575
|
+
p.ro = new ResizeObserver(() => refit(p));
|
|
450
576
|
p.ro.observe(el.querySelector(".term"));
|
|
451
|
-
refit();
|
|
577
|
+
refit(p);
|
|
578
|
+
// The theme's font may still be loading: measure again once it has.
|
|
579
|
+
document.fonts?.ready?.then(() => {
|
|
580
|
+
if (!p.closed) remeasure(p);
|
|
581
|
+
});
|
|
452
582
|
void connect(p);
|
|
453
583
|
}
|
|
454
584
|
|
|
@@ -477,7 +607,7 @@ function closePane(id, { quiet = false } = {}) {
|
|
|
477
607
|
|
|
478
608
|
async function stopAgent(p, button) {
|
|
479
609
|
const r = rowOf(p.id);
|
|
480
|
-
if (!r?.agent
|
|
610
|
+
if (!r?.agent) return;
|
|
481
611
|
button.disabled = true;
|
|
482
612
|
try {
|
|
483
613
|
await api(
|
|
@@ -493,7 +623,54 @@ async function stopAgent(p, button) {
|
|
|
493
623
|
|
|
494
624
|
// ── Start agent ─────────────────────────────────────────────────────────
|
|
495
625
|
|
|
496
|
-
|
|
626
|
+
// Personas and runtimes, per workspace (AG-0.5: a workspace's own runtime
|
|
627
|
+
// paths win), asked once per page each.
|
|
628
|
+
const personasAsked = new Map();
|
|
629
|
+
|
|
630
|
+
function personasFor(ws) {
|
|
631
|
+
const key = ws || "";
|
|
632
|
+
if (!personasAsked.has(key))
|
|
633
|
+
personasAsked.set(
|
|
634
|
+
key,
|
|
635
|
+
api(
|
|
636
|
+
"GET",
|
|
637
|
+
`/api/personas${ws ? `?workspace=${encodeURIComponent(ws)}` : ""}`,
|
|
638
|
+
).catch((err) => {
|
|
639
|
+
personasAsked.delete(key);
|
|
640
|
+
throw err;
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
return personasAsked.get(key);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Fill the form for the workspace picked; false when the ask failed. */
|
|
647
|
+
async function fillForWorkspace(ws) {
|
|
648
|
+
let data;
|
|
649
|
+
try {
|
|
650
|
+
data = await personasFor(ws);
|
|
651
|
+
} catch (err) {
|
|
652
|
+
window.denShell?.errorToast?.(err);
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
const form = st.root.querySelector("#ag-form");
|
|
656
|
+
const keep = [
|
|
657
|
+
form.querySelector("#ag-persona").value,
|
|
658
|
+
form.querySelector("#ag-runtime").value,
|
|
659
|
+
];
|
|
660
|
+
fillStartForm(form, data, t);
|
|
661
|
+
// The persona and runtime picked stay, where the new list still has them.
|
|
662
|
+
const ps = form.querySelector("#ag-persona");
|
|
663
|
+
if (keep[0] && [...ps.options].some((o) => o.value === keep[0])) {
|
|
664
|
+
ps.value = keep[0];
|
|
665
|
+
ps.onchange?.();
|
|
666
|
+
}
|
|
667
|
+
const rs = form.querySelector("#ag-runtime");
|
|
668
|
+
if ([...rs.options].some((o) => o.value === keep[1] && !o.disabled)) {
|
|
669
|
+
rs.value = keep[1];
|
|
670
|
+
rs.onchange?.();
|
|
671
|
+
}
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
497
674
|
|
|
498
675
|
async function openStart() {
|
|
499
676
|
const dlg = st.root.querySelector("#ag-dlg");
|
|
@@ -504,39 +681,16 @@ async function openStart() {
|
|
|
504
681
|
.map((w) => `<option value="${esc(w)}">${esc(w)}</option>`)
|
|
505
682
|
.join("");
|
|
506
683
|
st.root.querySelector("#ag-ws-l").hidden = shown.length <= 1;
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
throw err;
|
|
510
|
-
});
|
|
511
|
-
let data;
|
|
512
|
-
try {
|
|
513
|
-
data = await personasAsked;
|
|
514
|
-
} catch (err) {
|
|
515
|
-
window.denShell?.errorToast?.(err);
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
st.root.querySelector("#ag-persona").innerHTML = (data.personas ?? [])
|
|
519
|
-
.map(
|
|
520
|
-
(p) =>
|
|
521
|
-
`<option value="${esc(p.id)}">${esc(p.id)}${p.role ? ` — ${esc(p.role)}` : ""}</option>`,
|
|
522
|
-
)
|
|
523
|
-
.join("");
|
|
524
|
-
st.root.querySelector("#ag-runtime").innerHTML =
|
|
525
|
-
`<option value="">${esc(t("ag.runtime.auto"))}</option>` +
|
|
526
|
-
(data.runtimes ?? [])
|
|
527
|
-
.map(
|
|
528
|
-
(r) =>
|
|
529
|
-
`<option value="${esc(r.id)}"${r.available ? "" : " disabled"}>${esc(r.id)}${r.available ? "" : ` — ${esc(t("ag.unavailable"))}`}</option>`,
|
|
530
|
-
)
|
|
531
|
-
.join("");
|
|
684
|
+
if (!(await fillForWorkspace(wsSel.value || shown[0]))) return;
|
|
685
|
+
if (dlg.open) return;
|
|
532
686
|
if (typeof dlg.showModal === "function") dlg.showModal();
|
|
533
687
|
else dlg.setAttribute("open", "");
|
|
534
688
|
}
|
|
535
689
|
|
|
536
690
|
async function submitStart(form) {
|
|
537
|
-
if (!requireUnlock()) return;
|
|
538
691
|
const dlg = st.root.querySelector("#ag-dlg");
|
|
539
692
|
const go = st.root.querySelector("#ag-go");
|
|
693
|
+
if (go.disabled) return;
|
|
540
694
|
const ws = form.querySelector("#ag-ws").value || viewWorkspaces()[0];
|
|
541
695
|
const persona = form.querySelector("#ag-persona").value;
|
|
542
696
|
const runtime = form.querySelector("#ag-runtime").value;
|
|
@@ -564,6 +718,40 @@ async function submitStart(form) {
|
|
|
564
718
|
}
|
|
565
719
|
}
|
|
566
720
|
|
|
721
|
+
/**
|
|
722
|
+
* Once per document: a font that loads later (the theme's --mono as a web
|
|
723
|
+
* font) and a theme applied without a reload (its sheet, then
|
|
724
|
+
* den:appearance) measure the cells again; the page shown again makes
|
|
725
|
+
* each pane say its size (another viewer may have taken the PTY while it
|
|
726
|
+
* was hidden).
|
|
727
|
+
*/
|
|
728
|
+
function wireDocument() {
|
|
729
|
+
st.docWired = true;
|
|
730
|
+
document.fonts?.addEventListener?.("loadingdone", remeasureAll);
|
|
731
|
+
window.addEventListener("den:appearance", () => {
|
|
732
|
+
for (const p of st.panes.values())
|
|
733
|
+
if (p.term) p.term.options.theme = termTheme();
|
|
734
|
+
remeasureAll();
|
|
735
|
+
});
|
|
736
|
+
document.addEventListener(
|
|
737
|
+
"load",
|
|
738
|
+
(e) => {
|
|
739
|
+
if (e.target?.id === "theme-css") {
|
|
740
|
+
for (const p of st.panes.values())
|
|
741
|
+
if (p.term) p.term.options.theme = termTheme();
|
|
742
|
+
remeasureAll();
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
true,
|
|
746
|
+
);
|
|
747
|
+
// Shown again: each pane says its size (another viewer may have taken
|
|
748
|
+
// the PTY while this page was hidden).
|
|
749
|
+
document.addEventListener("visibilitychange", () => {
|
|
750
|
+
if (document.hidden) return;
|
|
751
|
+
for (const p of st.panes.values()) claimSize(p);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
567
755
|
// ── mount ───────────────────────────────────────────────────────────────
|
|
568
756
|
|
|
569
757
|
function translateStatic(root) {
|
|
@@ -614,6 +802,9 @@ export async function mountAgentsPage(root) {
|
|
|
614
802
|
root.querySelector("#ag-start").addEventListener("click", () => {
|
|
615
803
|
void openStart();
|
|
616
804
|
});
|
|
805
|
+
root.querySelector("#ag-ws").addEventListener("change", (e) => {
|
|
806
|
+
void fillForWorkspace(e.currentTarget.value);
|
|
807
|
+
});
|
|
617
808
|
root.querySelector("#ag-cancel").addEventListener("click", () => {
|
|
618
809
|
root.querySelector("#ag-dlg").close?.();
|
|
619
810
|
});
|
|
@@ -621,6 +812,15 @@ export async function mountAgentsPage(root) {
|
|
|
621
812
|
e.preventDefault();
|
|
622
813
|
void submitStart(e.currentTarget);
|
|
623
814
|
});
|
|
815
|
+
// Enter starts from any field (a select doesn't submit on its own); Esc
|
|
816
|
+
// cancels (the dialog's own cancel). A button keeps its own Enter.
|
|
817
|
+
root.querySelector("#ag-form").addEventListener("keydown", (e) => {
|
|
818
|
+
if (e.key !== "Enter" || e.isComposing) return;
|
|
819
|
+
if (e.target.closest("button, textarea")) return;
|
|
820
|
+
e.preventDefault();
|
|
821
|
+
if (!st.root.querySelector("#ag-go").disabled)
|
|
822
|
+
e.currentTarget.requestSubmit();
|
|
823
|
+
});
|
|
624
824
|
// The terminals follow the mode (light, dark) and the theme.
|
|
625
825
|
new MutationObserver(() => {
|
|
626
826
|
for (const p of st.panes.values())
|
|
@@ -629,6 +829,7 @@ export async function mountAgentsPage(root) {
|
|
|
629
829
|
attributes: true,
|
|
630
830
|
attributeFilter: ["data-mode", "data-theme", "class"],
|
|
631
831
|
});
|
|
832
|
+
if (!st.docWired) wireDocument();
|
|
632
833
|
window.addEventListener("pagehide", () => {
|
|
633
834
|
for (const p of st.panes.values()) {
|
|
634
835
|
p.closed = true;
|