@songsid/agend 2.3.5-alpha.2 → 2.3.5-alpha.3

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 (212) hide show
  1. package/dist/agend-home.d.ts +13 -0
  2. package/dist/agend-home.js +42 -0
  3. package/dist/agend-home.js.map +1 -1
  4. package/dist/auth-api.d.ts +21 -0
  5. package/dist/auth-api.js +91 -4
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +12 -2
  8. package/dist/authority/web-authority.js +25 -4
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/backend/claude-code.js +16 -1
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +11 -1
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/kiro-identity.d.ts +2 -0
  15. package/dist/backend/kiro-identity.js +13 -6
  16. package/dist/backend/kiro-identity.js.map +1 -1
  17. package/dist/backend/kiro.js +18 -3
  18. package/dist/backend/kiro.js.map +1 -1
  19. package/dist/backend-availability.d.ts +6 -0
  20. package/dist/backend-availability.js +13 -5
  21. package/dist/backend-availability.js.map +1 -1
  22. package/dist/channel/mcp-tools.js +1 -1
  23. package/dist/channel/mcp-tools.js.map +1 -1
  24. package/dist/cli.js +6 -9
  25. package/dist/cli.js.map +1 -1
  26. package/dist/config-validator.js +12 -1
  27. package/dist/config-validator.js.map +1 -1
  28. package/dist/daemon.d.ts +5 -0
  29. package/dist/daemon.js +107 -30
  30. package/dist/daemon.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +143 -8
  32. package/dist/fleet-manager.js +874 -166
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/fleet-windows-runtime.js +7 -0
  35. package/dist/fleet-windows-runtime.js.map +1 -1
  36. package/dist/fleet-yaml-patch.d.ts +3 -0
  37. package/dist/fleet-yaml-patch.js +103 -0
  38. package/dist/fleet-yaml-patch.js.map +1 -0
  39. package/dist/general-binding.d.ts +85 -0
  40. package/dist/general-binding.js +298 -0
  41. package/dist/general-binding.js.map +1 -0
  42. package/dist/general-host-owner.d.ts +8 -0
  43. package/dist/general-host-owner.js +26 -0
  44. package/dist/general-host-owner.js.map +1 -0
  45. package/dist/general-instance.d.ts +7 -0
  46. package/dist/general-instance.js +20 -3
  47. package/dist/general-instance.js.map +1 -1
  48. package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
  49. package/dist/general-role.d.ts +5 -0
  50. package/dist/general-role.js +26 -0
  51. package/dist/general-role.js.map +1 -0
  52. package/dist/instance-lifecycle.d.ts +1 -0
  53. package/dist/instance-lifecycle.js +26 -4
  54. package/dist/instance-lifecycle.js.map +1 -1
  55. package/dist/instructions.d.ts +2 -0
  56. package/dist/instructions.js +6 -0
  57. package/dist/instructions.js.map +1 -1
  58. package/dist/kiro-resume-summary.d.ts +37 -0
  59. package/dist/kiro-resume-summary.js +85 -0
  60. package/dist/kiro-resume-summary.js.map +1 -0
  61. package/dist/locale.js +59 -0
  62. package/dist/locale.js.map +1 -1
  63. package/dist/login-controller.js +9 -1
  64. package/dist/login-controller.js.map +1 -1
  65. package/dist/native-pane-lifecycle.js +4 -1
  66. package/dist/native-pane-lifecycle.js.map +1 -1
  67. package/dist/native-pane-manager.d.ts +1 -0
  68. package/dist/native-pane-manager.js +23 -2
  69. package/dist/native-pane-manager.js.map +1 -1
  70. package/dist/needs-you-hub.js +1 -1
  71. package/dist/needs-you-hub.js.map +1 -1
  72. package/dist/needs-you.d.ts +8 -1
  73. package/dist/needs-you.js +5 -2
  74. package/dist/needs-you.js.map +1 -1
  75. package/dist/package-preimage.js +27 -2
  76. package/dist/package-preimage.js.map +1 -1
  77. package/dist/pane-port.d.ts +11 -0
  78. package/dist/public-web-gateway.js +9 -0
  79. package/dist/public-web-gateway.js.map +1 -1
  80. package/dist/public-web-link.d.ts +5 -0
  81. package/dist/public-web-link.js +4 -0
  82. package/dist/public-web-link.js.map +1 -1
  83. package/dist/quickstart-api.js +9 -5
  84. package/dist/quickstart-api.js.map +1 -1
  85. package/dist/quickstart.d.ts +1 -0
  86. package/dist/quickstart.js +4 -1
  87. package/dist/quickstart.js.map +1 -1
  88. package/dist/reply-buttons-controller.js +10 -0
  89. package/dist/reply-buttons-controller.js.map +1 -1
  90. package/dist/runtime-bundle.js +3 -0
  91. package/dist/runtime-bundle.js.map +1 -1
  92. package/dist/runtime-general-protocol.d.ts +22 -0
  93. package/dist/runtime-general-protocol.js +57 -0
  94. package/dist/runtime-general-protocol.js.map +1 -0
  95. package/dist/runtime-general-workspace.d.ts +17 -0
  96. package/dist/runtime-general-workspace.js +213 -0
  97. package/dist/runtime-general-workspace.js.map +1 -0
  98. package/dist/runtime-pane-assets.d.ts +1 -1
  99. package/dist/runtime-pane-assets.js +1 -1
  100. package/dist/runtime-pane-assets.js.map +1 -1
  101. package/dist/runtime-pane-host.d.ts +1 -0
  102. package/dist/runtime-pane-host.js +22 -1
  103. package/dist/runtime-pane-host.js.map +1 -1
  104. package/dist/runtime-pane-mcp.d.ts +8 -0
  105. package/dist/runtime-pane-mcp.js +15 -2
  106. package/dist/runtime-pane-mcp.js.map +1 -1
  107. package/dist/runtime-pane-port.d.ts +6 -1
  108. package/dist/runtime-pane-port.js +21 -0
  109. package/dist/runtime-pane-port.js.map +1 -1
  110. package/dist/runtime-pane-protocol.d.ts +4 -1
  111. package/dist/runtime-pane-protocol.js +7 -1
  112. package/dist/runtime-pane-protocol.js.map +1 -1
  113. package/dist/settings-api.d.ts +5 -0
  114. package/dist/settings-api.js +37 -1
  115. package/dist/settings-api.js.map +1 -1
  116. package/dist/settings-baseline.d.ts +1 -0
  117. package/dist/settings-baseline.js +1 -1
  118. package/dist/settings-baseline.js.map +1 -1
  119. package/dist/settings-confirmation.d.ts +1 -0
  120. package/dist/settings-confirmation.js +1 -0
  121. package/dist/settings-confirmation.js.map +1 -1
  122. package/dist/settings-effect.d.ts +4 -0
  123. package/dist/settings-effect.js +35 -0
  124. package/dist/settings-effect.js.map +1 -1
  125. package/dist/settings-http-confirmation.d.ts +1 -0
  126. package/dist/settings-http-confirmation.js +4 -0
  127. package/dist/settings-http-confirmation.js.map +1 -1
  128. package/dist/settings-request-capability.d.ts +2 -0
  129. package/dist/settings-request-capability.js +4 -0
  130. package/dist/settings-request-capability.js.map +1 -1
  131. package/dist/settings-route-lifetime.d.ts +14 -0
  132. package/dist/settings-route-lifetime.js +48 -0
  133. package/dist/settings-route-lifetime.js.map +1 -0
  134. package/dist/settings-transaction.d.ts +4 -0
  135. package/dist/settings-transaction.js +9 -0
  136. package/dist/settings-transaction.js.map +1 -1
  137. package/dist/setup-form.js +6 -3
  138. package/dist/setup-form.js.map +1 -1
  139. package/dist/setup-i18n.js +3 -0
  140. package/dist/setup-i18n.js.map +1 -1
  141. package/dist/stall-reporter.d.ts +21 -0
  142. package/dist/stall-reporter.js +82 -0
  143. package/dist/stall-reporter.js.map +1 -0
  144. package/dist/tmux-control.d.ts +2 -2
  145. package/dist/tmux-control.js +4 -2
  146. package/dist/tmux-control.js.map +1 -1
  147. package/dist/tmux-exec-helper.d.ts +6 -3
  148. package/dist/tmux-exec-helper.js +115 -53
  149. package/dist/tmux-exec-helper.js.map +1 -1
  150. package/dist/tool-permissions.js +2 -1
  151. package/dist/tool-permissions.js.map +1 -1
  152. package/dist/tunnel/cloudflared-install.d.ts +4 -0
  153. package/dist/tunnel/cloudflared-install.js +20 -1
  154. package/dist/tunnel/cloudflared-install.js.map +1 -1
  155. package/dist/tunnel/cloudflared.d.ts +8 -1
  156. package/dist/tunnel/cloudflared.js +34 -6
  157. package/dist/tunnel/cloudflared.js.map +1 -1
  158. package/dist/tunnel/lease.d.ts +3 -3
  159. package/dist/tunnel/lease.js +23 -19
  160. package/dist/tunnel/lease.js.map +1 -1
  161. package/dist/tunnel/manager.js +6 -7
  162. package/dist/tunnel/manager.js.map +1 -1
  163. package/dist/types.d.ts +2 -0
  164. package/dist/ui/agent-proposals.js +2 -2
  165. package/dist/ui/general-location.js +71 -0
  166. package/dist/ui/native-pane-recovery.js +1 -1
  167. package/dist/ui/panel-chat.js +2 -0
  168. package/dist/ui/panel-needs.js +2 -0
  169. package/dist/ui/panel-settings.js +5 -1
  170. package/dist/ui/settings-dialogs.js +8 -2
  171. package/dist/ui/settings-model.js +2 -1
  172. package/dist/ui/settings-outside.js +176 -0
  173. package/dist/ui/settings-registry.js +1 -0
  174. package/dist/ui/settings-strings.js +2 -0
  175. package/dist/ui/shared/app-i18n.js +2 -2
  176. package/dist/ui/shared/app.css +16 -1
  177. package/dist/ui/shared/instance-nav.js +13 -3
  178. package/dist/ui/shared/panel-view.js +266 -17
  179. package/dist/ui/shared/qrcode.module.js +2237 -0
  180. package/dist/ui/shared/signin.js +39 -5
  181. package/dist/ui/shared/vendor/qrcode-generator.LICENSE +27 -0
  182. package/dist/ui/shared/vendor/vendor.json +19 -2
  183. package/dist/ui/shared/view-strings.js +28 -0
  184. package/dist/unattended-install.d.ts +1 -1
  185. package/dist/unattended-install.js +19 -5
  186. package/dist/unattended-install.js.map +1 -1
  187. package/dist/update-check.d.ts +2 -8
  188. package/dist/update-check.js +2 -5
  189. package/dist/update-check.js.map +1 -1
  190. package/dist/view-api.d.ts +15 -0
  191. package/dist/view-api.js +94 -33
  192. package/dist/view-api.js.map +1 -1
  193. package/dist/web-api.d.ts +12 -0
  194. package/dist/web-api.js +77 -0
  195. package/dist/web-api.js.map +1 -1
  196. package/dist/web-login.d.ts +4 -3
  197. package/dist/web-login.js +23 -14
  198. package/dist/web-login.js.map +1 -1
  199. package/dist/web-only-fleet.d.ts +20 -0
  200. package/dist/web-only-fleet.js +21 -0
  201. package/dist/web-only-fleet.js.map +1 -1
  202. package/dist/web-remote-access.d.ts +152 -0
  203. package/dist/web-remote-access.js +368 -0
  204. package/dist/web-remote-access.js.map +1 -0
  205. package/dist/web-session.d.ts +12 -0
  206. package/dist/web-session.js +22 -8
  207. package/dist/web-session.js.map +1 -1
  208. package/dist/windows-agents.d.ts +12 -0
  209. package/dist/windows-agents.js +24 -0
  210. package/dist/windows-agents.js.map +1 -1
  211. package/launcher/agend-install.cjs +16 -2
  212. package/package.json +1 -1
@@ -0,0 +1,71 @@
1
+ // The location is a separate revision-bound Settings operation, never an agent-config patch.
2
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
3
+ import { t, register } from "/assets/app-i18n.js";
4
+ import { appStore, useStore, guestPage } from "/assets/app-store.js";
5
+ import { useLease } from "/assets/app-ctx.js";
6
+ import { toast } from "/assets/ui-toast.js";
7
+ import { confirmedWrite, newKey } from "./settings-confirm.js";
8
+ import { ModelSelect } from "./settings-dialogs.js";
9
+ register("generalLocation", {
10
+ en: {
11
+ label: "Run General on", host: "This AgEnD host", windows: "Paired Windows PC",
12
+ offline: "General’s Windows PC is offline. AgEnD is still running here. Reconnect that PC, or switch General back to this host in Settings. Pending or uncertain requests will not be sent again automatically.",
13
+ exit: "General was not switched: the previous process has not been confirmed stopped. Reconnect its PC or use the existing stopped-process recovery, then try again. No replacement was started.",
14
+ start: "General’s location was saved, but it could not start. Check Needs you and choose Start or switch back. Its previous conversation is kept; no input was replayed.",
15
+ history: "Host and Windows conversations stay separate. Switching does not copy or merge their history.",
16
+ },
17
+ "zh-TW": {
18
+ label: "General 執行位置", host: "這台 AgEnD 主機", windows: "已配對的 Windows 電腦",
19
+ offline: "General 所在的 Windows 電腦已離線。AgEnD 仍在這台主機運作。請重新連線那台電腦,或到設定把 General 切回這台主機。待處理或結果不明的請求不會自動重送。",
20
+ exit: "General 尚未切換:還無法確認前一個程序已停止。請重新連線那台電腦,或完成現有的程序停止復原,再重試。沒有啟動替代程序。",
21
+ start: "General 的執行位置已儲存,但未能啟動。請查看「等你處理」,選擇啟動或切回原位置。先前的對話已保留,沒有重送輸入。",
22
+ history: "主機與 Windows 各自保留對話,切換時不會複製或合併對話紀錄。",
23
+ },
24
+ });
25
+ const tr = key => t(`generalLocation.${key}`);
26
+ export function GeneralLocation({ name, attentionOnly = false }) {
27
+ const app = useStore(appStore);
28
+ const attentionKey = JSON.stringify((app.needs || []).filter(item => item.generalLocation && (!name || item.instance === name)).map(item => item.id));
29
+ const lease = useLease("general-location"), [data, setData] = useState(null), [error, setError] = useState(null);
30
+ const read = async () => {
31
+ try { const response = await lease.fetch("/api/settings/general-location"); const result = await response.json();
32
+ if (lease.current()) { if (response.ok) { setData(result); setError(null); } else setError(result.error); }
33
+ } catch (err) { if (lease.current()) setError(err.message); }
34
+ };
35
+ useEffect(() => { if (!guestPage()) void read(); }, [name, attentionKey]);
36
+ if (guestPage()) return null;
37
+ return html`<section id="general-location" class="card general-location"><h2>${tr("label")}</h2>
38
+ ${error ? html`<p role="alert">${error}</p>` : null}
39
+ ${(data?.generals || []).filter(row => !name || row.name === name).map(row => html`<${LocationRow} key=${row.name + row.revision} row=${row} machines=${data.machines || []} attentionOnly=${attentionOnly} refresh=${read} />`)}
40
+ ${!attentionOnly ? html`<button type="button" class="btn btn-sm" onClick=${read}>${t("settings.refresh")}</button>` : null}
41
+ </section>`;
42
+ }
43
+ function LocationRow({ row, machines, attentionOnly, refresh }) {
44
+ const [target, setTarget] = useState("host"), [cli, setCli] = useState("claude-code"), [model, setModel] = useState("");
45
+ const [busy, setBusy] = useState(false), flight = useRef(false);
46
+ const lease = useLease("general-location-row");
47
+ const machine = machines.find(item => item.id === target);
48
+ // The rendered revision stays with this click. Never re-read and silently retry a stale proposal.
49
+ const propose = async next => {
50
+ if (flight.current || !lease.current()) return; flight.current = true; setBusy(true);
51
+ try { const result = await confirmedWrite("/api/settings/general-location", { body: { name: row.name, revision: row.revision, target: next }, key: newKey("general"), label: tr("label") });
52
+ if (lease.current()) { toast(result.body?.message || result.body?.error || t(result.ok ? "app.saved" : "app.failed"), result.ok); await refresh(); }
53
+ } catch (err) { if (lease.current()) toast(err.message, false); }
54
+ finally { flight.current = false; if (lease.current()) setBusy(false); }
55
+ };
56
+ if (attentionOnly && !row.offline && !row.hold) return null;
57
+ return html`<div class="general-location-row" data-general=${row.name}>
58
+ <strong>${row.displayName}</strong><p>${row.location === "host" ? tr("host") : `${machines.find(item => item.id === row.machineId)?.name || tr("windows")} · ${row.cli}`}</p>
59
+ ${row.offline || row.hold ? html`<p role="alert">${tr(row.hold || "offline")}</p>` : null}
60
+ <p class="note">${tr("history")}</p>
61
+ ${row.canSwitchBack ? html`<button type="button" class="btn" disabled=${busy || row.switching} onClick=${() => propose({ location: "host" })}>${tr("host")}</button>` : null}
62
+ ${row.hold === "start" ? html`<button type="button" class="btn" disabled=${busy || row.switching} onClick=${() => propose(row.retryTarget || (row.location === "host" ? { location: "host" } : { location: "windows", machineId: row.machineId, cli: row.cli, model: row.model }))}>${t("chat.start")}</button>` : null}
63
+ ${!attentionOnly ? html`<label>${tr("label")}<select value=${target} disabled=${busy || row.switching} onChange=${e => { setTarget(e.target.value); setModel(""); }}>
64
+ <option value="host">${tr("host")}</option>${machines.map(item => html`<option value=${item.id} disabled=${!item.online}>${item.name}</option>`)}
65
+ </select></label>
66
+ ${target !== "host" ? html`<label>${t("settings.backend")}<select value=${cli} onChange=${e => { setCli(e.target.value); setModel(""); }}>
67
+ ${["claude-code", "codex"].map(id => html`<option value=${id} disabled=${!machine?.clis.some(item => item.id === id)}>${id}</option>`)}
68
+ </select></label><${ModelSelect} value=${model} onChange=${setModel} backend=${cli} runtime="windows" machineId=${target} instance=${row.name} />` : null}
69
+ <button type="button" class="btn btn-primary" disabled=${busy || row.switching || target === "host" && !row.canSwitchBack || target !== "host" && (!machine?.online || !machine.clis.some(item => item.id === cli))} onClick=${() => propose(target === "host" ? { location: "host" } : { location: "windows", machineId: target, cli, model })}>${t("settings.save")}</button>` : null}
70
+ </div>`;
71
+ }
@@ -23,7 +23,7 @@ export function NativePaneRecovery({ name, inst, lease }) {
23
23
  finally { inFlight.current = false; if (lease.current()) setBusy(false); }
24
24
  };
25
25
  if (guestPage() || inst.native_adapter !== "pane") return null;
26
- if (!recovery) return ["stopped", "crashed"].includes(inst.status)
26
+ if (!recovery) return !inst.general_topic && ["stopped", "crashed"].includes(inst.status)
27
27
  ? html`<div class="windows-agent-blocked"><button type="button" class="btn" disabled=${busy} onClick=${() => act(`/ui/start/${encodeURIComponent(name)}`)}>${t("chat.start")}</button></div>` : null;
28
28
  if (recovery.kind === "session-unconfirmed") return html`<div class="windows-agent-blocked" role="alert">
29
29
  <p>${t(recovery.provider === "codex" ? "chat.paneCodexSessionUnconfirmed" : "chat.paneSessionUnconfirmed")}</p>
@@ -1,4 +1,5 @@
1
1
  import { NativePaneRecovery } from "./native-pane-recovery.js";
2
+ import { GeneralLocation } from "./general-location.js";
2
3
  // #1408 step 1: the chat panel. Behind the session gate (/ui/js/), loaded by the app with a dynamic import in "full"
3
4
  // mode only. boot() runs once per page (the chat store must hear the stream from the first frame); <ChatPanel> mounts
4
5
  // for /ui and /ui/chat/<name>.
@@ -365,6 +366,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
365
366
  ${theme && theme.background ? html`<img ref=${bg} class=${`th-bg th-bg-${theme.background.fit}`} src=${themeImageUrl(name, theme.background.image)} alt="" aria-hidden="true" decoding="async" />` : null}
366
367
  <${ChatAmbient} name=${name} surface=${effectSurface} part="layer" />
367
368
  <${AmbientMotionNote} name=${name} />
369
+ ${inst.general_topic ? html`<${GeneralLocation} name=${name} attentionOnly=${true} />` : null}
368
370
  <${NativePaneRecovery} name=${name} inst=${inst} lease=${lease} />
369
371
  ${inst.native_blocked ? html`<div class="windows-agent-blocked" role="alert"><p>${t("chat.nativeBlocked")}</p><button type="button" class="btn" disabled=${recovering} onClick=${recoverNative}>${t("chat.nativeRecover")}</button></div>` : null}
370
372
  <${Thread} name=${name} label=${inst.runtime === "windows" ? inst.display_name || name : name} th=${thread} />
@@ -25,6 +25,7 @@ import { toast } from "/assets/ui-toast.js";
25
25
  import { notifyStore, notifySupport, configDetail, reasonText, refreshNotify, setNotify } from "/assets/app-needs.js";
26
26
  import { openSignIn, signInOffered } from "./backend-signin.js";
27
27
  import { decideProposal, installProposals } from "./agent-proposals.js";
28
+ import { GeneralLocation } from "./general-location.js";
28
29
 
29
30
  register("needs", {
30
31
  en: {
@@ -242,6 +243,7 @@ function NeedsItem({ item, nowMs, owner }) {
242
243
  <div class="n-body">
243
244
  <div class="n-title"><strong>${reasonText(item)}</strong><span class="n-age">${age(item.since, nowMs)}</span></div>
244
245
  ${detailOf(item) ? html`<p class="n-detail">${detailOf(item)}</p>` : null}
246
+ ${item.generalLocation ? html`<${GeneralLocation} name=${item.instance} attentionOnly=${true} />` : null}
245
247
  <div class="n-actions">
246
248
  ${item.type === "prompt" && Array.isArray(item.actions) ? item.actions.filter((a) => !a.input).map((a) => html`<button key=${a.id} type="button" class="btn btn-sm"
247
249
  disabled=${promptBusy} onClick=${() => answer(a.id)}>${(p ? p.busy : fallback === "busy") ? tn("answering") : a.label}</button>`) : null}
@@ -53,8 +53,10 @@ import { OfficeSharesCard } from "./office-share-dialog.js";
53
53
  import { PageSharesCard } from "./page-share-dialog.js";
54
54
  import { syncStage, useDraft } from "./settings-draft.js";
55
55
  import { WebAuthorityCard } from "./settings-web-authority.js";
56
+ import { OutsideCard } from "./settings-outside.js";
56
57
  import { NamedLinkTest, setTypedTunnelToken } from "./settings-named-test.js";
57
58
  import { SignInList } from "./backend-signin.js";
59
+ import { GeneralLocation } from "./general-location.js";
58
60
 
59
61
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
60
62
  /** What a refused change says to a person: the server's code in words (the code itself only for one not listed). */
@@ -385,7 +387,7 @@ export function SettingsPanel({ route, navKey }) {
385
387
  : !data ? html`<${Skeleton} lines=${6} />`
386
388
  : data.error ? html`<${ErrorState} message=${tn("configLoadFailed")} onRetry=${reload} />`
387
389
  : section === "agents" ? html`<${YamlLock} staged=${staged}><${Agents} ctx=${ctx} search=${search} openDialog=${toDialog} /></${YamlLock}>${ctx.providerOk ? html`<${ProviderKeys} ctx=${ctx} />` : null}`
388
- : section === "agent-defaults" ? html`<${YamlLock} staged=${staged}><${General} key=${formKey} ctx=${ctx} part="fleet" /></${YamlLock}>`
390
+ : section === "agent-defaults" ? html`<${GeneralLocation} /><${YamlLock} staged=${staged}><${General} key=${formKey} ctx=${ctx} part="fleet" /></${YamlLock}>`
389
391
  : section === "chat" ? html`<${YamlLock} staged=${staged}><${Bots} ctx=${ctx} search=${search} openDialog=${toDialog} /></${YamlLock}>
390
392
  <${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="chat" />
391
393
  <div id="classicbot" class="s-anchor"><h2 class="s-ap-group">ClassicBot</h2><${Classic} ctx=${ctx} search=${search} openDialog=${toDialog} />
@@ -1377,6 +1379,8 @@ function WebSection({ ctx, staged, part = "all" }) {
1377
1379
  ${has("access") ? html`<${OfficeSharesCard} />` : null}
1378
1380
  ${has("access") ? html`<${PageSharesCard} />` : null}
1379
1381
  ${has("access") && !overPublicLink ? html`<${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />` : null}
1382
+ ${/* #3008: a web-only fleet's connections from outside — changed on this computer only, shown (read-only) elsewhere. */ ""}
1383
+ ${has("access") ? html`<${OutsideCard} />` : null}
1380
1384
  ${has("chat") ? lock(html`<section class="card"><h3>${tn("webChat")}</h3>
1381
1385
  <label class=${fc("check", "echo")}><input id="webEchoToChannel" type="checkbox" checked=${f.echo} onChange=${(e) => set("echo")(e.target.checked)} /><span>${tn("webEcho")}</span><${Impact} schema=${schema} field="web.echo_to_channel" /></label>
1382
1386
  <p class="note">${tn("webEchoHint")}</p>
@@ -281,8 +281,13 @@ export function AgentDialog({ name, inst, ctx, onClose }) {
281
281
  auto: f.inherit.auto_pause_after ? "" : nonNegative(f.auto_pause_after),
282
282
  hang: f.inherit.hang ? "" : positive(f.hang),
283
283
  };
284
+ // #3008 G1: a fleet with no chat connection has one General at most — ticking a second says which one it is, right
285
+ // by the checkbox (kept ticked, Save waits), by the name the chat list shows (the leader's string 18).
286
+ const otherGeneral = !channelsOf(fleet).length && f.general_topic && !inst.general_topic
287
+ ? Object.entries(fleet.instances || {}).find(([other, c]) => other !== name && c && (c.general_topic === true || (other === "general" && c.general_topic !== false))) : null;
288
+ const generalError = otherGeneral ? tn("oneGeneral", ctx.shortName(otherGeneral[0], otherGeneral[1])) : "";
284
289
  const failoverWarn = !f.inherit.model_failover && !f.model_failover.trim() ? "failoverEmpty" : "";
285
- const ready = !errors.auto && !errors.hang;
290
+ const ready = !errors.auto && !errors.hang && !generalError;
286
291
  const pending = () => agentPatch({ ...f, status_emojis: emojis.value }, inst, defaults, visibilityBaseline, emojis.baseline);
287
292
  const stage = () => {
288
293
  if (!ready) return;
@@ -360,8 +365,9 @@ export function AgentDialog({ name, inst, ctx, onClose }) {
360
365
  </div>
361
366
  <div class="field">${L(tn("systemPrompt"), "instance.systemPrompt", "ag-sys")}<input id="ag-sys" type="text" placeholder="e.g. file:prompts/dev.md" value=${f.systemPrompt} onInput=${(e) => set("systemPrompt")(e.target.value)} /></div>
362
367
  <div class="field">${L(tn("tags"), "instance.tags")}<${ChipList} items=${f.tags} onChange=${set("tags")} placeholder=${tn("tagPlaceholder")} label=${tn("tags")} /></div>
363
- <label class="check"><input type="checkbox" checked=${f.general_topic} onChange=${(e) => set("general_topic")(e.target.checked)} /><span>${tn("generalTopicField")}</span>
368
+ <label class="check"><input id="ag-general" type="checkbox" checked=${f.general_topic} onChange=${(e) => set("general_topic")(e.target.checked)} /><span>${tn("generalTopicField")}</span>
364
369
  <${Impact} schema=${schema} field="instance.general_topic" /></label>
370
+ ${generalError ? html`<p class="feedback error" role="alert" id="ag-general-one">${generalError}</p>` : null}
365
371
  </${Drawer}>
366
372
  <${Drawer} title=${tn("statusEmojis")}>
367
373
  <p class="note">${tn("statusEmojisInstance")} <${Impact} schema=${schema} field="instance.status_emojis" /></p>
@@ -23,7 +23,8 @@ export const TOOL_SETS = ["worker", "coordinator", "standard", "minimal", "full"
23
23
  export function toolSetDefault(defaults, inst, name = "") {
24
24
  if (TOOL_SETS.includes(defaults && defaults.tool_set)) return defaults.tool_set;
25
25
  const general = hasOwn(inst, "general_topic") ? inst.general_topic === true : !!defaults && defaults.general_topic === true;
26
- return general || name === "general" ? "general" : "worker";
26
+ // The literal-name fallback, unless unticked (general-instance.ts generalByName: an explicit general_topic: false).
27
+ return general || (name === "general" && !(hasOwn(inst, "general_topic") && inst.general_topic === false)) ? "general" : "worker";
27
28
  }
28
29
  export const TOOL_PROGRESS = ["off", "standard", "verbose"];
29
30
  export const AGENT_MODES = ["mcp", "cli"];
@@ -0,0 +1,176 @@
1
+ // #3008 ② (alpha.3; decision 28c2d3a3 rule 3, the user's simplified form hmsg-33c3d609): Settings → Access & security →
2
+ // "Connections from outside", on a fleet with no chat connection. This computer's admin, here, sets up an authenticator
3
+ // app (a QR code and the key, then its first code), and turns on "Allow connections from outside": the public link
4
+ // opens, and signing in through it asks for the app's code. Turning it off closes the link and signs everyone out there.
5
+ // Every change applies at once (an action, not a staged setting) and is this computer's only: elsewhere the card shows
6
+ // the state, read-only. The server decides who may (GET /ui/outside's `here`) and refuses everything else regardless.
7
+ // The words are the leader's (cid-alpha3-design, strings 1–13 and 17).
8
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
9
+ import { t, register } from "/assets/app-i18n.js";
10
+ import { toast } from "/assets/ui-toast.js";
11
+ import { confirmDialog } from "/assets/ui-confirm.js";
12
+ import { api } from "./settings-confirm.js";
13
+
14
+ register("outside", {
15
+ en: {
16
+ title: "Connections from outside", switch: "Allow connections from outside",
17
+ needApp: "Set up an authenticator app first. Signing in from outside will ask for its code.",
18
+ setUp: "Set up authenticator app",
19
+ scan: "Scan this with Authy, Google Authenticator or Microsoft Authenticator, or type the key. Then enter the 6-digit code it shows.",
20
+ setUpOn: "Authenticator app set up on {0}.", again: "Set up again",
21
+ againConfirm: "Codes from the old app stop working, and everyone signed in from outside is signed out.",
22
+ remove: "Remove", removeConfirm: "Connections from outside are turned off, and everyone signed in from outside is signed out.",
23
+ openAt: "Open at {0}.", quick: "A quick link gets a new address each time it opens.",
24
+ hereOnly: "Only on the AgEnD computer.", lostPhone: "Lost your phone? On the AgEnD computer, choose “Set up again”.",
25
+ wrong: "That code didn't work. Check that your phone's clock is right and try the newest code.",
26
+ paused: "Too many wrong codes. Try again in {0}.", code: "Authenticator code", key: "Key",
27
+ minutes: "{0} minutes", minute: "1 minute", hours: "{0} hours", hour: "1 hour",
28
+ },
29
+ "zh-TW": {
30
+ title: "從外面連線", switch: "允許從外面連線",
31
+ needApp: "請先設定驗證器 App。從外面登入時會要求輸入它的代碼。",
32
+ setUp: "設定驗證器 App",
33
+ scan: "用 Authy、Google Authenticator 或 Microsoft Authenticator 掃描,或手動輸入金鑰,再輸入 App 顯示的 6 位數代碼。",
34
+ setUpOn: "驗證器 App 已於 {0} 設定。", again: "重新設定",
35
+ againConfirm: "舊 App 的代碼會失效,所有從外面登入的人都會被登出。",
36
+ remove: "移除", removeConfirm: "從外面連線會關閉,所有從外面登入的人都會被登出。",
37
+ openAt: "已開啟:{0}。", quick: "快速連結每次開啟網址都不同。",
38
+ hereOnly: "只能在 AgEnD 電腦上變更。", lostPhone: "手機遺失?請在 AgEnD 電腦上選「重新設定」。",
39
+ wrong: "代碼不對。請確認手機時間正確,並輸入最新的代碼。",
40
+ paused: "錯誤次數太多,請 {0} 後再試。", code: "驗證器代碼", key: "金鑰",
41
+ minutes: "{0} 分鐘", minute: "1 分鐘", hours: "{0} 小時", hour: "1 小時",
42
+ },
43
+ });
44
+ const to = (k, ...v) => t(`outside.${k}`, ...v);
45
+
46
+ /** A wait as words: "15 minutes" / 「15 分鐘」, "2 hours" / 「2 小時」 (rounded up). */
47
+ export function waitWords(ms) {
48
+ const minutes = Math.max(1, Math.ceil(ms / 60000));
49
+ if (minutes < 60) return minutes === 1 ? to("minute") : to("minutes", minutes);
50
+ const hours = Math.ceil(minutes / 60);
51
+ return hours === 1 ? to("hour") : to("hours", hours);
52
+ }
53
+
54
+ /** The key as an app shows it: groups of four. */
55
+ export function keyGroups(secret) {
56
+ return String(secret || "").replace(/(.{4})(?=.)/g, "$1 ");
57
+ }
58
+
59
+ /**
60
+ * The QR code of an otpauth:// URI as one SVG path (dark modules), drawn by the page — no inline style, no innerHTML
61
+ * (the CSP). `qrcode` is the vendored encoder's factory (qrcode.module.js).
62
+ */
63
+ export function qrPath(qrcode, uri) {
64
+ const qr = qrcode(0, "M");
65
+ qr.addData(uri);
66
+ qr.make();
67
+ const n = qr.getModuleCount();
68
+ let d = "";
69
+ for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (qr.isDark(r, c)) d += `M${c + 4} ${r + 4}h1v1h-1z`;
70
+ return { size: n + 8, d };
71
+ }
72
+
73
+ function Qr({ uri }) {
74
+ const [shape, setShape] = useState(null);
75
+ useEffect(() => {
76
+ let live = true;
77
+ import("/assets/qrcode.module.js").then((m) => { if (live) setShape(qrPath(m.default || m.qrcode, uri)); }).catch(() => { if (live) setShape(null); });
78
+ return () => { live = false; };
79
+ }, [uri]);
80
+ if (!shape) return null;
81
+ return html`<svg class="s-out-qr" id="outQr" viewBox=${`0 0 ${shape.size} ${shape.size}`} width="200" height="200" role="img" aria-label=${to("setUp")}>
82
+ <rect width=${shape.size} height=${shape.size} fill="#fff" /><path d=${shape.d} fill="#000" /></svg>`;
83
+ }
84
+
85
+ /**
86
+ * #3035 review: how often the card reads the state again while it is shown — quickly while the link is opening (the
87
+ * switch answers before the link is up), slowly otherwise (its lifetime ending, a wait starting or ending elsewhere).
88
+ */
89
+ export const outsidePoll = { quickMs: 2000, slowMs: 15000 };
90
+
91
+ export function OutsideCard() {
92
+ const [s, setS] = useState(null), [enroll, setEnroll] = useState(null), [code, setCode] = useState(""), [msg, setMsg] = useState(""), [busy, setBusy] = useState(false);
93
+ // Fences: an answer that arrives after the card is gone, or after a newer read began, is dropped; nor does a read
94
+ // land while an action is under way (the box shows the click until the action's own read).
95
+ const live = useRef({ mounted: true, seq: 0, busy: false });
96
+ // Every read that ends (answered or not) moves `rev`: the next one is scheduled from there, so a failed read (the
97
+ // network, a 5xx, an unreadable answer) never stops the card reading again (#3035 review r2) — and keeps what it
98
+ // showed meanwhile.
99
+ const [rev, setRev] = useState(0), failed = useRef(false);
100
+ const load = async () => {
101
+ const seq = ++live.current.seq;
102
+ let r = null;
103
+ try { r = await api("/ui/outside"); } catch { r = null; }
104
+ if (!live.current.mounted || seq !== live.current.seq) return;
105
+ const ok = !!(r && r.ok && r.body && typeof r.body === "object");
106
+ failed.current = !ok;
107
+ if (ok) setS(r.body);
108
+ setRev((n) => n + 1);
109
+ };
110
+ useEffect(() => { live.current.mounted = true; load(); return () => { live.current.mounted = false; }; }, []);
111
+ useEffect(() => {
112
+ if (rev === 0) return undefined; // the first read is still out
113
+ if (s && !s.webOnly && !failed.current) return undefined; // a chat fleet: nothing to follow
114
+ const opening = !!s && s.outside && (!s.link || s.link.state !== "open");
115
+ const delay = !s || opening ? outsidePoll.quickMs : outsidePoll.slowMs; // none read yet, or opening: soon
116
+ const timer = setTimeout(() => { if (!live.current.busy) load(); else setRev((n) => n + 1); }, delay);
117
+ return () => clearTimeout(timer);
118
+ }, [rev]);
119
+ if (!s || !s.webOnly) return null;
120
+ const here = s.here === true;
121
+ const act = async (fn) => {
122
+ setBusy(true); setMsg(""); live.current.busy = true; live.current.seq++; // a read under way is outdated now
123
+ try { await fn(); } finally { live.current.busy = false; if (live.current.mounted) { setBusy(false); load(); } }
124
+ };
125
+ const begin = () => act(async () => {
126
+ const r = await api("/ui/outside/enroll", { method: "POST", body: "{}" });
127
+ if (r.ok && r.body && r.body.secret) { setEnroll(r.body); setCode(""); } else toast(t("app.failed"), false);
128
+ });
129
+ const confirm = () => act(async () => {
130
+ const r = await api("/ui/outside/enroll/confirm", { method: "POST", body: JSON.stringify({ code }) });
131
+ if (r.ok) { setEnroll(null); setCode(""); return; }
132
+ const why = r.body && r.body.error;
133
+ if (why === "expired") { setEnroll(null); return; }
134
+ setMsg(why === "wrong" ? to("wrong") : t("app.failed"));
135
+ });
136
+ const again = async () => {
137
+ if (!(await confirmDialog({ title: to("again"), message: to("againConfirm"), confirmLabel: to("again"), danger: true }))) return;
138
+ begin();
139
+ };
140
+ const remove = async () => {
141
+ if (!(await confirmDialog({ title: to("remove"), message: to("removeConfirm"), confirmLabel: to("remove"), danger: true }))) return;
142
+ act(async () => { const r = await api("/ui/outside/remove", { method: "POST", body: "{}" }); if (!r.ok) toast(t("app.failed"), false); });
143
+ };
144
+ // The box follows the click at once (smoke-web-outside-3008: a controlled box that waits for the server reads as a
145
+ // click that did nothing); the server's state, read again after, is what stays.
146
+ const flip = (on) => { setS((x) => ({ ...x, outside: on })); act(async () => {
147
+ const r = await api("/ui/outside/switch", { method: "POST", body: JSON.stringify({ on }) });
148
+ if (!r.ok) toast(t("app.failed"), false);
149
+ }); };
150
+ const link = s.link, open = link && link.state === "open";
151
+ return html`<section class="card s-outside" id="outsideCard"><h3>${to("title")}</h3>
152
+ ${!here ? html`<p class="note" id="outHereOnly">${to("hereOnly")}</p>` : null}
153
+ <label class="check"><input id="outSwitch" type="checkbox" checked=${s.outside} disabled=${!here || busy || (!s.enrolled && !s.outside)}
154
+ onChange=${(e) => flip(e.target.checked)} /><span>${to("switch")}</span></label>
155
+ ${!s.enrolled ? html`<p class="note" id="outNeedApp">${to("needApp")}</p>` : null}
156
+ ${s.outside && open && link.url ? html`<p class="note" id="outOpenAt">${to("openAt", link.url)}${link.quick ? ` ${to("quick")}` : ""}</p>` : null}
157
+ ${s.outside && link && !open ? html`<p class="note" id="outStarting">${t("app.loading")}</p>` : null}
158
+ ${s.pausedForMs > 0 ? html`<p class="note s-va-warn" role="status" id="outPaused">${to("paused", waitWords(s.pausedForMs))}</p>` : null}
159
+ ${enroll ? html`<div class="s-out-enroll" id="outEnroll">
160
+ <p class="note">${to("scan")}</p>
161
+ <${Qr} uri=${enroll.uri} />
162
+ <p class="note">${to("key")}: <code id="outKey">${keyGroups(enroll.secret)}</code></p>
163
+ <div class="field"><label for="outCode">${to("code")}</label>
164
+ <input id="outCode" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="7" placeholder="123456" value=${code}
165
+ onInput=${(e) => setCode(e.target.value)} /></div>
166
+ ${msg ? html`<p class="feedback error" role="alert" id="outMsg">${msg}</p>` : null}
167
+ <div class="save-row"><button type="button" class="btn" id="outCancel" disabled=${busy} onClick=${() => { setEnroll(null); setMsg(""); }}>${t("app.cancel")}</button>
168
+ <button type="button" class="btn btn-primary" id="outConfirm" disabled=${busy || !/^\s*\d{3}\s?\d{3}\s*$/.test(code)} onClick=${confirm}>${t("app.confirmOk")}</button></div>
169
+ </div>`
170
+ : s.enrolled ? html`<p class="note" id="outSetUpOn">${to("setUpOn", new Date(s.enrolledAt).toLocaleDateString())}</p>
171
+ <p class="note">${to("lostPhone")}</p>
172
+ ${here ? html`<div class="save-row"><button type="button" class="btn" id="outAgain" disabled=${busy} onClick=${again}>${to("again")}</button>
173
+ <button type="button" class="btn btn-danger" id="outRemove" disabled=${busy} onClick=${remove}>${to("remove")}</button></div>` : null}`
174
+ : here ? html`<div class="save-row"><button type="button" class="btn btn-primary" id="outSetUp" disabled=${busy} onClick=${begin}>${to("setUp")}</button></div>` : null}
175
+ </section>`;
176
+ }
@@ -12,6 +12,7 @@ import { GUESTS_SETTINGS } from "./settings-guests.js";
12
12
  const L = (en, zh) => ({ en, "zh-TW": zh });
13
13
  /** [id, section, target, labelKey | label, config key, words en, words zh] */
14
14
  const ROWS = [
15
+ ["general.location", "agent-defaults", "#general-location", null, null, ["general", "windows", "paired PC", "host", "location"], ["General", "Windows", "電腦", "主機", "執行位置"], L("Run General on", "General 執行位置")],
15
16
  // You · Preferences
16
17
  ["profile.name", "preferences", "#profile-name", "settings.profileTitle", null, ["name", "display name", "who agents see", "profile"], ["名字", "名稱", "顯示名稱", "個人資料"]],
17
18
  ["prefs.theme", "preferences", "#pref-theme", "app.theme", null, ["theme", "dark", "light", "dark mode", "colour scheme"], ["主題", "深色", "淺色", "外觀"]],
@@ -63,6 +63,7 @@ register("settings", {
63
63
  systemPrompt: "System prompt",
64
64
  tags: "Tags",
65
65
  generalTopicField: "General topic (fleet dispatcher)",
66
+ oneGeneral: "{0} is already the General. Untick it first.",
66
67
  runtimeResources: "Runtime & Resources",
67
68
  autoPause: "Auto-pause after",
68
69
  minutes: "minutes",
@@ -753,6 +754,7 @@ register("settings", {
753
754
  systemPrompt: "System prompt",
754
755
  tags: "標籤",
755
756
  generalTopicField: "General topic(fleet 分派器)",
757
+ oneGeneral: "{0} 已經是 General,請先取消勾選它。",
756
758
  runtimeResources: "執行環境與資源",
757
759
  autoPause: "閒置後自動暫停",
758
760
  minutes: "分鐘",
@@ -111,7 +111,7 @@ register("app", {
111
111
  confirmTitle: "Please confirm", confirmOk: "Continue", cancel: "Cancel",
112
112
  sidePanelLeave: "Leave {0} without saving? Your changes there will be lost.", sidePanelDiscard: "Discard changes",
113
113
  // The server's own wording (src/locale.ts needs.reason.*), so the web says what Discord says.
114
- needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_question: "Asking you a question", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_backend_signin: "Backend sign-in needed", needs_reason_config_invalid: "Configuration file needs attention", needs_reason_config_fallback: "Running on stored configuration", config_history_edit: "{0}, line {1}, column {2}: {3}. AgEnD is still running on the previous settings. The next restart would use the stored valid copy from {4}. Correct the YAML error; the file has not been changed by AgEnD.", config_history_no_history: "{0}, line {1}, column {2}: {3}. AgEnD is still running on the previous settings. No stored valid copy is available, so the next restart cannot recover automatically. Correct the YAML error before restarting; the file has not been changed by AgEnD.", config_history_fallback: "{0}, line {1}, column {2}: {3}. AgEnD started using the stored valid copy from {4}. Correct the YAML error and reload the settings. The file on disk has not been changed by AgEnD.", needs_reason_native_pane_hold: "Windows agent needs attention", needs_reason_crashed: "Crashed", needs_reason_usage_limit: "Usage limit reached · reset time unknown", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed", needs_reason_task_review: "Waiting for your confirmation", needs_reason_task_blocked: "Blocked: asks you",
114
+ needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_question: "Asking you a question", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_backend_signin: "Backend sign-in needed", needs_reason_config_invalid: "Configuration file needs attention", needs_reason_config_fallback: "Running on stored configuration", config_history_edit: "{0}, line {1}, column {2}: {3}. AgEnD is still running on the previous settings. The next restart would use the stored valid copy from {4}. Correct the YAML error; the file has not been changed by AgEnD.", config_history_no_history: "{0}, line {1}, column {2}: {3}. AgEnD is still running on the previous settings. No stored valid copy is available, so the next restart cannot recover automatically. Correct the YAML error before restarting; the file has not been changed by AgEnD.", config_history_fallback: "{0}, line {1}, column {2}: {3}. AgEnD started using the stored valid copy from {4}. Correct the YAML error and reload the settings. The file on disk has not been changed by AgEnD.", needs_reason_general_windows_offline: "General’s Windows PC is offline", needs_reason_general_location_hold: "Run General on", needs_reason_native_pane_hold: "Windows agent needs attention", needs_reason_crashed: "Crashed", needs_reason_usage_limit: "Usage limit reached · reset time unknown", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed", needs_reason_task_review: "Waiting for your confirmation", needs_reason_task_blocked: "Blocked: asks you",
115
115
  },
116
116
  "zh-TW": {
117
117
  brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet 概覽", view: "檢視", settings: "設定", instances: "Instances", newInstance: "新增 instance", newAgent: "新增 Agent",
@@ -156,6 +156,6 @@ register("app", {
156
156
  search: "搜尋", searchTitle: "搜尋聊天、agent 之間的訊息與任務({0})",
157
157
  confirmTitle: "請確認", confirmOk: "繼續", cancel: "取消",
158
158
  sidePanelLeave: "不儲存就離開「{0}」?你在那裡的修改會遺失。", sidePanelDiscard: "捨棄修改",
159
- needs_reason_hang: "沒有回應", needs_reason_exited: "已結束", needs_reason_assist: "在終端機上等待", needs_reason_question: "正在問你問題", needs_reason_permission: "需要權限", needs_reason_dangerous_command: "危險指令待確認", needs_reason_login: "需要登入", needs_reason_dialog: "停在對話框", needs_reason_terminal_input: "可能在等輸入", needs_reason_auth_paused: "已暫停:需要登入", needs_reason_backend_signin: "後端需要登入", needs_reason_config_invalid: "設定檔需要處理", needs_reason_config_fallback: "正在使用儲存的設定副本", config_history_edit: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 仍使用先前的設定執行。下次重新啟動會改用 {4} 儲存的有效副本。請修正 YAML 錯誤;AgEnD 未更動此檔案。", config_history_no_history: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 仍使用先前的設定執行。沒有可用的有效副本,下次重新啟動將無法自動恢復。請在重新啟動前修正 YAML 錯誤;AgEnD 未更動此檔案。", config_history_fallback: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 已使用 {4} 儲存的有效副本啟動。請修正 YAML 錯誤並重新載入設定。AgEnD 未更動磁碟上的檔案。", needs_reason_native_pane_hold: "Windows 代理需要處理", needs_reason_crashed: "已崩潰", needs_reason_usage_limit: "用量已達上限 · 恢復時間未知", needs_reason_delivery_uncertain: "無法確認是否送達", needs_reason_delivery_failed: "傳送失敗", needs_reason_task_review: "等你確認", needs_reason_task_blocked: "卡住,在問你",
159
+ needs_reason_hang: "沒有回應", needs_reason_exited: "已結束", needs_reason_assist: "在終端機上等待", needs_reason_question: "正在問你問題", needs_reason_permission: "需要權限", needs_reason_dangerous_command: "危險指令待確認", needs_reason_login: "需要登入", needs_reason_dialog: "停在對話框", needs_reason_terminal_input: "可能在等輸入", needs_reason_auth_paused: "已暫停:需要登入", needs_reason_backend_signin: "後端需要登入", needs_reason_config_invalid: "設定檔需要處理", needs_reason_config_fallback: "正在使用儲存的設定副本", config_history_edit: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 仍使用先前的設定執行。下次重新啟動會改用 {4} 儲存的有效副本。請修正 YAML 錯誤;AgEnD 未更動此檔案。", config_history_no_history: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 仍使用先前的設定執行。沒有可用的有效副本,下次重新啟動將無法自動恢復。請在重新啟動前修正 YAML 錯誤;AgEnD 未更動此檔案。", config_history_fallback: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 已使用 {4} 儲存的有效副本啟動。請修正 YAML 錯誤並重新載入設定。AgEnD 未更動磁碟上的檔案。", needs_reason_general_windows_offline: "General 所在的 Windows 電腦已離線", needs_reason_general_location_hold: "General 執行位置", needs_reason_native_pane_hold: "Windows 代理需要處理", needs_reason_crashed: "已崩潰", needs_reason_usage_limit: "用量已達上限 · 恢復時間未知", needs_reason_delivery_uncertain: "無法確認是否送達", needs_reason_delivery_failed: "傳送失敗", needs_reason_task_review: "等你確認", needs_reason_task_blocked: "卡住,在問你",
160
160
  },
161
161
  });
@@ -1348,6 +1348,21 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1348
1348
  /* #1844 Fleet overview → Docs: the arrow says "opens a new tab". */
1349
1349
  .hd-docs .hd-ext { color: var(--text-3); }
1350
1350
  .v-term { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: 5px 6px; background: #000000; position: relative; }
1351
+ /* M-2262: the terminal's box — the scrolling terminal, and its scrollback controls over it (they never scroll with it). */
1352
+ /* --v-term-rev: the View's mark (panel-view.js VIEW_REV): a tab still on an older stylesheet reads this one again. */
1353
+ .v-term-box { --v-term-rev: 2; flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
1354
+ .v-term:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
1355
+ .v-term-earlier, .v-term-latest { position: absolute; right: var(--s-4); z-index: 2; display: inline-flex; align-items: center; gap: 4px; opacity: .88; }
1356
+ .v-term-earlier { top: var(--s-2); }
1357
+ .v-term-latest { bottom: var(--s-3); opacity: 1; }
1358
+ .v-term-logbar { flex-shrink: 0; margin: 0; padding: var(--s-2) var(--s-4); background: #111111; border-bottom: 1px solid #2a2a2a; text-align: right; font-size: var(--fs-sm); }
1359
+ .v-term-logbar a { display: inline-flex; align-items: center; gap: 4px; color: #9cc8ff; text-decoration: underline; text-underline-offset: 2px; min-height: 28px; }
1360
+ @media (pointer: coarse) { .v-term-logbar a { min-height: 44px; } }
1361
+ .v-term-latest.has-new { background: var(--accent-fill); border-color: transparent; color: var(--on-accent); }
1362
+ .v-term-earlier:hover, .v-term-earlier:focus-visible { opacity: 1; }
1363
+ .v-term-history-note { position: absolute; left: var(--s-3); right: var(--s-3); bottom: var(--s-3); z-index: 2; margin: 0; padding: var(--s-1) var(--s-3); border-radius: var(--r-sm); background: rgba(20, 20, 20, .92); border: 1px solid #3a3a3a; color: #e0e0e0; font-size: var(--fs-sm); }
1364
+ @media (max-width: 600px) { .v-term-history-note { left: var(--s-4); right: var(--s-4); } }
1365
+ @media (pointer: coarse) { .v-term-earlier, .v-term-latest { min-height: 44px; padding-left: var(--s-3); padding-right: var(--s-3); } }
1351
1366
  .v-pre { margin: 0; min-width: 100%; min-height: 100%; font-family: var(--font-mono); line-height: 1.2; white-space: pre; color: #d0d0d0; }
1352
1367
  .v-pre .ansi[data-b] { font-weight: var(--fw-bold); }
1353
1368
  /* #1582: faint text (SGR 2) — a CLI's suggestion or placeholder, never input — stays faint here. */
@@ -2317,7 +2332,7 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
2317
2332
  .p-fleet > .seg, .p-settings > .seg,
2318
2333
  .p-fleet > .panel-body, .p-needs > .panel-body, .p-notices > .panel-body, .p-settings > .panel-body,
2319
2334
  .p-log > .panel-body, .p-details > .panel-body, .p-view > .panel-body,
2320
- .p-log > .log-bar, .p-view > .v-term, .p-view > .v-id, .p-view > .v-watching { position: relative; z-index: 1; }
2335
+ .p-log > .log-bar, .p-view > .v-term-box, .p-view > .v-id, .p-view > .v-watching { position: relative; z-index: 1; }
2321
2336
  /* Keep the existing Apply bar's sticky behavior, in its own bounded stock band. */
2322
2337
  .p-settings > .s-pending { z-index: 1; }
2323
2338
 
@@ -294,6 +294,8 @@ function writeFacetsOpen(open) { try { localStorage.setItem(FACETS_KEY, JSON.str
294
294
 
295
295
  /** The Favorites group's key among the tag groups: no tag can be it (a group name is never a control character). */
296
296
  const FAV_GROUP = "\u0000favorites";
297
+ /** #3008 G2: a web-only fleet's General (its status row says `general: true`), pinned first under its own head. */
298
+ const GENERAL_GROUP = "\u0000general";
297
299
  /** #1936: the open instance, pinned on top while a filter is on. */
298
300
  const PINNED_GROUP = "\u0000pinned";
299
301
  function persistOrder(groupNames, instByGroup) {
@@ -515,7 +517,10 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
515
517
  // their order. Changing them: a signed-in page that may write preferences (not the public link, not the anonymous
516
518
  // reader — they still see the list).
517
519
  const known = new Set(byName.keys());
518
- const favs = nav.favorites.filter((n) => known.has(n));
520
+ // #3008 G2: the General, when the server marks one (a web-only fleet), comes first — out of every other group.
521
+ const general = items.find((it) => it && it.general === true);
522
+ const generalName = general ? nameOf(general) : null;
523
+ const favs = nav.favorites.filter((n) => known.has(n) && n !== generalName);
519
524
  const favSet = new Set(favs);
520
525
  const canStar = !viewOnly && prefsWritable("nav.favorites");
521
526
  // #1920: a guest's list is not reordered (the order is the host's). #2855: it stars, sorts and filters as its own
@@ -533,14 +538,15 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
533
538
  let shownGroups, shownInsts;
534
539
  if (recent) {
535
540
  const sortRecent = byRecentReply(ageOf);
536
- const rest = groupNames.flatMap((g) => instByGroup.get(g) || []).filter((n) => !favSet.has(n)).sort(sortRecent);
541
+ const rest = groupNames.flatMap((g) => instByGroup.get(g) || []).filter((n) => !favSet.has(n) && n !== generalName).sort(sortRecent);
537
542
  shownGroups = [...(favs.length ? [FAV_GROUP] : []), ...(rest.length ? [RECENT_GROUP] : [])];
538
543
  shownInsts = new Map([[FAV_GROUP, favsSorted ? [...favs].sort(sortRecent) : favs], [RECENT_GROUP, rest]]);
539
544
  } else {
540
545
  shownGroups = favs.length ? [FAV_GROUP, ...groupNames] : groupNames;
541
- shownInsts = new Map([...instByGroup].map(([g, names]) => [g, names.filter((n) => !favSet.has(n))]));
546
+ shownInsts = new Map([...instByGroup].map(([g, names]) => [g, names.filter((n) => !favSet.has(n) && n !== generalName)]));
542
547
  if (favs.length) shownInsts.set(FAV_GROUP, favs);
543
548
  }
549
+ if (generalName) { shownGroups = [GENERAL_GROUP, ...shownGroups]; shownInsts.set(GENERAL_GROUP, [generalName]); }
544
550
  const f = nav.filter;
545
551
  const shown = filterSidebar(shownGroups, shownInsts, byName, f.q, backendLabel, { status: f.status, cli: f.cli, statusOf: facetOf });
546
552
  // #1936: while a filter is on, the instance you have open stays on top, pinned and marked "Current", whether or not
@@ -655,6 +661,10 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
655
661
  if (g === RECENT_GROUP) return html`<div key=${g} class="v-group v-recent" role="group" aria-label=${tn("recentOthers")}>
656
662
  <div class="v-group-head recent-head"><${Icon} name="clock" size=${14} /><span class="grow">${tn("recentOthers")}</span><span class="count">${names.length}</span></div>
657
663
  <ul class="inst-list">${names.map((name, i) => row(name, g, i, names.length))}</ul></div>`;
664
+ // #3008 G2: the General — always open, first, not dragged or starred into another place.
665
+ if (g === GENERAL_GROUP) return html`<div key=${g} class="v-group v-general" role="group" aria-label=${tn("generalGroup")}>
666
+ <div class="v-group-head general-head"><span class="grow">${tn("generalGroup")}</span></div>
667
+ <ul class="inst-list">${names.map((name, i) => row(name, g, i, names.length))}</ul></div>`;
658
668
  // #1664: Favorites — always open, not dragged (↑ ↓ in Edit), named in the page's language.
659
669
  if (g === FAV_GROUP) return html`<div key=${g} class="v-group v-fav" role="group" aria-label=${tn("favorites")}>
660
670
  <div class="v-group-head fav-head"><${Icon} name="star" size=${14} cls="fav-mark" /><span class="grow">${tn("favorites")}</span><span class="count">${names.length}</span></div>