@songsid/agend 2.0.11-beta.9 โ†’ 2.0.11

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.js +21 -16
  2. package/dist/channel/adapters/discord.js.map +1 -1
  3. package/dist/channel/adapters/telegram.js +1 -0
  4. package/dist/channel/adapters/telegram.js.map +1 -1
  5. package/dist/channel/mcp-tools.js +3 -0
  6. package/dist/channel/mcp-tools.js.map +1 -1
  7. package/dist/channel/tool-router.js +29 -2
  8. package/dist/channel/tool-router.js.map +1 -1
  9. package/dist/channel/types.d.ts +2 -0
  10. package/dist/classic-channel-manager.d.ts +59 -12
  11. package/dist/classic-channel-manager.js +121 -30
  12. package/dist/classic-channel-manager.js.map +1 -1
  13. package/dist/cli.js +70 -21
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config-validator.d.ts +20 -0
  16. package/dist/config-validator.js +154 -0
  17. package/dist/config-validator.js.map +1 -0
  18. package/dist/fleet-manager.d.ts +21 -5
  19. package/dist/fleet-manager.js +412 -258
  20. package/dist/fleet-manager.js.map +1 -1
  21. package/dist/instance-lifecycle.d.ts +1 -1
  22. package/dist/instance-lifecycle.js +12 -11
  23. package/dist/instance-lifecycle.js.map +1 -1
  24. package/dist/locale.d.ts +19 -0
  25. package/dist/locale.js +179 -0
  26. package/dist/locale.js.map +1 -0
  27. package/dist/outbound-schemas.d.ts +1 -0
  28. package/dist/outbound-schemas.js +1 -0
  29. package/dist/outbound-schemas.js.map +1 -1
  30. package/dist/quickstart.js +222 -9
  31. package/dist/quickstart.js.map +1 -1
  32. package/dist/scheduler/scheduler.js +8 -2
  33. package/dist/scheduler/scheduler.js.map +1 -1
  34. package/dist/settings-api.d.ts +33 -0
  35. package/dist/settings-api.js +283 -0
  36. package/dist/settings-api.js.map +1 -0
  37. package/dist/topic-commands.d.ts +14 -0
  38. package/dist/topic-commands.js +78 -15
  39. package/dist/topic-commands.js.map +1 -1
  40. package/dist/types.d.ts +2 -0
  41. package/dist/ui/settings.html +617 -0
  42. package/dist/ui/view.html +271 -43
  43. package/dist/view-api.js +86 -37
  44. package/dist/view-api.js.map +1 -1
  45. package/package.json +1 -1
package/dist/ui/view.html CHANGED
@@ -18,6 +18,10 @@
18
18
  .group-header .caret { width: 10px; flex: 0 0 10px; }
19
19
  .group-header .gcount { margin-left: auto; opacity: .7; font-weight: 400; }
20
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; }
21
25
  .inst:hover { background: rgba(255,255,255,.04); }
22
26
  .inst.active { background: rgba(88,166,255,.14); border-left: 3px solid var(--accent); padding-left: 13px; }
23
27
  .dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 8px; }
@@ -32,7 +36,9 @@
32
36
 
33
37
  /* Card */
34
38
  #card { background: var(--panel); border-top: 1px solid var(--border); padding: 14px 18px; display: flex; gap: 16px; align-items: flex-start; }
35
- #avatar { width: 56px; height: 56px; border-radius: 8px; object-fit: cover; background: #21262d; flex: 0 0 56px; }
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; }
36
42
  #cardBody { flex: 1; min-width: 0; }
37
43
  #cardBody .row1 { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
38
44
  #cardBody .dname { font-size: 16px; font-weight: 600; }
@@ -52,47 +58,104 @@
52
58
  #edit .hint { color: var(--dim); font-size: 11px; }
53
59
  #msg { font-size: 12px; }
54
60
  .err { color: #f85149; } .ok { color: #3fb950; }
61
+ .help-btn { position: fixed; top: 10px; right: 14px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid var(--border); background: var(--panel); color: var(--fg); cursor: pointer; font-size: 16px; z-index: 60; display: inline-flex; align-items: center; justify-content: center; padding: 0; }
62
+ .help-btn:hover { background: var(--accent); border-color: var(--accent); }
63
+ .help-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; z-index: 70; }
64
+ .help-backdrop.open { display: block; }
65
+ .help-panel { position: fixed; top: 0; right: 0; height: 100%; width: 360px; max-width: 90vw; background: var(--panel); border-left: 1px solid var(--border); box-shadow: -8px 0 24px rgba(0,0,0,.4); transform: translateX(100%); transition: transform .2s ease; z-index: 80; overflow-y: auto; padding: 20px 22px; }
66
+ .help-panel.open { transform: translateX(0); }
67
+ .help-panel h3 { font-size: 16px; margin-bottom: 8px; }
68
+ .help-panel ul { margin: 8px 0 0 18px; }
69
+ .help-panel li { font-size: 13px; margin: 6px 0; color: var(--fg); }
70
+ .help-panel .close { position: absolute; top: 14px; right: 16px; cursor: pointer; color: var(--dim); font-size: 22px; line-height: 1; background: none; border: none; }
55
71
  </style>
56
72
  </head>
57
73
  <body>
58
- <div id="sidebar"><h1>AgEnD ยท View</h1><div id="list"></div></div>
74
+ <button class="help-btn" id="langBtn" title="Language" style="right:56px">๐ŸŒ</button>
75
+ <button class="help-btn" id="helpBtn" title="Help">โ„น๏ธ</button>
76
+ <div class="help-backdrop" id="helpBackdrop"></div>
77
+ <aside class="help-panel" id="helpPanel">
78
+ <button class="close" id="helpClose" title="Close">ร—</button>
79
+ <div id="helpContent"></div>
80
+ </aside>
81
+ <div id="sidebar"><h1 data-i18n="viewTitle">AgEnD ยท View</h1><div id="list"></div></div>
59
82
  <div id="main">
60
83
  <div id="term"><pre id="pre"></pre></div>
61
84
  <div id="card">
62
- <img id="avatar" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E">
85
+ <div id="avatarBox"></div>
63
86
  <div id="cardBody">
64
87
  <div class="row1"><span class="dname" id="dname">โ€”</span><span class="role" id="role"></span></div>
65
88
  <div class="meta" id="meta"></div>
66
89
  <div class="desc" id="desc"></div>
67
90
  </div>
68
- <button id="editBtn">โœ๏ธ Edit</button>
91
+ <button id="editBtn" data-i18n="editProfile">โœ๏ธ Edit</button>
69
92
  <div id="edit">
70
- <input id="fDisplay" placeholder="Display name">
71
- <input id="fRole" placeholder="Role">
72
- <textarea id="fDesc" placeholder="Description"></textarea>
93
+ <input id="fDisplay" data-i18n-ph="displayName" placeholder="Display name">
94
+ <input id="fRole" data-i18n-ph="role" placeholder="Role">
95
+ <textarea id="fDesc" data-i18n-ph="description" placeholder="Description"></textarea>
73
96
  <input id="fAvatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
74
- <input id="fToken" type="password" placeholder="web.token (required to save)">
97
+ <input id="fToken" type="password" data-i18n-ph="tokenPh" placeholder="web.token (required to save)">
75
98
  <div class="actions">
76
- <button id="saveBtn">Save</button>
77
- <button id="cancelBtn">Cancel</button>
99
+ <button id="saveBtn" data-i18n="save">Save</button>
100
+ <button id="cancelBtn" data-i18n="cancel">Cancel</button>
78
101
  <span id="msg"></span>
79
102
  </div>
80
- <div class="hint">Read access uses the view token in the URL. Saving requires the web.token (~/.agend/web.token).</div>
103
+ <div class="hint" data-i18n="saveHint">Viewing is open (read-only). Saving requires the web.token (~/.agend/web.token).</div>
81
104
  </div>
82
105
  </div>
83
106
  </div>
84
107
 
85
108
  <script>
86
109
  (() => {
87
- const token = new URLSearchParams(location.search).get("token") || "";
110
+ // GET routes are open (no token). Writes (profile/avatar/sort-order POST)
111
+ // require the web.token, which the user enters in the edit panel; we keep it
112
+ // in localStorage. A ?token= URL param is still honored as a fallback.
113
+ const urlToken = new URLSearchParams(location.search).get("token") || "";
114
+ const webToken = () => localStorage.getItem("agend_web_token") || urlToken;
88
115
  const q = (id) => document.getElementById(id);
116
+
117
+ // โ”€โ”€ i18n โ”€โ”€
118
+ const I18N = {
119
+ en: { viewTitle: "AgEnD ยท View", editProfile: "โœ๏ธ Edit", save: "Save", cancel: "Cancel",
120
+ displayName: "Display name", role: "Role", description: "Description", tokenPh: "web.token (required to save)",
121
+ saveHint: "Viewing is open (read-only). Saving requires the web.token (~/.agend/web.token).", helpTitle: "๐Ÿ“– Help",
122
+ help1: "Pick an instance on the left โ†’ its live terminal shows on the right",
123
+ help2: "Click a group header โ–ผ/โ–ถ to collapse/expand", help3: "โœ๏ธ Edit changes display name, role, description",
124
+ help4: "Drag to reorder (needs web.token permission)" },
125
+ "zh-TW": { viewTitle: "AgEnD ยท ๆชข่ฆ–", editProfile: "โœ๏ธ ็ทจ่ผฏ", save: "ๅ„ฒๅญ˜", cancel: "ๅ–ๆถˆ",
126
+ displayName: "้กฏ็คบๅ็จฑ", role: "่ง’่‰ฒ", description: "ๆ่ฟฐ", tokenPh: "web.token๏ผˆๅ„ฒๅญ˜ๆ™‚้œ€่ฆ๏ผ‰",
127
+ saveHint: "ๆชข่ฆ–็‚บๅ…ฌ้–‹๏ผˆๅ”ฏ่ฎ€๏ผ‰ใ€‚ๅ„ฒๅญ˜้œ€่ฆ web.token๏ผˆ~/.agend/web.token๏ผ‰ใ€‚", helpTitle: "๐Ÿ“– ไฝฟ็”จ่ชชๆ˜Ž",
128
+ help1: "ๅทฆๅด้ธ instance โ†’ ๅณๅด้กฏ็คบๅณๆ™‚ terminal ็•ซ้ข",
129
+ help2: "้ปž group header โ–ผ/โ–ถ ๆ”ถๅˆ/ๅฑ•้–‹", help3: "โœ๏ธ Edit ๅฏไฟฎๆ”น้กฏ็คบๅ็จฑใ€่ง’่‰ฒใ€ๆ่ฟฐ",
130
+ help4: "ๆ‹–ๆ›ณๅฏ่ชฟๆ•ด้ †ๅบ๏ผˆ้œ€ web.token ๆฌŠ้™๏ผ‰" },
131
+ };
132
+ let lang = localStorage.getItem("agend_lang") || (navigator.language && navigator.language.toLowerCase().startsWith("zh") ? "zh-TW" : "en");
133
+ if (!I18N[lang]) lang = "en";
134
+ const T = (k) => (I18N[lang] && I18N[lang][k]) || I18N.en[k] || k;
135
+ function applyI18n() {
136
+ document.querySelectorAll("[data-i18n]").forEach(e => { e.textContent = T(e.getAttribute("data-i18n")); });
137
+ document.querySelectorAll("[data-i18n-ph]").forEach(e => { e.setAttribute("placeholder", T(e.getAttribute("data-i18n-ph"))); });
138
+ const hc = q("helpContent");
139
+ if (hc) { hc.innerHTML = ""; const h3 = document.createElement("h3"); h3.textContent = T("helpTitle"); hc.append(h3);
140
+ const ul = document.createElement("ul"); for (const k of ["help1","help2","help3","help4"]) { const li = document.createElement("li"); li.textContent = T(k); ul.append(li); } hc.append(ul); }
141
+ }
142
+
89
143
  let roster = [];
144
+ let rosterByName = new Map();
90
145
  let current = null;
91
146
  let poll = null;
92
147
  let fitted = false;
93
148
  const collapsed = new Set(); // group names the user has collapsed
149
+ // Drag-sort state
150
+ let sortGroups = new Map(); // groupName -> sort_index (persisted override)
151
+ let sortInsts = new Map(); // instanceName -> sort_index (within its group)
152
+ let groupNames = []; // group display order (computed)
153
+ let instByGroup = new Map(); // group -> [instance_name, โ€ฆ] display order (computed)
154
+ let writable = false; // whether the current web.token can POST
155
+ let probed = false;
156
+ let dragItem = null; // { type:'group'|'instance', name, group }
94
157
 
95
- const api = (path) => `${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
158
+ const api = (path) => urlToken ? `${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(urlToken)}` : path;
96
159
 
97
160
  // โ”€โ”€ ANSI (SGR) โ†’ HTML โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
98
161
  const BASE = ["#000000","#cd3131","#0dbc79","#e5e510","#2472c8","#bc3fbc","#11a8cd","#e5e5e5"];
@@ -170,76 +233,232 @@
170
233
  return n || it.instance_name;
171
234
  }
172
235
 
173
- function renderList() {
174
- const list = q("list");
175
- list.innerHTML = "";
176
-
177
- // Bucket instances by group.
236
+ // Compute display order: persisted sort-order first (by index), then any
237
+ // new/unsorted groups & instances by the tag+alpha fallback. Stored in
238
+ // groupNames / instByGroup so drag handlers can mutate + persist them.
239
+ function computeOrder() {
178
240
  const groups = new Map();
179
241
  for (const it of roster) {
180
242
  const g = groupOf(it);
181
243
  if (!groups.has(g)) groups.set(g, []);
182
244
  groups.get(g).push(it);
183
245
  }
184
- // Sort group names alphabetically, but keep "Other" last.
185
- const names = [...groups.keys()].sort((a, b) => {
246
+ const gidx = (g) => (sortGroups.has(g) ? sortGroups.get(g) : Infinity);
247
+ groupNames = [...groups.keys()].sort((a, b) => {
248
+ const ia = gidx(a), ib = gidx(b);
249
+ if (ia !== ib) return ia - ib;
186
250
  if (a === "Other") return 1;
187
251
  if (b === "Other") return -1;
188
252
  return a.localeCompare(b);
189
253
  });
254
+ instByGroup = new Map();
255
+ for (const [g, items] of groups) {
256
+ // stable sort keeps roster order for equal (unsorted) indices
257
+ const names = items.map(it => it.instance_name).sort((a, b) => {
258
+ const ia = sortInsts.has(a) ? sortInsts.get(a) : Infinity;
259
+ const ib = sortInsts.has(b) ? sortInsts.get(b) : Infinity;
260
+ return ia - ib;
261
+ });
262
+ instByGroup.set(g, names);
263
+ }
264
+ }
190
265
 
191
- for (const g of names) {
192
- const items = groups.get(g);
266
+ function renderList() {
267
+ const list = q("list");
268
+ list.innerHTML = "";
269
+ for (const g of groupNames) {
270
+ const names = instByGroup.get(g) || [];
271
+ if (!names.length) continue;
193
272
  const isCollapsed = collapsed.has(g);
194
273
  const header = document.createElement("div");
195
274
  header.className = "group-header";
196
- header.innerHTML = `<span class="caret">${isCollapsed ? "โ–ถ" : "โ–ผ"}</span><span>${esc(g)}</span><span class="gcount">${items.length}</span>`;
275
+ header.draggable = writable;
276
+ header.innerHTML = `<span class="caret">${isCollapsed ? "โ–ถ" : "โ–ผ"}</span><span>${esc(g)}</span><span class="gcount">${names.length}</span>`;
197
277
  header.onclick = () => { if (collapsed.has(g)) collapsed.delete(g); else collapsed.add(g); renderList(); };
278
+ if (writable) wireDrag(header, { type: "group", name: g });
198
279
  list.appendChild(header);
199
280
  if (isCollapsed) continue;
200
281
 
201
- for (const it of items) {
282
+ for (const name of names) {
283
+ const it = rosterByName.get(name);
284
+ if (!it) continue;
202
285
  const el = document.createElement("div");
203
- el.className = "inst" + (it.instance_name === current ? " active" : "");
286
+ el.className = "inst" + (name === current ? " active" : "");
287
+ el.draggable = writable;
204
288
  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>`;
205
- el.onclick = () => select(it.instance_name);
289
+ el.onclick = () => select(name);
290
+ if (writable) wireDrag(el, { type: "instance", name, group: g });
206
291
  list.appendChild(el);
207
292
  }
208
293
  }
209
294
  }
210
295
 
296
+ // โ”€โ”€ Drag & drop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
297
+ function wireDrag(el, item) {
298
+ el.addEventListener("dragstart", (e) => {
299
+ dragItem = item;
300
+ el.classList.add("dragging");
301
+ e.dataTransfer.effectAllowed = "move";
302
+ try { e.dataTransfer.setData("text/plain", item.name); } catch { /* Firefox needs data set */ }
303
+ });
304
+ el.addEventListener("dragend", () => {
305
+ el.classList.remove("dragging");
306
+ document.querySelectorAll(".drop-before,.drop-after").forEach(n => n.classList.remove("drop-before", "drop-after"));
307
+ dragItem = null;
308
+ });
309
+ el.addEventListener("dragover", (e) => {
310
+ if (!dragItem || dragItem.type !== item.type) return; // only same kind
311
+ if (item.type === "instance" && dragItem.group !== item.group) return; // no cross-group
312
+ if (dragItem.name === item.name) return;
313
+ e.preventDefault();
314
+ const before = e.clientY < el.getBoundingClientRect().top + el.offsetHeight / 2;
315
+ el.classList.toggle("drop-before", before);
316
+ el.classList.toggle("drop-after", !before);
317
+ });
318
+ el.addEventListener("dragleave", () => el.classList.remove("drop-before", "drop-after"));
319
+ el.addEventListener("drop", (e) => {
320
+ e.preventDefault();
321
+ const before = el.classList.contains("drop-before");
322
+ el.classList.remove("drop-before", "drop-after");
323
+ if (!dragItem || dragItem.type !== item.type) return;
324
+ if (item.type === "group") reorder(groupNames, dragItem.name, item.name, before);
325
+ else if (dragItem.group === item.group) reorder(instByGroup.get(item.group), dragItem.name, item.name, before);
326
+ else return;
327
+ renderList();
328
+ persistOrder();
329
+ });
330
+ }
331
+
332
+ function reorder(arr, moving, target, before) {
333
+ if (!arr) return;
334
+ const from = arr.indexOf(moving);
335
+ if (from === -1) return;
336
+ arr.splice(from, 1);
337
+ let to = arr.indexOf(target);
338
+ if (to === -1) { arr.push(moving); return; }
339
+ if (!before) to += 1;
340
+ arr.splice(to, 0, moving);
341
+ }
342
+
343
+ function orderRows() {
344
+ const rows = [];
345
+ groupNames.forEach((g, i) => rows.push({ item_type: "group", item_name: g, sort_index: i, group_name: null }));
346
+ for (const g of groupNames) {
347
+ (instByGroup.get(g) || []).forEach((n, j) => rows.push({ item_type: "instance", item_name: n, sort_index: j, group_name: g }));
348
+ }
349
+ return rows;
350
+ }
351
+
352
+ async function persistOrder() {
353
+ // Mirror into the local maps so a mid-flight refresh doesn't revert.
354
+ sortGroups = new Map(groupNames.map((g, i) => [g, i]));
355
+ sortInsts = new Map();
356
+ for (const g of groupNames) (instByGroup.get(g) || []).forEach((n, j) => sortInsts.set(n, j));
357
+ try {
358
+ const r = await fetch("/api/sort-order", {
359
+ method: "POST",
360
+ headers: { "Content-Type": "application/json", "X-Agend-Token": webToken() },
361
+ body: JSON.stringify(orderRows()),
362
+ });
363
+ if (r.status === 401) { writable = false; renderList(); }
364
+ } catch { /* transient */ }
365
+ }
366
+
367
+ // Probe write permission by POSTing the current order (idempotent for a
368
+ // web-token user; 401 without a valid web.token โ†’ drag stays disabled).
369
+ // Re-run after the user enters a web.token so drag-sort lights up.
370
+ async function probeWrite() {
371
+ if (!webToken()) { if (writable) { writable = false; renderList(); } return; }
372
+ try {
373
+ const r = await fetch("/api/sort-order", {
374
+ method: "POST",
375
+ headers: { "Content-Type": "application/json", "X-Agend-Token": webToken() },
376
+ body: JSON.stringify(orderRows()),
377
+ });
378
+ const nowWritable = r.ok;
379
+ if (nowWritable !== writable) { writable = nowWritable; renderList(); }
380
+ } catch { /* leave disabled */ }
381
+ }
382
+
211
383
  function renderCard() {
212
384
  const it = roster.find(r => r.instance_name === current);
213
385
  if (!it) return;
214
386
  q("dname").textContent = it.display_name || it.instance_name;
215
387
  q("role").textContent = it.role || "";
216
- q("meta").textContent = `${it.instance_name} ยท ${it.backend}${it.model ? " ยท " + it.model : ""} ยท ctx ${Math.round(it.context_pct || 0)}% ยท ${it.status}`;
388
+ // Only show ctx% when we actually have a reading (>0); 0/null means the
389
+ // backend didn't report one, so hiding it avoids a misleading "0%".
390
+ const ctxPart = it.context_pct ? ` ยท ctx ${Math.round(it.context_pct)}%` : "";
391
+ q("meta").textContent = `${it.instance_name} ยท ${it.backend}${it.model ? " ยท " + it.model : ""}${ctxPart} ยท ${it.status}`;
217
392
  q("desc").textContent = it.description || "";
218
- const av = q("avatar");
219
- if (it.has_avatar) av.src = api(`/api/avatar/${encodeURIComponent(it.instance_name)}`) + "&t=" + Date.now();
220
- else av.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E";
393
+ // Only issue an <img> request when an avatar actually exists โ€” otherwise a
394
+ // 404 shows a broken-image icon. No avatar โ†’ CSS placeholder (initial).
395
+ const box = q("avatarBox");
396
+ if (it.has_avatar) {
397
+ const base = api(`/api/avatar/${encodeURIComponent(it.instance_name)}`);
398
+ // Only rebuild (and cache-bust) when the avatar's instance changed.
399
+ // Rebuilding every renderCard re-issued the <img> request each 5s
400
+ // loadRoster tick, which made the avatar flicker. Compare the raw src
401
+ // attribute (relative, as set) rather than img.src (resolved absolute).
402
+ const existingImg = box.querySelector("img");
403
+ const cur = existingImg ? (existingImg.getAttribute("src") || "") : "";
404
+ // The delimiter (base+"?" / base+"&") prevents a prefix collision between
405
+ // e.g. ".../foo" and ".../foobar".
406
+ const sameAvatar = existingImg && (cur.startsWith(base + "?") || cur.startsWith(base + "&"));
407
+ if (!sameAvatar) {
408
+ const src = base + (base.includes("?") ? "&" : "?") + "t=" + Date.now(); // cache-bust, correct query separator
409
+ box.innerHTML = `<img class="avatar-img" alt="" src="${src}">`;
410
+ }
411
+ } else {
412
+ const initial = (it.display_name || it.instance_name || "?").trim().charAt(0) || "?";
413
+ const ph = box.querySelector(".avatar-ph");
414
+ if (!ph || ph.textContent !== initial) {
415
+ box.innerHTML = `<div class="avatar-ph">${esc(initial)}</div>`;
416
+ }
417
+ }
221
418
  }
222
419
 
223
420
  async function loadRoster() {
224
421
  try {
225
- const r = await fetch(api("/api/profiles"));
226
- if (!r.ok) throw new Error("HTTP " + r.status);
227
- roster = await r.json();
422
+ const [pr, so] = await Promise.all([fetch(api("/api/profiles")), fetch(api("/api/sort-order"))]);
423
+ if (!pr.ok) throw new Error("HTTP " + pr.status);
424
+ roster = await pr.json();
425
+ rosterByName = new Map(roster.map(r => [r.instance_name, r]));
426
+ const soRows = so.ok ? await so.json() : [];
427
+ sortGroups = new Map(); sortInsts = new Map();
428
+ for (const row of soRows) {
429
+ if (row.item_type === "group") sortGroups.set(row.item_name, row.sort_index);
430
+ else if (row.item_type === "instance") sortInsts.set(row.item_name, row.sort_index);
431
+ }
228
432
  if (!current && roster.length) current = roster[0].instance_name;
433
+ computeOrder();
229
434
  renderList(); renderCard();
230
- } catch (e) { q("pre").textContent = "Failed to load roster: " + e.message + "\n(Check the token in the URL.)"; }
435
+ if (!probed) { probed = true; probeWrite(); }
436
+ } catch (e) { q("pre").textContent = "Failed to load roster: " + e.message + "\n(Is the fleet running?)"; }
231
437
  }
232
438
 
233
439
  // โ”€โ”€ Terminal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
234
440
  async function refreshPane() {
235
441
  if (!current) return;
442
+ // If the user is selecting text inside the pane, skip this refresh so the
443
+ // 800ms poll doesn't wipe the selection mid-copy. Resumes on the next tick
444
+ // once the selection is released/collapsed.
445
+ const sel = window.getSelection();
446
+ if (sel && sel.toString().length > 0 && sel.anchorNode && q("pre").contains(sel.anchorNode)) return;
447
+ const target = current; // guard against a switch mid-fetch
236
448
  try {
237
- const r = await fetch(api(`/api/pane/${encodeURIComponent(current)}`));
449
+ const r = await fetch(api(`/api/pane/${encodeURIComponent(target)}`));
450
+ if (target !== current) return; // user switched while awaiting โ€” drop stale
451
+ if (!r.ok) {
452
+ // Surface the failure instead of leaving the previous instance's output
453
+ // on screen (which reads as "switching did nothing").
454
+ q("pre").textContent = `โš  Could not load pane for ${target} (HTTP ${r.status}).`;
455
+ return;
456
+ }
238
457
  const text = await r.text();
239
- const pre = q("pre");
240
- pre.innerHTML = ansiToHtml(text);
458
+ if (target !== current) return;
459
+ q("pre").innerHTML = ansiToHtml(text);
241
460
  if (!fitted) { fitFont(text); fitted = true; }
242
- } catch { /* transient */ }
461
+ } catch { /* transient network error โ€” keep last frame */ }
243
462
  }
244
463
 
245
464
  // Fit font size so the widest line fits the terminal width. Recomputed only
@@ -258,6 +477,7 @@
258
477
  if (name === current) return;
259
478
  current = name; fitted = false;
260
479
  exitEdit();
480
+ q("pre").textContent = ""; // blank the old instance's output immediately
261
481
  renderList(); renderCard(); refreshPane();
262
482
  }
263
483
 
@@ -283,24 +503,25 @@
283
503
  async function save() {
284
504
  const it = roster.find(r => r.instance_name === current);
285
505
  if (!it) return;
286
- const webToken = q("fToken").value.trim();
287
- if (!webToken) { q("msg").className = "err"; q("msg").textContent = "web.token required"; return; }
288
- localStorage.setItem("agend_web_token", webToken);
506
+ const wt = q("fToken").value.trim();
507
+ if (!wt) { q("msg").className = "err"; q("msg").textContent = "web.token required"; return; }
508
+ localStorage.setItem("agend_web_token", wt);
289
509
  const name = encodeURIComponent(current);
290
510
  try {
291
511
  const r = await fetch(`/api/profile/${name}`, {
292
512
  method: "POST",
293
- headers: { "Content-Type": "application/json", "X-Agend-Token": webToken },
513
+ headers: { "Content-Type": "application/json", "X-Agend-Token": wt },
294
514
  body: JSON.stringify({ display_name: q("fDisplay").value, role: q("fRole").value, description: q("fDesc").value }),
295
515
  });
296
516
  if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || ("HTTP " + r.status)); }
297
517
  const file = q("fAvatar").files[0];
298
518
  if (file) {
299
- const ar = await fetch(`/api/avatar/${name}`, { method: "POST", headers: { "Content-Type": file.type, "X-Agend-Token": webToken }, body: file });
519
+ const ar = await fetch(`/api/avatar/${name}`, { method: "POST", headers: { "Content-Type": file.type, "X-Agend-Token": wt }, body: file });
300
520
  if (!ar.ok) { const e = await ar.json().catch(() => ({})); throw new Error("avatar: " + (e.error || ar.status)); }
301
521
  }
302
522
  q("msg").className = "ok"; q("msg").textContent = "Saved";
303
523
  await loadRoster();
524
+ probeWrite(); // web.token now stored โ€” enable drag-sort
304
525
  setTimeout(exitEdit, 500);
305
526
  } catch (e) { q("msg").className = "err"; q("msg").textContent = e.message; }
306
527
  }
@@ -309,6 +530,13 @@
309
530
  q("cancelBtn").onclick = exitEdit;
310
531
  q("saveBtn").onclick = save;
311
532
 
533
+ const setHelp = (open) => { q("helpPanel").classList.toggle("open", open); q("helpBackdrop").classList.toggle("open", open); };
534
+ q("helpBtn").onclick = () => setHelp(!q("helpPanel").classList.contains("open"));
535
+ q("helpClose").onclick = () => setHelp(false);
536
+ q("helpBackdrop").onclick = () => setHelp(false);
537
+ q("langBtn").onclick = () => { lang = lang === "en" ? "zh-TW" : "en"; localStorage.setItem("agend_lang", lang); applyI18n(); };
538
+ applyI18n();
539
+
312
540
  // โ”€โ”€ Boot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
313
541
  loadRoster().then(() => { refreshPane(); });
314
542
  poll = setInterval(refreshPane, 800);