@songsid/agend 2.1.7 → 2.1.8-beta.10

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 (115) hide show
  1. package/dist/agent-cli.js +14 -0
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +4 -1
  4. package/dist/agent-endpoint.js +10 -1
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/backend/claude-code.d.ts +35 -5
  7. package/dist/backend/claude-code.js +288 -17
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/cli-env-probe-worker.d.ts +1 -0
  10. package/dist/backend/cli-env-probe-worker.js +25 -0
  11. package/dist/backend/cli-env-probe-worker.js.map +1 -0
  12. package/dist/backend/codex-session-lookup.d.ts +16 -0
  13. package/dist/backend/codex-session-lookup.js +76 -2
  14. package/dist/backend/codex-session-lookup.js.map +1 -1
  15. package/dist/backend/codex.d.ts +7 -4
  16. package/dist/backend/codex.js +120 -30
  17. package/dist/backend/codex.js.map +1 -1
  18. package/dist/backend/credential-profile.d.ts +9 -0
  19. package/dist/backend/credential-profile.js +20 -0
  20. package/dist/backend/credential-profile.js.map +1 -1
  21. package/dist/backend/grok.js +19 -0
  22. package/dist/backend/grok.js.map +1 -1
  23. package/dist/backend/muse.d.ts +6 -0
  24. package/dist/backend/muse.js +37 -3
  25. package/dist/backend/muse.js.map +1 -1
  26. package/dist/backend/types.d.ts +21 -2
  27. package/dist/backend/types.js +14 -3
  28. package/dist/backend/types.js.map +1 -1
  29. package/dist/channel/adapters/discord.d.ts +16 -4
  30. package/dist/channel/adapters/discord.js +20 -5
  31. package/dist/channel/adapters/discord.js.map +1 -1
  32. package/dist/channel/mcp-tools.js +6 -0
  33. package/dist/channel/mcp-tools.js.map +1 -1
  34. package/dist/cli.js +138 -48
  35. package/dist/cli.js.map +1 -1
  36. package/dist/config-validator.js +11 -0
  37. package/dist/config-validator.js.map +1 -1
  38. package/dist/config.d.ts +1 -0
  39. package/dist/config.js +1 -1
  40. package/dist/config.js.map +1 -1
  41. package/dist/cross-instance-envelope.d.ts +7 -0
  42. package/dist/cross-instance-envelope.js +19 -1
  43. package/dist/cross-instance-envelope.js.map +1 -1
  44. package/dist/daemon.d.ts +151 -6
  45. package/dist/daemon.js +550 -72
  46. package/dist/daemon.js.map +1 -1
  47. package/dist/delivery-outbox.d.ts +9 -1
  48. package/dist/delivery-outbox.js +25 -4
  49. package/dist/delivery-outbox.js.map +1 -1
  50. package/dist/fleet-context.d.ts +5 -0
  51. package/dist/fleet-context.js.map +1 -1
  52. package/dist/fleet-manager.d.ts +140 -18
  53. package/dist/fleet-manager.js +733 -106
  54. package/dist/fleet-manager.js.map +1 -1
  55. package/dist/forged-envelope-scan.d.ts +160 -0
  56. package/dist/forged-envelope-scan.js +312 -0
  57. package/dist/forged-envelope-scan.js.map +1 -0
  58. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +52 -0
  59. package/dist/instance-lifecycle.d.ts +81 -6
  60. package/dist/instance-lifecycle.js +201 -17
  61. package/dist/instance-lifecycle.js.map +1 -1
  62. package/dist/locale.js +34 -4
  63. package/dist/locale.js.map +1 -1
  64. package/dist/login-controller.js +11 -4
  65. package/dist/login-controller.js.map +1 -1
  66. package/dist/login-flows.d.ts +18 -1
  67. package/dist/login-flows.js +9 -2
  68. package/dist/login-flows.js.map +1 -1
  69. package/dist/ls-rows.d.ts +17 -0
  70. package/dist/ls-rows.js +73 -0
  71. package/dist/ls-rows.js.map +1 -1
  72. package/dist/outbound-handlers.d.ts +3 -0
  73. package/dist/outbound-handlers.js +3 -3
  74. package/dist/outbound-handlers.js.map +1 -1
  75. package/dist/outbound-schemas.d.ts +21 -4
  76. package/dist/outbound-schemas.js +12 -0
  77. package/dist/outbound-schemas.js.map +1 -1
  78. package/dist/paths.js +4 -2
  79. package/dist/paths.js.map +1 -1
  80. package/dist/pause-marker.d.ts +10 -1
  81. package/dist/pause-marker.js +26 -2
  82. package/dist/pause-marker.js.map +1 -1
  83. package/dist/service-installer.d.ts +8 -0
  84. package/dist/service-installer.js +36 -1
  85. package/dist/service-installer.js.map +1 -1
  86. package/dist/settings-api.d.ts +8 -8
  87. package/dist/settings-api.js +12 -5
  88. package/dist/settings-api.js.map +1 -1
  89. package/dist/status-emojis.d.ts +19 -0
  90. package/dist/status-emojis.js.map +1 -1
  91. package/dist/tool-permissions.js +8 -2
  92. package/dist/tool-permissions.js.map +1 -1
  93. package/dist/topic-commands.d.ts +9 -0
  94. package/dist/topic-commands.js +64 -21
  95. package/dist/topic-commands.js.map +1 -1
  96. package/dist/transcript-monitor.js +3 -0
  97. package/dist/transcript-monitor.js.map +1 -1
  98. package/dist/transcript-sources.d.ts +63 -0
  99. package/dist/transcript-sources.js +171 -27
  100. package/dist/transcript-sources.js.map +1 -1
  101. package/dist/types.d.ts +25 -0
  102. package/dist/types.js +9 -1
  103. package/dist/types.js.map +1 -1
  104. package/dist/ui/settings.html +143 -49
  105. package/dist/wake-coordinator.d.ts +143 -0
  106. package/dist/wake-coordinator.js +321 -0
  107. package/dist/wake-coordinator.js.map +1 -0
  108. package/dist/web-api.d.ts +3 -1
  109. package/dist/web-api.js +1 -1
  110. package/dist/web-api.js.map +1 -1
  111. package/dist/web-terminal.d.ts +2 -0
  112. package/dist/web-terminal.js +3 -6
  113. package/dist/web-terminal.js.map +1 -1
  114. package/package.json +1 -1
  115. package/templates/systemd.service.ejs +7 -0
@@ -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,8 +264,8 @@
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",
256
- 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",
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",
268
+ se_unicode: "Emoji", se_telegramOnly: "Telegram allows only these reactions", se_serverEmojis: "Server emojis", se_noServerEmojis: "No custom emojis on this server.", se_primaryServer: "primary", se_externalEmojiHint: "Another server's emoji needs the bot's Use External Emojis permission in the channel it reacts in.", 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",
259
271
  language: "Language (bot replies)", defaultBackend: "Default backend", defaultModel: "Default model", allowedUsers: "Allowed users (primary bot)",
@@ -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,8 +316,8 @@
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: "改用預設值",
305
- se_unicode: "Emoji", se_telegramOnly: "Telegram 只允許這些反應", se_serverEmojis: "此伺服器的 emoji", se_noServerEmojis: "此伺服器沒有自訂 emoji。", se_unavailable: "無法使用",
319
+ se_src_instance: "Agent", se_src_platform: "連線", se_src_builtin: "預設", se_pick: "選擇…", se_useDefault: "使用預設", se_fallback: "改用預設值",
320
+ se_unicode: "Emoji", se_telegramOnly: "Telegram 只允許這些反應", se_serverEmojis: "伺服器 emoji", se_noServerEmojis: "此伺服器沒有自訂 emoji。", se_primaryServer: "主伺服器", se_externalEmojiHint: "使用其他伺服器的 emoji,bot 在要加反應的頻道需有「使用外部表情符號」權限。", se_unavailable: "無法使用",
306
321
  se_hint: "留空即使用預設值。預覽由 AgEnD 依 bot 實際 react 的方式解析。",
307
322
  title: "⚙️ AgEnD 設定", reloadFleet: "↻ 重新載入", myAgents: "🤖 我的 Agent", bots: "🔌 機器人", classicChannels: "🎮 ClassicBot 頻道", noClassic: "沒有 ClassicBot 頻道 — 在聊天中用 /start 建立。",
308
323
  general: "⚙️ 一般設定", whatsNew: "🆕 更新重點", advanced: "📋 進階",
@@ -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(" ");
@@ -549,15 +569,27 @@
549
569
  const list = await fetchGuildEmojis(channelId, refreshList);
550
570
  guildBox.innerHTML = "";
551
571
  if (list.error) { guildBox.append(el("div", { class: "feedback error" }, `✗ ${list.error}`)); return; }
552
- if (!list.emojis.length) { guildBox.append(el("div", { class: "hint" }, t("se_noServerEmojis"))); return; }
553
- // An unavailable emoji (e.g. the server lost the Boost tier it
554
- // needs) cannot be reacted with; picking it would preview fine and
555
- // then fail silently on every status. Shown, but not pickable.
556
- guildBox.append(grid(list.emojis.map(e => el("button", {
557
- type: "button", class: "sm" + (e.available ? "" : " off"), title: `:${e.name}:${e.available ? "" : ` (${t("se_unavailable")})`}`,
558
- ...(e.available ? {} : { disabled: "disabled", "aria-disabled": "true" }),
559
- onclick: () => { if (e.available) choose(e.value); },
560
- }, emojiImg(e.image_url, `:${e.name}:`)))));
572
+ // One group per server the bot can draw on (#1021), primary first;
573
+ // a bot reacts with a custom emoji from any server it is in.
574
+ const groups = list.guilds;
575
+ // Discord checks Use External Emojis in the channel being reacted in.
576
+ if (groups.length > 1) guildBox.append(el("div", { class: "hint" }, t("se_externalEmojiHint")));
577
+ for (const g of groups) {
578
+ if (groups.length > 1) {
579
+ guildBox.append(el("div", { class: "hint se-guild" }, g.name || g.id,
580
+ ...(g.primary ? [" ", el("span", { class: "tag" }, t("se_primaryServer"))] : [])));
581
+ }
582
+ if (g.error) { guildBox.append(el("div", { class: "feedback error" }, `✗ ${g.error}`)); continue; }
583
+ if (!g.emojis.length) { guildBox.append(el("div", { class: "hint" }, t("se_noServerEmojis"))); continue; }
584
+ // An unavailable emoji (e.g. the server lost the Boost tier it
585
+ // needs) cannot be reacted with; picking it would preview fine and
586
+ // then fail silently on every status. Shown, but not pickable.
587
+ guildBox.append(grid(g.emojis.map(e => el("button", {
588
+ type: "button", class: "sm" + (e.available ? "" : " off"), title: `:${e.name}:${e.available ? "" : ` (${t("se_unavailable")})`}`,
589
+ ...(e.available ? {} : { disabled: "disabled", "aria-disabled": "true" }),
590
+ onclick: () => { if (e.available) choose(e.value); },
591
+ }, emojiImg(e.image_url, `:${e.name}:`)))));
592
+ }
561
593
  };
562
594
  picker.append(el("div", { class: "hint", style: "display:flex;gap:8px;align-items:center" }, t("se_serverEmojis"),
563
595
  el("button", { type: "button", class: "sm ghost", onclick: () => load(true) }, "↻")), guildBox);
@@ -881,7 +913,8 @@
881
913
  : row.status === "running" ? "…" : "·";
882
914
  // restart-required is terminal and is NOT success: the value is saved and a
883
915
  // running AgEnD cannot adopt it.
884
- const note = row.status === "restart-required" ? t("applyRestartNeeded")
916
+ const note = row.target === "fleet" && row.status === "running" ? t("restartFleetTitle")
917
+ : row.status === "restart-required" ? t("applyRestartNeeded")
885
918
  : row.settled_by === "no-change" ? t("applyNoChange")
886
919
  : row.settled_by === "fleet-restart" ? "(restart)"
887
920
  : row.kind === "hot" ? t("impactNow") : t("impactAgent");
@@ -892,17 +925,31 @@
892
925
  row.error ? el("span", { class: "err" }, row.error) : null);
893
926
  }
894
927
 
895
- async function restartFleet(job) {
928
+ // One restart at a time, and a button that says so. Without this the panel re-drew a fresh, enabled
929
+ // "Restart AgEnD" button the instant the request was accepted, so a second press asked again.
930
+ let restartingFleet = false;
931
+ async function restartFleet(job, button) {
932
+ if (restartingFleet) return;
896
933
  // Its own confirmation, its own key. A single Apply must never be able to
897
934
  // carry a full-fleet restart along with it.
898
935
  if (!confirm(t("restartFleetConfirm"))) return;
936
+ restartingFleet = true;
937
+ const label = button?.textContent;
938
+ if (button) { button.disabled = true; button.textContent = t("restartFleetBusy"); }
899
939
  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
- });
940
+ let res;
941
+ try {
942
+ res = await api("/api/settings/restart-fleet", {
943
+ method: "POST",
944
+ headers: { "Idempotency-Key": key },
945
+ body: JSON.stringify({ job_id: job.id, confirm: "restart-agend", idempotency_key: key }),
946
+ });
947
+ } catch {
948
+ res = { ok: false, status: 0, body: null };
949
+ }
905
950
  if (!res.ok) {
951
+ restartingFleet = false;
952
+ if (button?.isConnected) { button.disabled = false; button.textContent = label; }
906
953
  const secs = res.body?.retry_after_seconds;
907
954
  showBanner(secs
908
955
  ? tf("restartFleetRateLimited", res.body?.error || "", Math.ceil(secs / 60))
@@ -910,17 +957,32 @@
910
957
  return;
911
958
  }
912
959
  showBanner(t("restartFleetStarted"));
913
- await watchApplyJob(job);
960
+ // The server has moved this job's fleet row to "running" (and will settle it after the restart). Watch
961
+ // *that* job: the one in hand still says "restart-required" and is finished, so watching it ended at once.
962
+ const fresh = await api(`/api/settings/apply/${encodeURIComponent(job.id)}`).catch(() => null);
963
+ const watching = fresh?.ok && fresh.body?.id ? fresh.body : {
964
+ ...job, status: "running",
965
+ targets: job.targets.map(row => row.target === "fleet" ? { ...row, status: "running" } : row),
966
+ };
967
+ try {
968
+ await watchApplyJob(watching);
969
+ await reload();
970
+ } finally {
971
+ restartingFleet = false;
972
+ }
914
973
  }
915
974
 
916
975
  function renderApplyJob(job) {
917
976
  $("applyPanel").classList.add("open");
918
- $("applyTitle").textContent = job.status === "done"
977
+ const fleetRestarting = job.status === "running" && job.targets.some(row => row.target === "fleet" && row.status === "running");
978
+ $("applyTitle").textContent = fleetRestarting ? t("restartFleetTitle")
979
+ : job.status === "done"
919
980
  ? (job.targets.some(r => r.status === "failed") ? t("applyPartial") : t("applyDone"))
920
981
  : job.status === "failed" ? t("applyPartial") : t("applying");
921
982
  const needsRestart = job.targets.some(row => row.status === "restart-required");
922
983
  const mismatch = state.schema.fleet_signature_mismatch;
923
- $("applyNote").textContent = job.overdue
984
+ $("applyNote").textContent = fleetRestarting ? t("restartFleetWaiting")
985
+ : job.overdue
924
986
  ? tf("applyStillWorking", Math.round(job.elapsed_ms / 1000))
925
987
  : mismatch && needsRestart ? tf("signatureMismatch", mismatch.join(", "))
926
988
  : needsRestart ? t("applyRestartHint") : "";
@@ -928,8 +990,11 @@
928
990
  for (const row of job.targets) rows.append(applyRow(row));
929
991
  // A restart that cannot clear the mismatch is worse than no button at all.
930
992
  if (needsRestart && !mismatch) {
931
- rows.append(el("div", { class: "apply-row" },
932
- el("button", { class: "primary", onclick: () => restartFleet(job) }, t("restartFleetButton"))));
993
+ const restartButton = el("button", { class: "primary", onclick: ev => restartFleet(job, ev.currentTarget) },
994
+ restartingFleet ? t("restartFleetBusy") : t("restartFleetButton"));
995
+ // While a restart is under way this stays, disabled and saying so, if anything re-draws the panel.
996
+ if (restartingFleet) restartButton.disabled = true;
997
+ rows.append(el("div", { class: "apply-row" }, restartButton));
933
998
  }
934
999
  if (job.error) rows.append(el("div", { class: "apply-row err" }, job.error));
935
1000
  }
@@ -944,7 +1009,10 @@
944
1009
  let current = job;
945
1010
  while (current.status === "running" && Date.now() < deadline) {
946
1011
  await new Promise(r => setTimeout(r, 1000));
947
- const res = await api(`/api/settings/apply/${encodeURIComponent(job.id)}`);
1012
+ // A restart takes the server down under this request: the fetch rejects until AgEnD is back. That is
1013
+ // the expected middle of a restart, not the end of the watch.
1014
+ let res;
1015
+ try { res = await api(`/api/settings/apply/${encodeURIComponent(job.id)}`); } catch { continue; }
948
1016
  if (res.status === 404) {
949
1017
  // The job outlives the process that made it, so a 404 means it aged out
950
1018
  // of the store — not that it never happened.
@@ -1045,9 +1113,9 @@
1045
1113
  const form = agentEditForm(name, inst);
1046
1114
  openModal({ title: shortName(name, inst), subtitle: "ID: " + name, body: form.box, impacts: form.impacts, stage: form.stage });
1047
1115
  } }, 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,
1116
+ running ? el("button", { class: "sm", onclick: ev => pauseWakeAgent(name, "pause", ev.currentTarget) }, t("pause")) : null,
1117
+ paused ? el("button", { class: "sm", onclick: ev => pauseWakeAgent(name, "wake", ev.currentTarget) }, t("wake")) : null,
1118
+ !paused ? el("button", { class: "sm", onclick: ev => toggleAgent(name, running, ev.currentTarget) }, running ? t("stop") : t("start")) : null,
1051
1119
  el("details", { class: "more-menu" },
1052
1120
  el("summary", { title: "More actions" }, "⋯"),
1053
1121
  el("div", { class: "menu" }, el("button", { class: "sm danger", onclick: () => delAgent(name) }, "Delete Agent"))),
@@ -1242,15 +1310,40 @@
1242
1310
  "instance.lightweight", "instance.model_failover", "instance.systemPrompt",
1243
1311
  "instance.tags", "instance.general_topic", "instance.status_emojis",
1244
1312
  ];
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();
1313
+ // Start, stop, pause and wake take a moment and used to give no sign of it, so they were pressed again.
1314
+ // One action per agent at a time; the row's buttons are disabled and the pressed one says it is working.
1315
+ const busyAgents = new Set();
1316
+ async function runAgentAction(name, button, work) {
1317
+ if (busyAgents.has(name)) return;
1318
+ busyAgents.add(name);
1319
+ const row = button?.closest(".item");
1320
+ const buttons = row ? [...row.querySelectorAll("button")] : (button ? [button] : []);
1321
+ const label = button?.textContent;
1322
+ for (const b of buttons) b.disabled = true;
1323
+ if (button) button.textContent = t("working");
1324
+ try { await work(); }
1325
+ finally {
1326
+ busyAgents.delete(name);
1327
+ // The list is normally redrawn by then; if it was not (a failed reload), give the buttons back.
1328
+ for (const b of buttons) if (b.isConnected) b.disabled = false;
1329
+ if (button?.isConnected) button.textContent = label;
1330
+ }
1331
+ }
1332
+ function toggleAgent(name, running, button) {
1333
+ return runAgentAction(name, button, async () => {
1334
+ const res = running ? await api(`/stop/${encodeURIComponent(name)}`, { method: "POST" })
1335
+ : await api(`/api/instance/${encodeURIComponent(name)}/start`, { method: "POST" });
1336
+ if (!res.ok) showBanner(`${tf("actionFailed", running ? t("stop") : t("start"))}: ${res.body?.error || res.status}`, true);
1337
+ await new Promise(r => setTimeout(r, 800));
1338
+ await reloadLive();
1339
+ });
1340
+ }
1341
+ function pauseWakeAgent(name, action, button) {
1342
+ return runAgentAction(name, button, async () => {
1343
+ const res = await api(`/api/settings/instances/${encodeURIComponent(name)}/${action}`, { method: "POST" });
1344
+ if (!res.ok) alert(res.body?.error || tf("actionFailed", action));
1345
+ await reloadLive();
1346
+ });
1254
1347
  }
1255
1348
  async function delAgent(name) {
1256
1349
  if (!confirm(tf("deleteAgent", name))) return;
@@ -1295,7 +1388,7 @@
1295
1388
  chs.forEach((ch, i) => {
1296
1389
  const item = el("div", { class: "item" });
1297
1390
  const type = ch.type || "?";
1298
- item.append(el("div", { class: "item-row" },
1391
+ item.append(el("div", { class: "item-row conn-row" },
1299
1392
  el("span", { class: "name" }, type === "telegram" ? "Telegram" : "Discord"),
1300
1393
  el("span", { class: "tag" + (i === 0 ? "" : " persona") + (type === "telegram" ? " tg" : "") }, ch.id || chLabel(i)),
1301
1394
  el("span", { class: "sub", style: "margin:0" }, ch.bot_token_env || "(no token env)"),
@@ -1305,13 +1398,14 @@
1305
1398
  (ch.access?.allowed_users || []).length
1306
1399
  ? (ch.access.allowed_users).slice(0, 3).join(", ") + ((ch.access.allowed_users).length > 3 ? ` +${(ch.access.allowed_users).length - 3}` : "")
1307
1400
  : 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")),
1401
+ el("span", { class: "row-end" },
1402
+ 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"),
1403
+ el("span", { style: `color:${state.fleetUp ? "var(--green)" : "var(--red)"};font-size:13px` }, state.fleetUp ? "Connected" : "Problem"),
1404
+ el("button", { class: "sm ghost", onclick: () => {
1405
+ const form = botEditForm(ch, i);
1406
+ openModal({ title: type === "telegram" ? "Telegram" : "Discord", subtitle: ch.id || chLabel(i), body: form.box, impacts: form.impacts, stage: form.stage });
1407
+ } }, t("settingsButton")),
1408
+ ),
1315
1409
  ));
1316
1410
  host.append(item);
1317
1411
  });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Phase 2b (docs/design/phase2-submit-contract.md §1.2–§1.6, §3.1): the one
3
+ * owner that wakes a paused target because durable work is waiting for it.
4
+ *
5
+ * Before this, the only code that woke a target for a queued cross-instance
6
+ * message ran *after* the outbox pump had claimed the row, and the pump never
7
+ * claims a target without a live daemon — so a marker-only paused instance
8
+ * (paused across a fleet restart) was never woken: Bug1, the attempt_0
9
+ * deadlock. The coordinator wakes first; the pump stays the only claimer and
10
+ * only claims targets that are awake. Nothing here writes a pane or claims a
11
+ * row.
12
+ *
13
+ * Active only for targets whose `delivery_worker` is `wake_only` or `on`;
14
+ * `off` (the default) leaves every path as it was.
15
+ */
16
+ import type { DeliveryWorkerMode } from "./types.js";
17
+ import type { OutboxDelivery } from "./delivery-outbox.js";
18
+ import type { PauseReason } from "./pause-marker.js";
19
+ /** Row kinds that never warrant waking a target. Empty: every current kind asks for action (§1.2). */
20
+ export declare const PASSIVE_KINDS: ReadonlySet<string>;
21
+ export declare const isWakeEligibleKind: (kind: string) => boolean;
22
+ export declare const WAKE_BACKOFF_BASE_MS = 1000;
23
+ export declare const WAKE_BACKOFF_MAX_MS = 60000;
24
+ /** Consecutive wake failures before both topics are told, once per failure episode. */
25
+ export declare const WAKE_FAILURE_NOTICE_AFTER = 3;
26
+ /** Pre-claim lease length (mirrors instance-lifecycle PRECLAIM_LEASE_MS). */
27
+ export declare const PRECLAIM_WINDOW_MS = 120000;
28
+ export declare const PARK_BASE_MS = 60000;
29
+ export declare const PARK_MAX_MS: number;
30
+ /** Queued work on an awake target that has not been claimed for this long is reported once (§1.4). */
31
+ export declare const CLAIM_BLOCK_NOTICE_MS: number;
32
+ /** How far `warm_cap` may be exceeded for leased work (§1.6). */
33
+ export declare const DEFAULT_WARM_OVERFLOW = 2;
34
+ export declare const WATCHDOG_INTERVAL_MS = 30000;
35
+ export interface WakeCoordinatorDeps {
36
+ mode(target: string): DeliveryWorkerMode;
37
+ /**
38
+ * Whether the outbox can be read now. False once its database is closed
39
+ * (fleet shutdown, a test tearing down): the coordinator then does nothing,
40
+ * and stops itself, instead of querying a closed connection.
41
+ */
42
+ available(): boolean;
43
+ /** Rows still to deliver: queued, retry_wait, delivering, submission_started (created_seq order). */
44
+ listPending(): OutboxDelivery[];
45
+ isPaused(target: string): boolean;
46
+ pauseReason(target: string): PauseReason | null;
47
+ isRestarting(target: string): boolean;
48
+ /** The single-flight lifecycle wake, marked as the coordinator's own. */
49
+ wake(target: string): Promise<void>;
50
+ /** Instances counted against warm_cap right now (running, not paused, not general). */
51
+ residentNames(): string[];
52
+ /** warm_cap; 0 or less means unlimited. */
53
+ warmCap(): number;
54
+ warmOverflow(): number;
55
+ /** When the target was last woken or started for work (the pre-claim clock). */
56
+ wokeAt(target: string): number | undefined;
57
+ /** Why an awake target is not accepting input (dialog, auth hold…), or null. */
58
+ notAcceptingReason(target: string): string | null;
59
+ notifyTarget(target: string, text: string): void;
60
+ /**
61
+ * Run the outbox pump. A wake changes no outbox row, so nothing else would
62
+ * tell the pump that the target it skipped while paused can be claimed now.
63
+ */
64
+ kickPump(): void;
65
+ notifySender(source: string, text: string): void;
66
+ now(): number;
67
+ logger: {
68
+ info(obj: object, msg: string): void;
69
+ warn(obj: object, msg: string): void;
70
+ debug?(obj: object, msg: string): void;
71
+ };
72
+ }
73
+ export declare class WakeCoordinator {
74
+ private readonly deps;
75
+ private readonly states;
76
+ /** Targets whose wake is in flight (a reservation each, counted against the hard cap). */
77
+ private readonly reserved;
78
+ private scanTimer;
79
+ private watchdog;
80
+ private stopped;
81
+ constructor(deps: WakeCoordinatorDeps);
82
+ start(): void;
83
+ stop(): void;
84
+ /** Schedule a scan soon (coalesced). Safe to call from any event. */
85
+ kick(delayMs?: number): void;
86
+ private state;
87
+ private active;
88
+ /**
89
+ * The pump must not claim this target (§3.1, §1.4): it is paused, being
90
+ * woken, or awake but not accepting input by the daemon's own account
91
+ * (a dialog, a login failure, stuck). Called from the pump's claim
92
+ * callback, in the same synchronous step that reads the daemon's bootId, so
93
+ * the verdict and the claim are about the same daemon generation. A busy
94
+ * (working) target is accepting: the manager's idle gate still waits it out.
95
+ */
96
+ blocksClaim(target: string): boolean;
97
+ isWaking(target: string): boolean;
98
+ /** The last wake error, while the target is failing to wake (for the expiry reason). */
99
+ wakeFailure(target: string): string | null;
100
+ isParked(target: string): boolean;
101
+ /**
102
+ * A wake that did not come from here (an operator's /wake or wake_instance,
103
+ * a user's channel message) *succeeded*. Park and backoff block only
104
+ * automatic wakes, so such a wake always gets its one attempt (§1.6); only
105
+ * its success lifts them. A failed attempt, or a refusal for capacity,
106
+ * leaves the park and backoff exactly as they were. If the hold persists
107
+ * after a successful wake, the park comes back on its next window with the
108
+ * level kept (it is reset only by a delivery).
109
+ */
110
+ noteExternalWake(target: string): void;
111
+ /** A row for `target` was delivered: the hold is over, and so are the backoffs. */
112
+ noteDelivered(target: string): void;
113
+ /** Free warm slots under the hard cap, counting every target exactly once. */
114
+ private freeSlots;
115
+ /** Synchronously take a slot for `target` (before any await). */
116
+ tryReserve(target: string): symbol | null;
117
+ /** Return exactly the slot `token` holds (idempotent). */
118
+ release(target: string, token: symbol): void;
119
+ get reservedCount(): number;
120
+ /**
121
+ * One pass over the pending queue: wake what needs waking, park what will
122
+ * not take work. Runs from timers, so it must never throw: an exception
123
+ * there is uncaught and takes the whole fleet process down (e.g. a scan
124
+ * landing after the outbox's database was closed). The next kick or the
125
+ * watchdog retries.
126
+ */
127
+ scan(): void;
128
+ private scanOnce;
129
+ /**
130
+ * An awake target with queued work: past its pre-claim window without the
131
+ * work moving, it is parked (no automatic re-wake until the hold clears or
132
+ * the park expires), and a long pre-claim block is reported once.
133
+ */
134
+ private checkAwakeTarget;
135
+ /**
136
+ * Whether a wake completion must do nothing but return its slot: the
137
+ * coordinator was stopped, or the outbox went away while the wake was in
138
+ * flight (the coordinator stops itself then). Checked before any side
139
+ * effect, so a late completion can never kick a pump at a closed database.
140
+ */
141
+ private fencedCompletion;
142
+ private startWake;
143
+ }