@songsid/agend 2.0.11-beta.3 → 2.0.11-beta.30

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 (45) hide show
  1. package/dist/channel/adapters/discord.d.ts +2 -0
  2. package/dist/channel/adapters/discord.js +39 -34
  3. package/dist/channel/adapters/discord.js.map +1 -1
  4. package/dist/channel/factory.d.ts +4 -0
  5. package/dist/channel/factory.js +1 -0
  6. package/dist/channel/factory.js.map +1 -1
  7. package/dist/channel/mcp-tools.js +3 -0
  8. package/dist/channel/mcp-tools.js.map +1 -1
  9. package/dist/channel/tool-router.js +29 -2
  10. package/dist/channel/tool-router.js.map +1 -1
  11. package/dist/classic-channel-manager.d.ts +59 -12
  12. package/dist/classic-channel-manager.js +121 -30
  13. package/dist/classic-channel-manager.js.map +1 -1
  14. package/dist/cli.js +94 -7
  15. package/dist/cli.js.map +1 -1
  16. package/dist/config-validator.d.ts +20 -0
  17. package/dist/config-validator.js +154 -0
  18. package/dist/config-validator.js.map +1 -0
  19. package/dist/fleet-manager.d.ts +27 -5
  20. package/dist/fleet-manager.js +360 -168
  21. package/dist/fleet-manager.js.map +1 -1
  22. package/dist/instance-lifecycle.d.ts +1 -1
  23. package/dist/instance-lifecycle.js +7 -6
  24. package/dist/instance-lifecycle.js.map +1 -1
  25. package/dist/outbound-schemas.d.ts +1 -0
  26. package/dist/outbound-schemas.js +1 -0
  27. package/dist/outbound-schemas.js.map +1 -1
  28. package/dist/quickstart.js +245 -16
  29. package/dist/quickstart.js.map +1 -1
  30. package/dist/service-installer.d.ts +1 -0
  31. package/dist/service-installer.js +2 -1
  32. package/dist/service-installer.js.map +1 -1
  33. package/dist/settings-api.d.ts +33 -0
  34. package/dist/settings-api.js +283 -0
  35. package/dist/settings-api.js.map +1 -0
  36. package/dist/topic-commands.d.ts +14 -0
  37. package/dist/topic-commands.js +63 -2
  38. package/dist/topic-commands.js.map +1 -1
  39. package/dist/ui/settings.html +457 -0
  40. package/dist/ui/view.html +477 -0
  41. package/dist/view-api.d.ts +29 -0
  42. package/dist/view-api.js +370 -0
  43. package/dist/view-api.js.map +1 -0
  44. package/package.json +1 -1
  45. package/templates/systemd.service.ejs +2 -1
@@ -0,0 +1,477 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>AgEnD View</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ :root { --bg:#0d1117; --panel:#161b22; --border:#21262d; --fg:#c9d1d9; --dim:#8b949e; --accent:#58a6ff; }
10
+ html, body { height: 100%; }
11
+ body { background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
12
+
13
+ /* Sidebar */
14
+ #sidebar { width: 240px; flex: 0 0 240px; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; }
15
+ #sidebar h1 { font-size: 14px; color: var(--accent); padding: 14px 16px; border-bottom: 1px solid var(--border); font-weight: 600; letter-spacing: .5px; }
16
+ .group-header { display: flex; align-items: center; gap: 6px; padding: 8px 12px; cursor: pointer; font-size: 12px; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: .4px; background: rgba(255,255,255,.02); border-bottom: 1px solid var(--border); user-select: none; }
17
+ .group-header:hover { color: var(--fg); }
18
+ .group-header .caret { width: 10px; flex: 0 0 10px; }
19
+ .group-header .gcount { margin-left: auto; opacity: .7; font-weight: 400; }
20
+ .inst { display: flex; align-items: center; gap: 8px; padding: 10px 16px 10px 24px; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.03); font-size: 13px; }
21
+ .dragging { opacity: .4; }
22
+ .drop-before { border-top: 2px solid var(--accent) !important; }
23
+ .drop-after { border-bottom: 2px solid var(--accent) !important; }
24
+ [draggable="true"] { cursor: grab; }
25
+ .inst:hover { background: rgba(255,255,255,.04); }
26
+ .inst.active { background: rgba(88,166,255,.14); border-left: 3px solid var(--accent); padding-left: 13px; }
27
+ .dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 8px; }
28
+ .dot.running { background: #3fb950; } .dot.stopped { background: #6e7681; } .dot.crashed { background: #f85149; }
29
+ .inst .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
30
+ .inst .pct { color: var(--dim); font-size: 11px; }
31
+
32
+ /* Main */
33
+ #main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
34
+ #term { flex: 1; background: #000; overflow: auto; padding: 10px 12px; }
35
+ #term pre { font-family: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; line-height: 1.2; white-space: pre; color: #d0d0d0; }
36
+
37
+ /* Card */
38
+ #card { background: var(--panel); border-top: 1px solid var(--border); padding: 14px 18px; display: flex; gap: 16px; align-items: flex-start; }
39
+ #avatarBox { width: 56px; height: 56px; flex: 0 0 56px; }
40
+ .avatar-img { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; display: block; }
41
+ .avatar-ph { width: 56px; height: 56px; border-radius: 50%; background: #30363d; color: var(--dim); display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 600; text-transform: uppercase; user-select: none; }
42
+ #cardBody { flex: 1; min-width: 0; }
43
+ #cardBody .row1 { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
44
+ #cardBody .dname { font-size: 16px; font-weight: 600; }
45
+ #cardBody .role { color: var(--accent); font-size: 13px; }
46
+ #cardBody .meta { color: var(--dim); font-size: 12px; margin-top: 3px; }
47
+ #cardBody .desc { font-size: 13px; margin-top: 6px; color: #adbac7; white-space: pre-wrap; }
48
+ #editBtn { background: #21262d; color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 12px; cursor: pointer; font-size: 13px; }
49
+ #editBtn:hover { border-color: var(--accent); }
50
+
51
+ /* Edit form */
52
+ #edit { display: none; flex-direction: column; gap: 8px; flex: 1; }
53
+ #edit input, #edit textarea { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font-size: 13px; font-family: inherit; width: 100%; }
54
+ #edit textarea { resize: vertical; min-height: 48px; }
55
+ #edit .actions { display: flex; gap: 8px; align-items: center; }
56
+ #edit button { border: none; border-radius: 6px; padding: 6px 14px; cursor: pointer; font-size: 13px; }
57
+ #saveBtn { background: #238636; color: #fff; } #cancelBtn { background: #21262d; color: var(--fg); border: 1px solid var(--border); }
58
+ #edit .hint { color: var(--dim); font-size: 11px; }
59
+ #msg { font-size: 12px; }
60
+ .err { color: #f85149; } .ok { color: #3fb950; }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <div id="sidebar"><h1>AgEnD · View</h1><div id="list"></div></div>
65
+ <div id="main">
66
+ <div id="term"><pre id="pre"></pre></div>
67
+ <div id="card">
68
+ <div id="avatarBox"></div>
69
+ <div id="cardBody">
70
+ <div class="row1"><span class="dname" id="dname">—</span><span class="role" id="role"></span></div>
71
+ <div class="meta" id="meta"></div>
72
+ <div class="desc" id="desc"></div>
73
+ </div>
74
+ <button id="editBtn">✏️ Edit</button>
75
+ <div id="edit">
76
+ <input id="fDisplay" placeholder="Display name">
77
+ <input id="fRole" placeholder="Role">
78
+ <textarea id="fDesc" placeholder="Description"></textarea>
79
+ <input id="fAvatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
80
+ <input id="fToken" type="password" placeholder="web.token (required to save)">
81
+ <div class="actions">
82
+ <button id="saveBtn">Save</button>
83
+ <button id="cancelBtn">Cancel</button>
84
+ <span id="msg"></span>
85
+ </div>
86
+ <div class="hint">Viewing is open (read-only). Saving requires the web.token (~/.agend/web.token).</div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+
91
+ <script>
92
+ (() => {
93
+ // GET routes are open (no token). Writes (profile/avatar/sort-order POST)
94
+ // require the web.token, which the user enters in the edit panel; we keep it
95
+ // in localStorage. A ?token= URL param is still honored as a fallback.
96
+ const urlToken = new URLSearchParams(location.search).get("token") || "";
97
+ const webToken = () => localStorage.getItem("agend_web_token") || urlToken;
98
+ const q = (id) => document.getElementById(id);
99
+ let roster = [];
100
+ let rosterByName = new Map();
101
+ let current = null;
102
+ let poll = null;
103
+ let fitted = false;
104
+ const collapsed = new Set(); // group names the user has collapsed
105
+ // Drag-sort state
106
+ let sortGroups = new Map(); // groupName -> sort_index (persisted override)
107
+ let sortInsts = new Map(); // instanceName -> sort_index (within its group)
108
+ let groupNames = []; // group display order (computed)
109
+ let instByGroup = new Map(); // group -> [instance_name, …] display order (computed)
110
+ let writable = false; // whether the current web.token can POST
111
+ let probed = false;
112
+ let dragItem = null; // { type:'group'|'instance', name, group }
113
+
114
+ const api = (path) => urlToken ? `${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(urlToken)}` : path;
115
+
116
+ // ── ANSI (SGR) → HTML ─────────────────────────────────
117
+ const BASE = ["#000000","#cd3131","#0dbc79","#e5e510","#2472c8","#bc3fbc","#11a8cd","#e5e5e5"];
118
+ const BRIGHT = ["#666666","#f14c4c","#23d18b","#f5f543","#3b8eea","#d670d6","#29b8db","#ffffff"];
119
+ const esc = (s) => s.replace(/[&<>]/g, c => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;" }[c]));
120
+
121
+ function ansiToHtml(text) {
122
+ let out = "", fg = null, bg = null, bold = false, open = false;
123
+ const style = () => {
124
+ const p = [];
125
+ let f = fg;
126
+ if (bold && typeof f === "number" && f < 8) f = BRIGHT[f];
127
+ else if (typeof f === "number") f = BASE[f];
128
+ if (f) p.push("color:" + f);
129
+ if (bg) p.push("background:" + (typeof bg === "number" ? BASE[bg] : bg));
130
+ if (bold) p.push("font-weight:bold");
131
+ return p.join(";");
132
+ };
133
+ const flush = () => { if (open) { out += "</span>"; open = false; } };
134
+ const openSpan = () => { const s = style(); if (s) { out += `<span style="${s}">`; open = true; } };
135
+
136
+ const re = /\x1b\[([0-9;]*)m/g;
137
+ let last = 0, m;
138
+ const emit = (chunk) => { if (!chunk) return; if (!open) openSpan(); out += esc(chunk); };
139
+ while ((m = re.exec(text)) !== null) {
140
+ emit(text.slice(last, m.index));
141
+ last = re.lastIndex;
142
+ flush();
143
+ const codes = m[1].split(";").filter(x => x !== "").map(Number);
144
+ if (codes.length === 0) codes.push(0);
145
+ for (let i = 0; i < codes.length; i++) {
146
+ const c = codes[i];
147
+ if (c === 0) { fg = bg = null; bold = false; }
148
+ else if (c === 1) bold = true;
149
+ else if (c === 22) bold = false;
150
+ else if (c === 39) fg = null;
151
+ else if (c === 49) bg = null;
152
+ else if (c >= 30 && c <= 37) fg = c - 30;
153
+ else if (c >= 90 && c <= 97) fg = BRIGHT[c - 90];
154
+ else if (c >= 40 && c <= 47) bg = c - 40;
155
+ else if (c >= 100 && c <= 107) bg = BRIGHT[c - 100];
156
+ else if (c === 38 || c === 48) {
157
+ const target = c === 38 ? "fg" : "bg";
158
+ if (codes[i + 1] === 5) { const n = codes[i + 2]; const col = xterm256(n); if (target === "fg") fg = col; else bg = col; i += 2; }
159
+ else if (codes[i + 1] === 2) { const col = `rgb(${codes[i+2]||0},${codes[i+3]||0},${codes[i+4]||0})`; if (target === "fg") fg = col; else bg = col; i += 4; }
160
+ }
161
+ }
162
+ }
163
+ emit(text.slice(last));
164
+ flush();
165
+ return out;
166
+ }
167
+ function xterm256(n) {
168
+ if (n < 8) return BASE[n];
169
+ if (n < 16) return BRIGHT[n - 8];
170
+ if (n < 232) { n -= 16; const r = Math.floor(n/36), g = Math.floor((n%36)/6), b = n%6; const v = x => x ? x*40+55 : 0; return `rgb(${v(r)},${v(g)},${v(b)})`; }
171
+ const l = (n - 232) * 10 + 8; return `rgb(${l},${l},${l})`;
172
+ }
173
+
174
+ // ── Sidebar (grouped by tag) ──────────────────────────
175
+ // Group name: "Classic" if tagged classic, else the first tag, else "Other".
176
+ function groupOf(it) {
177
+ const tags = it.tags || [];
178
+ if (tags.includes("classic")) return "Classic";
179
+ if (tags.length) return tags[0];
180
+ return "Other";
181
+ }
182
+ // Short label: display name if set, else instance name minus its tag prefix
183
+ // and the -t<id> suffix (doupo-server-t1503… → server).
184
+ function shortName(it) {
185
+ if (it.display_name) return it.display_name;
186
+ let n = it.instance_name.replace(/-t[0-9a-z]+$/i, "");
187
+ const tag = (it.tags || [])[0];
188
+ if (tag && n.toLowerCase().startsWith(tag.toLowerCase() + "-")) n = n.slice(tag.length + 1);
189
+ return n || it.instance_name;
190
+ }
191
+
192
+ // Compute display order: persisted sort-order first (by index), then any
193
+ // new/unsorted groups & instances by the tag+alpha fallback. Stored in
194
+ // groupNames / instByGroup so drag handlers can mutate + persist them.
195
+ function computeOrder() {
196
+ const groups = new Map();
197
+ for (const it of roster) {
198
+ const g = groupOf(it);
199
+ if (!groups.has(g)) groups.set(g, []);
200
+ groups.get(g).push(it);
201
+ }
202
+ const gidx = (g) => (sortGroups.has(g) ? sortGroups.get(g) : Infinity);
203
+ groupNames = [...groups.keys()].sort((a, b) => {
204
+ const ia = gidx(a), ib = gidx(b);
205
+ if (ia !== ib) return ia - ib;
206
+ if (a === "Other") return 1;
207
+ if (b === "Other") return -1;
208
+ return a.localeCompare(b);
209
+ });
210
+ instByGroup = new Map();
211
+ for (const [g, items] of groups) {
212
+ // stable sort keeps roster order for equal (unsorted) indices
213
+ const names = items.map(it => it.instance_name).sort((a, b) => {
214
+ const ia = sortInsts.has(a) ? sortInsts.get(a) : Infinity;
215
+ const ib = sortInsts.has(b) ? sortInsts.get(b) : Infinity;
216
+ return ia - ib;
217
+ });
218
+ instByGroup.set(g, names);
219
+ }
220
+ }
221
+
222
+ function renderList() {
223
+ const list = q("list");
224
+ list.innerHTML = "";
225
+ for (const g of groupNames) {
226
+ const names = instByGroup.get(g) || [];
227
+ if (!names.length) continue;
228
+ const isCollapsed = collapsed.has(g);
229
+ const header = document.createElement("div");
230
+ header.className = "group-header";
231
+ header.draggable = writable;
232
+ header.innerHTML = `<span class="caret">${isCollapsed ? "▶" : "▼"}</span><span>${esc(g)}</span><span class="gcount">${names.length}</span>`;
233
+ header.onclick = () => { if (collapsed.has(g)) collapsed.delete(g); else collapsed.add(g); renderList(); };
234
+ if (writable) wireDrag(header, { type: "group", name: g });
235
+ list.appendChild(header);
236
+ if (isCollapsed) continue;
237
+
238
+ for (const name of names) {
239
+ const it = rosterByName.get(name);
240
+ if (!it) continue;
241
+ const el = document.createElement("div");
242
+ el.className = "inst" + (name === current ? " active" : "");
243
+ el.draggable = writable;
244
+ el.innerHTML = `<span class="dot ${it.status}"></span><span class="nm">${esc(shortName(it))}</span><span class="pct">${it.context_pct ? Math.round(it.context_pct)+"%" : ""}</span>`;
245
+ el.onclick = () => select(name);
246
+ if (writable) wireDrag(el, { type: "instance", name, group: g });
247
+ list.appendChild(el);
248
+ }
249
+ }
250
+ }
251
+
252
+ // ── Drag & drop ───────────────────────────────────────
253
+ function wireDrag(el, item) {
254
+ el.addEventListener("dragstart", (e) => {
255
+ dragItem = item;
256
+ el.classList.add("dragging");
257
+ e.dataTransfer.effectAllowed = "move";
258
+ try { e.dataTransfer.setData("text/plain", item.name); } catch { /* Firefox needs data set */ }
259
+ });
260
+ el.addEventListener("dragend", () => {
261
+ el.classList.remove("dragging");
262
+ document.querySelectorAll(".drop-before,.drop-after").forEach(n => n.classList.remove("drop-before", "drop-after"));
263
+ dragItem = null;
264
+ });
265
+ el.addEventListener("dragover", (e) => {
266
+ if (!dragItem || dragItem.type !== item.type) return; // only same kind
267
+ if (item.type === "instance" && dragItem.group !== item.group) return; // no cross-group
268
+ if (dragItem.name === item.name) return;
269
+ e.preventDefault();
270
+ const before = e.clientY < el.getBoundingClientRect().top + el.offsetHeight / 2;
271
+ el.classList.toggle("drop-before", before);
272
+ el.classList.toggle("drop-after", !before);
273
+ });
274
+ el.addEventListener("dragleave", () => el.classList.remove("drop-before", "drop-after"));
275
+ el.addEventListener("drop", (e) => {
276
+ e.preventDefault();
277
+ const before = el.classList.contains("drop-before");
278
+ el.classList.remove("drop-before", "drop-after");
279
+ if (!dragItem || dragItem.type !== item.type) return;
280
+ if (item.type === "group") reorder(groupNames, dragItem.name, item.name, before);
281
+ else if (dragItem.group === item.group) reorder(instByGroup.get(item.group), dragItem.name, item.name, before);
282
+ else return;
283
+ renderList();
284
+ persistOrder();
285
+ });
286
+ }
287
+
288
+ function reorder(arr, moving, target, before) {
289
+ if (!arr) return;
290
+ const from = arr.indexOf(moving);
291
+ if (from === -1) return;
292
+ arr.splice(from, 1);
293
+ let to = arr.indexOf(target);
294
+ if (to === -1) { arr.push(moving); return; }
295
+ if (!before) to += 1;
296
+ arr.splice(to, 0, moving);
297
+ }
298
+
299
+ function orderRows() {
300
+ const rows = [];
301
+ groupNames.forEach((g, i) => rows.push({ item_type: "group", item_name: g, sort_index: i, group_name: null }));
302
+ for (const g of groupNames) {
303
+ (instByGroup.get(g) || []).forEach((n, j) => rows.push({ item_type: "instance", item_name: n, sort_index: j, group_name: g }));
304
+ }
305
+ return rows;
306
+ }
307
+
308
+ async function persistOrder() {
309
+ // Mirror into the local maps so a mid-flight refresh doesn't revert.
310
+ sortGroups = new Map(groupNames.map((g, i) => [g, i]));
311
+ sortInsts = new Map();
312
+ for (const g of groupNames) (instByGroup.get(g) || []).forEach((n, j) => sortInsts.set(n, j));
313
+ try {
314
+ const r = await fetch("/api/sort-order", {
315
+ method: "POST",
316
+ headers: { "Content-Type": "application/json", "X-Agend-Token": webToken() },
317
+ body: JSON.stringify(orderRows()),
318
+ });
319
+ if (r.status === 401) { writable = false; renderList(); }
320
+ } catch { /* transient */ }
321
+ }
322
+
323
+ // Probe write permission by POSTing the current order (idempotent for a
324
+ // web-token user; 401 without a valid web.token → drag stays disabled).
325
+ // Re-run after the user enters a web.token so drag-sort lights up.
326
+ async function probeWrite() {
327
+ if (!webToken()) { if (writable) { writable = false; renderList(); } return; }
328
+ try {
329
+ const r = await fetch("/api/sort-order", {
330
+ method: "POST",
331
+ headers: { "Content-Type": "application/json", "X-Agend-Token": webToken() },
332
+ body: JSON.stringify(orderRows()),
333
+ });
334
+ const nowWritable = r.ok;
335
+ if (nowWritable !== writable) { writable = nowWritable; renderList(); }
336
+ } catch { /* leave disabled */ }
337
+ }
338
+
339
+ function renderCard() {
340
+ const it = roster.find(r => r.instance_name === current);
341
+ if (!it) return;
342
+ q("dname").textContent = it.display_name || it.instance_name;
343
+ q("role").textContent = it.role || "";
344
+ // Only show ctx% when we actually have a reading (>0); 0/null means the
345
+ // backend didn't report one, so hiding it avoids a misleading "0%".
346
+ const ctxPart = it.context_pct ? ` · ctx ${Math.round(it.context_pct)}%` : "";
347
+ q("meta").textContent = `${it.instance_name} · ${it.backend}${it.model ? " · " + it.model : ""}${ctxPart} · ${it.status}`;
348
+ q("desc").textContent = it.description || "";
349
+ // Only issue an <img> request when an avatar actually exists — otherwise a
350
+ // 404 shows a broken-image icon. No avatar → CSS placeholder (initial).
351
+ const box = q("avatarBox");
352
+ if (it.has_avatar) {
353
+ const base = api(`/api/avatar/${encodeURIComponent(it.instance_name)}`);
354
+ const src = base + (base.includes("?") ? "&" : "?") + "t=" + Date.now(); // cache-bust, correct query separator
355
+ box.innerHTML = `<img class="avatar-img" alt="" src="${src}">`;
356
+ } else {
357
+ const initial = (it.display_name || it.instance_name || "?").trim().charAt(0) || "?";
358
+ box.innerHTML = `<div class="avatar-ph">${esc(initial)}</div>`;
359
+ }
360
+ }
361
+
362
+ async function loadRoster() {
363
+ try {
364
+ const [pr, so] = await Promise.all([fetch(api("/api/profiles")), fetch(api("/api/sort-order"))]);
365
+ if (!pr.ok) throw new Error("HTTP " + pr.status);
366
+ roster = await pr.json();
367
+ rosterByName = new Map(roster.map(r => [r.instance_name, r]));
368
+ const soRows = so.ok ? await so.json() : [];
369
+ sortGroups = new Map(); sortInsts = new Map();
370
+ for (const row of soRows) {
371
+ if (row.item_type === "group") sortGroups.set(row.item_name, row.sort_index);
372
+ else if (row.item_type === "instance") sortInsts.set(row.item_name, row.sort_index);
373
+ }
374
+ if (!current && roster.length) current = roster[0].instance_name;
375
+ computeOrder();
376
+ renderList(); renderCard();
377
+ if (!probed) { probed = true; probeWrite(); }
378
+ } catch (e) { q("pre").textContent = "Failed to load roster: " + e.message + "\n(Is the fleet running?)"; }
379
+ }
380
+
381
+ // ── Terminal ──────────────────────────────────────────
382
+ async function refreshPane() {
383
+ if (!current) return;
384
+ const target = current; // guard against a switch mid-fetch
385
+ try {
386
+ const r = await fetch(api(`/api/pane/${encodeURIComponent(target)}`));
387
+ if (target !== current) return; // user switched while awaiting — drop stale
388
+ if (!r.ok) {
389
+ // Surface the failure instead of leaving the previous instance's output
390
+ // on screen (which reads as "switching did nothing").
391
+ q("pre").textContent = `⚠ Could not load pane for ${target} (HTTP ${r.status}).`;
392
+ return;
393
+ }
394
+ const text = await r.text();
395
+ if (target !== current) return;
396
+ q("pre").innerHTML = ansiToHtml(text);
397
+ if (!fitted) { fitFont(text); fitted = true; }
398
+ } catch { /* transient network error — keep last frame */ }
399
+ }
400
+
401
+ // Fit font size so the widest line fits the terminal width. Recomputed only
402
+ // on instance switch (fitted flag), not every poll.
403
+ function fitFont(text) {
404
+ const cols = text.split("\n").reduce((m, l) => Math.max(m, l.replace(/\x1b\[[0-9;]*m/g, "").length), 0);
405
+ if (cols < 1) return;
406
+ const termW = q("term").clientWidth - 24;
407
+ // monospace char width ≈ 0.6 × font-size
408
+ let size = Math.floor(termW / (cols * 0.6));
409
+ size = Math.max(8, Math.min(size, 16));
410
+ q("pre").style.fontSize = size + "px";
411
+ }
412
+
413
+ function select(name) {
414
+ if (name === current) return;
415
+ current = name; fitted = false;
416
+ exitEdit();
417
+ q("pre").textContent = ""; // blank the old instance's output immediately
418
+ renderList(); renderCard(); refreshPane();
419
+ }
420
+
421
+ // ── Edit ──────────────────────────────────────────────
422
+ function enterEdit() {
423
+ const it = roster.find(r => r.instance_name === current);
424
+ if (!it) return;
425
+ q("fDisplay").value = it.display_name || "";
426
+ q("fRole").value = it.role || "";
427
+ q("fDesc").value = it.description || "";
428
+ q("fToken").value = localStorage.getItem("agend_web_token") || "";
429
+ q("msg").textContent = "";
430
+ q("cardBody").style.display = "none";
431
+ q("editBtn").style.display = "none";
432
+ q("edit").style.display = "flex";
433
+ }
434
+ function exitEdit() {
435
+ q("edit").style.display = "none";
436
+ q("cardBody").style.display = "";
437
+ q("editBtn").style.display = "";
438
+ }
439
+
440
+ async function save() {
441
+ const it = roster.find(r => r.instance_name === current);
442
+ if (!it) return;
443
+ const wt = q("fToken").value.trim();
444
+ if (!wt) { q("msg").className = "err"; q("msg").textContent = "web.token required"; return; }
445
+ localStorage.setItem("agend_web_token", wt);
446
+ const name = encodeURIComponent(current);
447
+ try {
448
+ const r = await fetch(`/api/profile/${name}`, {
449
+ method: "POST",
450
+ headers: { "Content-Type": "application/json", "X-Agend-Token": wt },
451
+ body: JSON.stringify({ display_name: q("fDisplay").value, role: q("fRole").value, description: q("fDesc").value }),
452
+ });
453
+ if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || ("HTTP " + r.status)); }
454
+ const file = q("fAvatar").files[0];
455
+ if (file) {
456
+ const ar = await fetch(`/api/avatar/${name}`, { method: "POST", headers: { "Content-Type": file.type, "X-Agend-Token": wt }, body: file });
457
+ if (!ar.ok) { const e = await ar.json().catch(() => ({})); throw new Error("avatar: " + (e.error || ar.status)); }
458
+ }
459
+ q("msg").className = "ok"; q("msg").textContent = "Saved";
460
+ await loadRoster();
461
+ probeWrite(); // web.token now stored — enable drag-sort
462
+ setTimeout(exitEdit, 500);
463
+ } catch (e) { q("msg").className = "err"; q("msg").textContent = e.message; }
464
+ }
465
+
466
+ q("editBtn").onclick = enterEdit;
467
+ q("cancelBtn").onclick = exitEdit;
468
+ q("saveBtn").onclick = save;
469
+
470
+ // ── Boot ──────────────────────────────────────────────
471
+ loadRoster().then(() => { refreshPane(); });
472
+ poll = setInterval(refreshPane, 800);
473
+ setInterval(loadRoster, 5000); // refresh status dots / ctx%
474
+ })();
475
+ </script>
476
+ </body>
477
+ </html>
@@ -0,0 +1,29 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { FleetConfig } from "./types.js";
3
+ import type { Logger } from "./logger.js";
4
+ export interface ViewApiContext {
5
+ readonly viewToken: string | null;
6
+ readonly webToken: string | null;
7
+ readonly dataDir: string;
8
+ readonly fleetConfig: FleetConfig | null;
9
+ readonly logger: Logger;
10
+ readonly classicChannels: {
11
+ getAll(): {
12
+ instanceName: string;
13
+ name: string;
14
+ backend?: string;
15
+ channelId: string;
16
+ }[];
17
+ getBackendByInstance(name: string, fleetDefault?: string): string;
18
+ } | null;
19
+ getInstanceStatus(name: string): "running" | "stopped" | "crashed";
20
+ getUiStatus(): unknown;
21
+ }
22
+ /** True if the path belongs to the view feature (so the caller can skip the
23
+ * global web-token gate and let this module do its own token checks). */
24
+ export declare function isViewPath(path: string): boolean;
25
+ /**
26
+ * Handle a `/view` feature request. Returns true if the request was a view
27
+ * route (and has been answered), false if it isn't ours.
28
+ */
29
+ export declare function handleViewRequest(req: IncomingMessage, res: ServerResponse, url: URL, ctx: ViewApiContext): boolean;