@songsid/agend 2.1.7 → 2.1.8-beta.2

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.
@@ -37,8 +37,20 @@
37
37
  .tag { font-size: 12px; color: var(--muted); background: #eef2ff; color: #3730a3; padding: 1px 7px; border-radius: 10px; }
38
38
  .tag.persona { background: #fdf2f8; color: #9d174d; }
39
39
  .tag.tg { background: #e0f2fe; color: #075985; }
40
+ .tag.se-default { background: transparent; color: var(--muted); border: 1px dashed var(--border); }
40
41
  .sub { font-size: 13px; color: var(--muted); margin-top: 3px; margin-left: 19px; }
41
42
  .spacer { flex: 1; }
43
+ /* "Connections & Bots" rows carry up to ten short facts. Left to shrink, every one of them wraps inside
44
+ its own box (a chip becomes two lines, "存取模式:" and "設定" break mid-word) and the tail is clipped by
45
+ the card. So: no fact wraps inside itself; when the row is too long it wraps between facts, and the
46
+ status/settings cluster stays together at the end. Long values (env names, user ids) may break
47
+ anywhere rather than push the row wider than its card. */
48
+ .conn-row { flex-wrap: wrap; row-gap: 6px; }
49
+ .conn-row > * { flex: 0 0 auto; max-width: 100%; }
50
+ .conn-row .name, .conn-row .tag, .conn-row button, .conn-row .row-end > * { white-space: nowrap; }
51
+ .conn-row .tag { overflow: hidden; text-overflow: ellipsis; }
52
+ .conn-row .sub { flex: 0 1 auto; min-width: 0; overflow-wrap: anywhere; }
53
+ .conn-row .row-end { display: inline-flex; align-items: center; gap: 10px; margin-left: auto; }
42
54
  button { font-family: inherit; font-size: 13px; border-radius: var(--radius-sm); padding: 6px 12px; border: 1px solid var(--border); background: #fff; color: var(--fg); cursor: pointer; }
43
55
  button:hover { background: #f3f4f6; }
44
56
  button.primary { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
@@ -252,7 +264,7 @@
252
264
  runtimeResources: "Runtime & Resources", autoPause: "Auto-pause after", minutes: "minutes", inheritDefault: "Inherit from defaults",
253
265
  statusEmojis: "Status emojis", statusEmojisInstance: "This agent's status emojis (override the connection's)", statusEmojisChannel: "Status emojis for this connection's bots",
254
266
  se_received: "Received", se_queued: "Queued", se_processing: "Processing", se_delivered: "Delivered", se_failed: "Failed", se_progress_prefix: "Progress prefix",
255
- se_src_instance: "agent", se_src_platform: "connection", se_pick: "Pick…", se_useDefault: "Use the default", se_fallback: "the default is used instead",
267
+ se_src_instance: "agent", se_src_platform: "connection", se_src_builtin: "default", se_pick: "Pick…", se_useDefault: "Use the default", se_fallback: "the default is used instead",
256
268
  se_unicode: "Emoji", se_telegramOnly: "Telegram allows only these reactions", se_serverEmojis: "This server's emojis", se_noServerEmojis: "This server has no custom emojis.", se_unavailable: "unavailable",
257
269
  se_hint: "Empty keeps the default. The preview is resolved by AgEnD exactly as the bot will react.",
258
270
  detection: "Detection", startup: "Startup", backendSection: "Backend", advancedSection: "Advanced & Access", hangEnabled: "Hang detector enabled", hangTimeout: "Stuck timeout", agentMode: "Agent mode", toolSet: "Tool set", toolProgress: "Tool progress", replyCompletionGuard: "Require a delivered reply", replyGuardSupported: "Active for Claude Code in MCP mode.", replyGuardUnsupported: "Stored but inactive: {0} in {1} mode does not support reply-drop recovery.", logLevel: "Log level", concurrency: "Startup concurrency", staggerDelay: "Stagger delay", lightweight: "Lightweight mode", modelFailover: "Fallback model", displayName: "Display name", pause: "Pause", wake: "Wake", accessMode: "Access mode", affected: "Affected instances",
@@ -289,7 +301,10 @@
289
301
  applyRestartHint: "This change needs AgEnD itself restarted. You can restart it here, or run /restart in your chat channel.",
290
302
  applyBusy: "AgEnD is already reloading its configuration. Your changes are saved — try Apply again in a moment.",
291
303
  restartFleetButton: "Restart AgEnD", restartFleetConfirm: "Restart AgEnD now?\n\nEvery agent stops and starts again, work in progress is interrupted, and the panel is unreachable for up to a minute. A notice is posted to your chat channel.",
292
- restartFleetStarted: "Restarting AgEnD — this page keeps checking.", restartFleetRefused: "Restart refused: {0}",
304
+ restartFleetStarted: "Restarting AgEnD — this page keeps checking.",
305
+ restartFleetBusy: "Restarting…", restartFleetTitle: "Restarting AgEnD…",
306
+ restartFleetWaiting: "AgEnD is restarting. This page keeps checking and will say when it is back — there is nothing to press.",
307
+ working: "Working…", restartFleetRefused: "Restart refused: {0}",
293
308
  restartFleetRateLimited: "Restart refused: {0} (try again in about {1} minutes)",
294
309
  signatureMismatch: "fleet.yaml and the running configuration disagree on: {0}. Restarting will not clear this — check fleet.log first.",
295
310
  applyStillWorking: "Still restarting ({0}s) — AgEnD is still working; this page keeps checking.",
@@ -301,7 +316,7 @@
301
316
  "zh-TW": {
302
317
  statusEmojis: "狀態 emoji", statusEmojisInstance: "此 Agent 的狀態 emoji(覆蓋連線設定)", statusEmojisChannel: "此連線 bot 的狀態 emoji",
303
318
  se_received: "已收到", se_queued: "排隊中", se_processing: "處理中", se_delivered: "已送達", se_failed: "失敗", se_progress_prefix: "進度前綴",
304
- se_src_instance: "Agent", se_src_platform: "連線", se_pick: "選擇…", se_useDefault: "使用預設", se_fallback: "改用預設值",
319
+ se_src_instance: "Agent", se_src_platform: "連線", se_src_builtin: "預設", se_pick: "選擇…", se_useDefault: "使用預設", se_fallback: "改用預設值",
305
320
  se_unicode: "Emoji", se_telegramOnly: "Telegram 只允許這些反應", se_serverEmojis: "此伺服器的 emoji", se_noServerEmojis: "此伺服器沒有自訂 emoji。", se_unavailable: "無法使用",
306
321
  se_hint: "留空即使用預設值。預覽由 AgEnD 依 bot 實際 react 的方式解析。",
307
322
  title: "⚙️ AgEnD 設定", reloadFleet: "↻ 重新載入", myAgents: "🤖 我的 Agent", bots: "🔌 機器人", classicChannels: "🎮 ClassicBot 頻道", noClassic: "沒有 ClassicBot 頻道 — 在聊天中用 /start 建立。",
@@ -345,7 +360,10 @@
345
360
  applyRestartHint: "此變更需要重新啟動 AgEnD 本身。可以在這裡重新啟動,或在聊天頻道執行 /restart。",
346
361
  applyBusy: "AgEnD 正在重新載入設定。你的變更已儲存,請稍候再按一次套用。",
347
362
  restartFleetButton: "重新啟動 AgEnD", restartFleetConfirm: "現在重新啟動 AgEnD?\n\n所有 agent 都會停止再啟動,進行中的工作會被中斷,面板會有最多約一分鐘無法連線。聊天頻道會收到一則通知。",
348
- restartFleetStarted: "AgEnD 重新啟動中 —— 此頁面會持續確認。", restartFleetRefused: "重新啟動被拒絕:{0}",
363
+ restartFleetStarted: "AgEnD 重新啟動中 —— 此頁面會持續確認。",
364
+ restartFleetBusy: "重啟中…", restartFleetTitle: "AgEnD 重新啟動中…",
365
+ restartFleetWaiting: "AgEnD 正在重新啟動。此頁面會持續確認,恢復後會告訴你 —— 不需要再按任何按鈕。",
366
+ working: "執行中…", restartFleetRefused: "重新啟動被拒絕:{0}",
349
367
  restartFleetRateLimited: "重新啟動被拒絕:{0}(約 {1} 分鐘後可再試)",
350
368
  signatureMismatch: "fleet.yaml 與執行中的設定在這些項目上不一致:{0}。重新啟動無法解決,請先查看 fleet.log。",
351
369
  applyStillWorking: "仍在重啟中({0} 秒)—— AgEnD 還在處理,此頁面會持續確認。",
@@ -525,7 +543,9 @@
525
543
  for (const entry of r.body.entries) {
526
544
  preview.append(el("span", { class: "se-item", title: `${entry.key}: ${entry.value}` }, emojiGlyph(entry),
527
545
  el("span", {}, t(`se_${entry.key}`)),
528
- entry.source === "builtin" ? null : el("span", { class: "tag" }, t(`se_src_${entry.source}`))));
546
+ // Say where every value comes from, defaults included: 👀 is the built-in for three statuses, and
547
+ // an untagged 👀 left under "Processing" after Received is changed reads as if it had moved there.
548
+ el("span", { class: "tag" + (entry.source === "builtin" ? " se-default" : "") }, t(`se_src_${entry.source}`))));
529
549
  }
530
550
  problems.className = r.body.problems.length ? "feedback error" : "feedback";
531
551
  problems.textContent = r.body.problems.map(p => `⚠ ${t(`se_${p.key}`)}: ${p.problem} — ${t("se_fallback")}`).join(" ");
@@ -881,7 +901,8 @@
881
901
  : row.status === "running" ? "…" : "·";
882
902
  // restart-required is terminal and is NOT success: the value is saved and a
883
903
  // running AgEnD cannot adopt it.
884
- const note = row.status === "restart-required" ? t("applyRestartNeeded")
904
+ const note = row.target === "fleet" && row.status === "running" ? t("restartFleetTitle")
905
+ : row.status === "restart-required" ? t("applyRestartNeeded")
885
906
  : row.settled_by === "no-change" ? t("applyNoChange")
886
907
  : row.settled_by === "fleet-restart" ? "(restart)"
887
908
  : row.kind === "hot" ? t("impactNow") : t("impactAgent");
@@ -892,17 +913,31 @@
892
913
  row.error ? el("span", { class: "err" }, row.error) : null);
893
914
  }
894
915
 
895
- async function restartFleet(job) {
916
+ // One restart at a time, and a button that says so. Without this the panel re-drew a fresh, enabled
917
+ // "Restart AgEnD" button the instant the request was accepted, so a second press asked again.
918
+ let restartingFleet = false;
919
+ async function restartFleet(job, button) {
920
+ if (restartingFleet) return;
896
921
  // Its own confirmation, its own key. A single Apply must never be able to
897
922
  // carry a full-fleet restart along with it.
898
923
  if (!confirm(t("restartFleetConfirm"))) return;
924
+ restartingFleet = true;
925
+ const label = button?.textContent;
926
+ if (button) { button.disabled = true; button.textContent = t("restartFleetBusy"); }
899
927
  const key = (crypto.randomUUID ? crypto.randomUUID() : `r-${Date.now()}-${Math.random().toString(36).slice(2)}`);
900
- const res = await api("/api/settings/restart-fleet", {
901
- method: "POST",
902
- headers: { "Idempotency-Key": key },
903
- body: JSON.stringify({ job_id: job.id, confirm: "restart-agend", idempotency_key: key }),
904
- });
928
+ let res;
929
+ try {
930
+ res = await api("/api/settings/restart-fleet", {
931
+ method: "POST",
932
+ headers: { "Idempotency-Key": key },
933
+ body: JSON.stringify({ job_id: job.id, confirm: "restart-agend", idempotency_key: key }),
934
+ });
935
+ } catch {
936
+ res = { ok: false, status: 0, body: null };
937
+ }
905
938
  if (!res.ok) {
939
+ restartingFleet = false;
940
+ if (button?.isConnected) { button.disabled = false; button.textContent = label; }
906
941
  const secs = res.body?.retry_after_seconds;
907
942
  showBanner(secs
908
943
  ? tf("restartFleetRateLimited", res.body?.error || "", Math.ceil(secs / 60))
@@ -910,17 +945,32 @@
910
945
  return;
911
946
  }
912
947
  showBanner(t("restartFleetStarted"));
913
- await watchApplyJob(job);
948
+ // The server has moved this job's fleet row to "running" (and will settle it after the restart). Watch
949
+ // *that* job: the one in hand still says "restart-required" and is finished, so watching it ended at once.
950
+ const fresh = await api(`/api/settings/apply/${encodeURIComponent(job.id)}`).catch(() => null);
951
+ const watching = fresh?.ok && fresh.body?.id ? fresh.body : {
952
+ ...job, status: "running",
953
+ targets: job.targets.map(row => row.target === "fleet" ? { ...row, status: "running" } : row),
954
+ };
955
+ try {
956
+ await watchApplyJob(watching);
957
+ await reload();
958
+ } finally {
959
+ restartingFleet = false;
960
+ }
914
961
  }
915
962
 
916
963
  function renderApplyJob(job) {
917
964
  $("applyPanel").classList.add("open");
918
- $("applyTitle").textContent = job.status === "done"
965
+ const fleetRestarting = job.status === "running" && job.targets.some(row => row.target === "fleet" && row.status === "running");
966
+ $("applyTitle").textContent = fleetRestarting ? t("restartFleetTitle")
967
+ : job.status === "done"
919
968
  ? (job.targets.some(r => r.status === "failed") ? t("applyPartial") : t("applyDone"))
920
969
  : job.status === "failed" ? t("applyPartial") : t("applying");
921
970
  const needsRestart = job.targets.some(row => row.status === "restart-required");
922
971
  const mismatch = state.schema.fleet_signature_mismatch;
923
- $("applyNote").textContent = job.overdue
972
+ $("applyNote").textContent = fleetRestarting ? t("restartFleetWaiting")
973
+ : job.overdue
924
974
  ? tf("applyStillWorking", Math.round(job.elapsed_ms / 1000))
925
975
  : mismatch && needsRestart ? tf("signatureMismatch", mismatch.join(", "))
926
976
  : needsRestart ? t("applyRestartHint") : "";
@@ -928,8 +978,11 @@
928
978
  for (const row of job.targets) rows.append(applyRow(row));
929
979
  // A restart that cannot clear the mismatch is worse than no button at all.
930
980
  if (needsRestart && !mismatch) {
931
- rows.append(el("div", { class: "apply-row" },
932
- el("button", { class: "primary", onclick: () => restartFleet(job) }, t("restartFleetButton"))));
981
+ const restartButton = el("button", { class: "primary", onclick: ev => restartFleet(job, ev.currentTarget) },
982
+ restartingFleet ? t("restartFleetBusy") : t("restartFleetButton"));
983
+ // While a restart is under way this stays, disabled and saying so, if anything re-draws the panel.
984
+ if (restartingFleet) restartButton.disabled = true;
985
+ rows.append(el("div", { class: "apply-row" }, restartButton));
933
986
  }
934
987
  if (job.error) rows.append(el("div", { class: "apply-row err" }, job.error));
935
988
  }
@@ -944,7 +997,10 @@
944
997
  let current = job;
945
998
  while (current.status === "running" && Date.now() < deadline) {
946
999
  await new Promise(r => setTimeout(r, 1000));
947
- const res = await api(`/api/settings/apply/${encodeURIComponent(job.id)}`);
1000
+ // A restart takes the server down under this request: the fetch rejects until AgEnD is back. That is
1001
+ // the expected middle of a restart, not the end of the watch.
1002
+ let res;
1003
+ try { res = await api(`/api/settings/apply/${encodeURIComponent(job.id)}`); } catch { continue; }
948
1004
  if (res.status === 404) {
949
1005
  // The job outlives the process that made it, so a 404 means it aged out
950
1006
  // of the store — not that it never happened.
@@ -1045,9 +1101,9 @@
1045
1101
  const form = agentEditForm(name, inst);
1046
1102
  openModal({ title: shortName(name, inst), subtitle: "ID: " + name, body: form.box, impacts: form.impacts, stage: form.stage });
1047
1103
  } }, t("settingsButton")),
1048
- running ? el("button", { class: "sm", onclick: () => pauseWakeAgent(name, "pause") }, t("pause")) : null,
1049
- paused ? el("button", { class: "sm", onclick: () => pauseWakeAgent(name, "wake") }, t("wake")) : null,
1050
- !paused ? el("button", { class: "sm", onclick: () => toggleAgent(name, running) }, running ? t("stop") : t("start")) : null,
1104
+ running ? el("button", { class: "sm", onclick: ev => pauseWakeAgent(name, "pause", ev.currentTarget) }, t("pause")) : null,
1105
+ paused ? el("button", { class: "sm", onclick: ev => pauseWakeAgent(name, "wake", ev.currentTarget) }, t("wake")) : null,
1106
+ !paused ? el("button", { class: "sm", onclick: ev => toggleAgent(name, running, ev.currentTarget) }, running ? t("stop") : t("start")) : null,
1051
1107
  el("details", { class: "more-menu" },
1052
1108
  el("summary", { title: "More actions" }, "⋯"),
1053
1109
  el("div", { class: "menu" }, el("button", { class: "sm danger", onclick: () => delAgent(name) }, "Delete Agent"))),
@@ -1242,15 +1298,40 @@
1242
1298
  "instance.lightweight", "instance.model_failover", "instance.systemPrompt",
1243
1299
  "instance.tags", "instance.general_topic", "instance.status_emojis",
1244
1300
  ];
1245
- async function toggleAgent(name, running) {
1246
- if (running) await api(`/stop/${encodeURIComponent(name)}`, { method: "POST" });
1247
- else await api(`/api/instance/${encodeURIComponent(name)}/start`, { method: "POST" });
1248
- setTimeout(reloadLive, 800);
1249
- }
1250
- async function pauseWakeAgent(name, action) {
1251
- const res = await api(`/api/settings/instances/${encodeURIComponent(name)}/${action}`, { method: "POST" });
1252
- if (!res.ok) alert(res.body?.error || tf("actionFailed", action));
1253
- await reloadLive();
1301
+ // Start, stop, pause and wake take a moment and used to give no sign of it, so they were pressed again.
1302
+ // One action per agent at a time; the row's buttons are disabled and the pressed one says it is working.
1303
+ const busyAgents = new Set();
1304
+ async function runAgentAction(name, button, work) {
1305
+ if (busyAgents.has(name)) return;
1306
+ busyAgents.add(name);
1307
+ const row = button?.closest(".item");
1308
+ const buttons = row ? [...row.querySelectorAll("button")] : (button ? [button] : []);
1309
+ const label = button?.textContent;
1310
+ for (const b of buttons) b.disabled = true;
1311
+ if (button) button.textContent = t("working");
1312
+ try { await work(); }
1313
+ finally {
1314
+ busyAgents.delete(name);
1315
+ // The list is normally redrawn by then; if it was not (a failed reload), give the buttons back.
1316
+ for (const b of buttons) if (b.isConnected) b.disabled = false;
1317
+ if (button?.isConnected) button.textContent = label;
1318
+ }
1319
+ }
1320
+ function toggleAgent(name, running, button) {
1321
+ return runAgentAction(name, button, async () => {
1322
+ const res = running ? await api(`/stop/${encodeURIComponent(name)}`, { method: "POST" })
1323
+ : await api(`/api/instance/${encodeURIComponent(name)}/start`, { method: "POST" });
1324
+ if (!res.ok) showBanner(`${tf("actionFailed", running ? t("stop") : t("start"))}: ${res.body?.error || res.status}`, true);
1325
+ await new Promise(r => setTimeout(r, 800));
1326
+ await reloadLive();
1327
+ });
1328
+ }
1329
+ function pauseWakeAgent(name, action, button) {
1330
+ return runAgentAction(name, button, async () => {
1331
+ const res = await api(`/api/settings/instances/${encodeURIComponent(name)}/${action}`, { method: "POST" });
1332
+ if (!res.ok) alert(res.body?.error || tf("actionFailed", action));
1333
+ await reloadLive();
1334
+ });
1254
1335
  }
1255
1336
  async function delAgent(name) {
1256
1337
  if (!confirm(tf("deleteAgent", name))) return;
@@ -1295,7 +1376,7 @@
1295
1376
  chs.forEach((ch, i) => {
1296
1377
  const item = el("div", { class: "item" });
1297
1378
  const type = ch.type || "?";
1298
- item.append(el("div", { class: "item-row" },
1379
+ item.append(el("div", { class: "item-row conn-row" },
1299
1380
  el("span", { class: "name" }, type === "telegram" ? "Telegram" : "Discord"),
1300
1381
  el("span", { class: "tag" + (i === 0 ? "" : " persona") + (type === "telegram" ? " tg" : "") }, ch.id || chLabel(i)),
1301
1382
  el("span", { class: "sub", style: "margin:0" }, ch.bot_token_env || "(no token env)"),
@@ -1305,13 +1386,14 @@
1305
1386
  (ch.access?.allowed_users || []).length
1306
1387
  ? (ch.access.allowed_users).slice(0, 3).join(", ") + ((ch.access.allowed_users).length > 3 ? ` +${(ch.access.allowed_users).length - 3}` : "")
1307
1388
  : t("noAllowedUsers")),
1308
- el("span", { class: "spacer" }),
1309
- el("span", { class: "tag", title: "secret status" }, state.connections.find(c => (c.id || c.type) === (ch.id || ch.type))?.token_present ? "Token configured" : "Token missing"),
1310
- el("span", { style: `color:${state.fleetUp ? "var(--green)" : "var(--red)"};font-size:13px` }, state.fleetUp ? "Connected" : "Problem"),
1311
- el("button", { class: "sm ghost", onclick: () => {
1312
- const form = botEditForm(ch, i);
1313
- openModal({ title: type === "telegram" ? "Telegram" : "Discord", subtitle: ch.id || chLabel(i), body: form.box, impacts: form.impacts, stage: form.stage });
1314
- } }, t("settingsButton")),
1389
+ el("span", { class: "row-end" },
1390
+ el("span", { class: "tag", title: "secret status" }, state.connections.find(c => (c.id || c.type) === (ch.id || ch.type))?.token_present ? "Token configured" : "Token missing"),
1391
+ el("span", { style: `color:${state.fleetUp ? "var(--green)" : "var(--red)"};font-size:13px` }, state.fleetUp ? "Connected" : "Problem"),
1392
+ el("button", { class: "sm ghost", onclick: () => {
1393
+ const form = botEditForm(ch, i);
1394
+ openModal({ title: type === "telegram" ? "Telegram" : "Discord", subtitle: ch.id || chLabel(i), body: form.box, impacts: form.impacts, stage: form.stage });
1395
+ } }, t("settingsButton")),
1396
+ ),
1315
1397
  ));
1316
1398
  host.append(item);
1317
1399
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@songsid/agend",
3
- "version": "2.1.7",
3
+ "version": "2.1.8-beta.2",
4
4
  "description": "Multi-agent fleet daemon — run any coding CLI (Claude, Gemini, Codex, OpenCode) from Telegram",
5
5
  "type": "module",
6
6
  "bin": {