@songsid/agend 2.3.0-beta.16 → 2.3.0-beta.17

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 (122) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/backend/claude-code.js +13 -4
  4. package/dist/backend/claude-code.js.map +1 -1
  5. package/dist/backend/codex.js +15 -2
  6. package/dist/backend/codex.js.map +1 -1
  7. package/dist/backend-command.d.ts +2 -0
  8. package/dist/backend-command.js +3 -1
  9. package/dist/backend-command.js.map +1 -1
  10. package/dist/bug-report-cli.d.ts +30 -0
  11. package/dist/bug-report-cli.js +138 -0
  12. package/dist/bug-report-cli.js.map +1 -0
  13. package/dist/bug-report.d.ts +47 -0
  14. package/dist/bug-report.js +136 -0
  15. package/dist/bug-report.js.map +1 -0
  16. package/dist/channel/mcp-tools.js +1 -1
  17. package/dist/channel/mcp-tools.js.map +1 -1
  18. package/dist/cli.js +93 -8
  19. package/dist/cli.js.map +1 -1
  20. package/dist/config-validator.d.ts +6 -0
  21. package/dist/config-validator.js +13 -0
  22. package/dist/config-validator.js.map +1 -1
  23. package/dist/daemon.d.ts +14 -1
  24. package/dist/daemon.js +59 -3
  25. package/dist/daemon.js.map +1 -1
  26. package/dist/diagnostic-redaction.d.ts +10 -0
  27. package/dist/diagnostic-redaction.js +167 -0
  28. package/dist/diagnostic-redaction.js.map +1 -0
  29. package/dist/fleet-manager.d.ts +18 -0
  30. package/dist/fleet-manager.js +109 -8
  31. package/dist/fleet-manager.js.map +1 -1
  32. package/dist/general-knowledge/skills/bug-report/SKILL.md +31 -0
  33. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +22 -1
  34. package/dist/instance-lifecycle.d.ts +8 -1
  35. package/dist/instance-lifecycle.js +68 -12
  36. package/dist/instance-lifecycle.js.map +1 -1
  37. package/dist/locale.js +55 -2
  38. package/dist/locale.js.map +1 -1
  39. package/dist/login-controller.d.ts +45 -0
  40. package/dist/login-controller.js +66 -3
  41. package/dist/login-controller.js.map +1 -1
  42. package/dist/ls-attach-resolve.d.ts +3 -2
  43. package/dist/ls-attach-resolve.js +10 -4
  44. package/dist/ls-attach-resolve.js.map +1 -1
  45. package/dist/ls-rows.d.ts +12 -0
  46. package/dist/ls-rows.js +82 -3
  47. package/dist/ls-rows.js.map +1 -1
  48. package/dist/needs-you.d.ts +10 -0
  49. package/dist/needs-you.js +2 -1
  50. package/dist/needs-you.js.map +1 -1
  51. package/dist/outbound-handlers.js +31 -5
  52. package/dist/outbound-handlers.js.map +1 -1
  53. package/dist/quickstart-api.d.ts +9 -0
  54. package/dist/quickstart-api.js +82 -0
  55. package/dist/quickstart-api.js.map +1 -1
  56. package/dist/service-installer.js +12 -0
  57. package/dist/service-installer.js.map +1 -1
  58. package/dist/settings-effect.js +15 -1
  59. package/dist/settings-effect.js.map +1 -1
  60. package/dist/setup-form.d.ts +1 -1
  61. package/dist/setup-form.js +23 -7
  62. package/dist/setup-form.js.map +1 -1
  63. package/dist/ui/backend-signin.js +297 -0
  64. package/dist/ui/chat-msg-actions.js +176 -0
  65. package/dist/ui/chat-store.js +33 -3
  66. package/dist/ui/chat-strings.js +8 -2
  67. package/dist/ui/chat-thread.js +44 -2
  68. package/dist/ui/panel-chat.js +37 -0
  69. package/dist/ui/panel-needs.js +4 -2
  70. package/dist/ui/panel-settings.js +6 -3
  71. package/dist/ui/shared/app-shell.js +2 -1
  72. package/dist/ui/shared/app-stream.js +2 -1
  73. package/dist/ui/shared/app.css +42 -0
  74. package/dist/ui/shared/app.js +2 -0
  75. package/dist/ui/shared/usage-dialog.js +26 -3
  76. package/dist/ui/shared/view-strings.js +10 -0
  77. package/dist/ui/web-terminal/terminal.html +1 -1
  78. package/dist/update-launch.d.ts +9 -0
  79. package/dist/update-launch.js +38 -12
  80. package/dist/update-launch.js.map +1 -1
  81. package/dist/usage/format-rich.js +6 -3
  82. package/dist/usage/format-rich.js.map +1 -1
  83. package/dist/usage/i18n-keys.d.ts +1 -1
  84. package/dist/usage/i18n-keys.js +2 -1
  85. package/dist/usage/i18n-keys.js.map +1 -1
  86. package/dist/usage/i18n.d.ts +13 -1
  87. package/dist/usage/i18n.js +29 -0
  88. package/dist/usage/i18n.js.map +1 -1
  89. package/dist/usage/providers.d.ts +43 -8
  90. package/dist/usage/providers.js +58 -20
  91. package/dist/usage/providers.js.map +1 -1
  92. package/dist/usage/usage-api.d.ts +2 -0
  93. package/dist/usage/usage-api.js +180 -55
  94. package/dist/usage/usage-api.js.map +1 -1
  95. package/dist/user-systemd.d.ts +24 -0
  96. package/dist/user-systemd.js +75 -0
  97. package/dist/user-systemd.js.map +1 -0
  98. package/dist/web-api.d.ts +10 -1
  99. package/dist/web-api.js +134 -5
  100. package/dist/web-api.js.map +1 -1
  101. package/dist/web-backend-login.d.ts +130 -0
  102. package/dist/web-backend-login.js +225 -0
  103. package/dist/web-backend-login.js.map +1 -0
  104. package/dist/web-chat-history.d.ts +31 -0
  105. package/dist/web-chat-history.js +49 -1
  106. package/dist/web-chat-history.js.map +1 -1
  107. package/dist/web-commands.d.ts +5 -1
  108. package/dist/web-commands.js +1 -1
  109. package/dist/web-commands.js.map +1 -1
  110. package/dist/web-guest-access.d.ts +3 -2
  111. package/dist/web-guest-access.js +10 -6
  112. package/dist/web-guest-access.js.map +1 -1
  113. package/dist/windows-shortcuts.d.ts +78 -0
  114. package/dist/windows-shortcuts.js +203 -0
  115. package/dist/windows-shortcuts.js.map +1 -0
  116. package/launcher/agend-install.cjs +548 -0
  117. package/launcher/install-admission.cjs +4 -39
  118. package/launcher/postinstall.cjs +14 -12
  119. package/package.json +3 -2
  120. package/scripts/preinstall-guard.cjs +2 -5
  121. package/templates/windows-shortcuts.ps1 +181 -0
  122. package/launcher/old-updater-note.cjs +0 -48
@@ -15,7 +15,7 @@
15
15
  * prove they also have the code they learn nothing from it.
16
16
  */
17
17
  export declare const SETUP_CODE_PAGE_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<!-- Proof that a request reached THIS listener and not merely something that\n answers 200. The tunnel's readiness probe looks for exactly this. It is the\n sid, which is already in the URL, so it discloses nothing new. -->\n<meta name=\"agend-setup\" content=\"__AGEND_SETUP_MARKER__\">\n<title>Set up AgEnD</title>\n<style>\n body { font-family: Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; margin: 0; background: #ffffff; color: #111827; }\n main { max-width: 420px; margin: 0 auto; padding: 64px 20px; }\n h1 { font-size: 20px; margin: 0 0 8px; }\n p { color: #6b7280; font-size: 13px; margin: 0 0 20px; }\n input { width: 100%; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 20px; letter-spacing: 3px; text-align: center; text-transform: uppercase; box-sizing: border-box; }\n button { width: 100%; margin-top: 12px; padding: 12px; border: 1px solid #2563eb; border-radius: 6px; background: #2563eb; color: #fff; font-size: 15px; cursor: pointer; }\n button:disabled { opacity: .5; cursor: default; }\n .err { color: #dc2626; font-size: 13px; margin-top: 12px; min-height: 18px; }\n</style>\n</head>\n<body>\n<main>\n <h1>Set up AgEnD</h1>\n <p>Enter the setup code shown in the terminal where you ran <code>agend setup</code>.</p>\n <form id=\"f\">\n <input id=\"code\" autocomplete=\"off\" autocapitalize=\"characters\" spellcheck=\"false\" placeholder=\"ABCD-EFGH\" maxlength=\"12\" autofocus>\n <button id=\"go\" type=\"submit\">Continue</button>\n </form>\n <div class=\"err\" id=\"msg\"></div>\n</main>\n<script>\n(() => {\n \"use strict\";\n const msg = document.getElementById(\"msg\");\n const go = document.getElementById(\"go\");\n document.getElementById(\"f\").addEventListener(\"submit\", async (event) => {\n event.preventDefault();\n go.disabled = true;\n msg.textContent = \"\";\n let res;\n try {\n res = await fetch(\"open\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ code: document.getElementById(\"code\").value }),\n });\n } catch {\n msg.textContent = \"Could not reach the setup page.\";\n go.disabled = false;\n return;\n }\n if (res.ok) { location.reload(); return; }\n let body = null; try { body = await res.json(); } catch {}\n msg.textContent = (body && body.error) || \"That code was not accepted.\";\n // A spent budget ends the page; there is nothing useful left to type into.\n if (res.status !== 410) go.disabled = false;\n });\n})();\n</script>\n</body>\n</html>\n";
18
- export declare const SETUP_FORM_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Set up AgEnD</title>\n<style>\n * { box-sizing: border-box; }\n body { font-family: Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; margin: 0; background: #ffffff; color: #111827; }\n main { max-width: 640px; margin: 0 auto; padding: 32px 20px 64px; }\n h1 { font-size: 20px; margin: 0 0 4px; }\n .sub { color: #6b7280; font-size: 13px; }\n .step { border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-top: 16px; }\n .step h2 { font-size: 14px; margin: 0 0 12px; }\n label { display: block; font-size: 13px; font-weight: 600; margin: 12px 0 4px; }\n input, select { width: 100%; padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; }\n button { padding: 8px 14px; border: 1px solid #e5e7eb; border-radius: 6px; background: #fff; font-size: 14px; cursor: pointer; }\n button.primary { background: #2563eb; color: #fff; border-color: #2563eb; }\n button:disabled { opacity: .5; cursor: default; }\n .row { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }\n .msg { font-size: 13px; }\n .ok { color: #059669; } .err { color: #dc2626; }\n pre { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px; font-size: 12px; overflow: auto; }\n</style>\n</head>\n<body>\n<main>\n <h1>Set up AgEnD</h1>\n <div class=\"sub\" id=\"intro\">This page is open only while setup is running. It closes itself when you finish, or after 15 minutes.</div>\n\n <div class=\"step\">\n <h2>1 \u00B7 Agent</h2>\n <label for=\"backend\">Backend</label>\n <select id=\"backend\"></select>\n <div class=\"sub\" id=\"backendHint\"></div>\n <label for=\"dir\">Working directory (absolute path)</label>\n <input id=\"dir\" placeholder=\"/home/you/projects/app\">\n <label for=\"name\">First agent name</label>\n <input id=\"name\" value=\"agent-1\">\n </div>\n\n <div class=\"step\">\n <h2>2 \u00B7 Platform</h2>\n <div class=\"row\">\n <button id=\"pickTelegram\" class=\"primary\">Telegram</button>\n <button id=\"pickDiscord\">Discord</button>\n </div>\n <div class=\"sub\" id=\"platformHint\">Create a bot with @BotFather, then add it to a group.</div>\n </div>\n\n <div class=\"step\">\n <h2>3 \u00B7 Credentials</h2>\n <label for=\"token\">Bot token</label>\n <input id=\"token\" type=\"password\" placeholder=\"123456:ABC-DEF\u2026\">\n <div class=\"sub\">Written to ~/.agend/.env and never shown again.</div>\n <label for=\"tokenEnv\">Environment variable</label>\n <input id=\"tokenEnv\" value=\"AGEND_BOT_TOKEN\">\n <div class=\"row\"><button id=\"verify\">Verify</button><span class=\"msg\" id=\"verifyMsg\"></span></div>\n <div id=\"platformFields\"></div>\n </div>\n\n <div class=\"step\">\n <h2>4 \u00B7 Review and start</h2>\n <div class=\"row\"><button id=\"preview\">Show what will be written</button></div>\n <pre id=\"planOut\" hidden></pre>\n <div class=\"row\"><button id=\"finish\" class=\"primary\" disabled>Create and start AgEnD</button><span class=\"msg\" id=\"finishMsg\"></span></div>\n </div>\n</main>\n<script>\n(() => {\n \"use strict\";\n const $ = (id) => document.getElementById(id);\n const state = { platform: \"telegram\", identity: null, guilds: [], offset: 0, plan: null };\n // Relative to the page's own directory, which is /s/<sid>/. An absolute path\n // would leave that namespace and 404 \u2014 the sid is where this page lives, not\n // a prefix bolted on to it.\n const api = async (path, opts) => {\n const res = await fetch(path, Object.assign({ headers: { \"Content-Type\": \"application/json\" } }, opts || {}));\n let body = null; try { body = await res.json(); } catch {}\n return { ok: res.ok, status: res.status, body };\n };\n\n async function loadEnvironment() {\n const res = await api(\"api/settings/quickstart/environment\");\n if (formFrozen) return;\n const backends = res.body?.backends || [];\n $(\"backend\").innerHTML = \"\";\n for (const name of backends.length ? backends : [\"claude-code\"]) {\n const opt = document.createElement(\"option\"); opt.value = name; opt.textContent = name; $(\"backend\").append(opt);\n }\n $(\"backendHint\").textContent = backends.length\n ? \"Found on this host: \" + backends.join(\", \")\n : \"No backend CLI found on this host \u2014 install one first.\";\n }\n\n function setPlatform(platform) {\n if (formFrozen) return;\n state.platform = platform;\n state.identity = null; $(\"verifyMsg\").textContent = \"\";\n $(\"pickTelegram\").className = platform === \"telegram\" ? \"primary\" : \"\";\n $(\"pickDiscord\").className = platform === \"discord\" ? \"primary\" : \"\";\n $(\"platformHint\").textContent = platform === \"telegram\"\n ? \"Create a bot with @BotFather, then add it to a group.\"\n : \"Create an application in the Discord developer portal and invite the bot to your server.\";\n renderPlatformFields();\n }\n\n // Only the ids that platform has \u2014 the same question the CLI asks.\n function renderPlatformFields() {\n if (formFrozen) return;\n const host = $(\"platformFields\"); host.innerHTML = \"\";\n const field = (id, labelText, hint) => {\n const label = document.createElement(\"label\"); label.textContent = labelText; label.htmlFor = id;\n const input = document.createElement(\"input\"); input.id = id;\n host.append(label, input);\n if (hint) { const h = document.createElement(\"div\"); h.className = \"sub\"; h.textContent = hint; host.append(h); }\n return input;\n };\n if (state.platform === \"discord\") {\n const guild = document.createElement(\"select\"); guild.id = \"guild\";\n const label = document.createElement(\"label\"); label.textContent = \"Server (guild)\"; label.htmlFor = \"guild\";\n host.append(label, guild);\n for (const g of state.guilds) { const opt = document.createElement(\"option\"); opt.value = g.id; opt.textContent = g.name + \" (\" + g.id + \")\"; guild.append(opt); }\n field(\"general\", \"General channel id\");\n field(\"admin\", \"Your Discord user id\", \"Becomes the first allowed user.\");\n } else {\n field(\"group\", \"Group id\", \"Or post any message in the group and press Detect.\");\n const row = document.createElement(\"div\"); row.className = \"row\";\n const detect = document.createElement(\"button\"); detect.id = \"detect\"; detect.textContent = \"Detect from a message\";\n detect.onclick = detectGroup; row.append(detect);\n const msg = document.createElement(\"span\"); msg.className = \"msg\"; msg.id = \"detectMsg\"; row.append(msg);\n host.append(row);\n field(\"admin\", \"Your Telegram user id\", \"Becomes the first allowed user.\");\n }\n }\n\n async function detectGroup() {\n if (formFrozen) return;\n $(\"detectMsg\").textContent = \"Waiting for a message in the group\u2026\";\n const res = await api(\"api/settings/quickstart/probe\", { method: \"POST\", body: JSON.stringify({ action: \"await-telegram-start\", token: $(\"token\").value.trim(), offset: state.offset }) });\n if (formFrozen) return;\n if (!res.ok) { $(\"detectMsg\").textContent = res.body?.error || \"Failed.\"; return; }\n state.offset = res.body.offset;\n if (res.body.found) {\n $(\"group\").value = String(res.body.found.groupId);\n $(\"admin\").value = String(res.body.found.userId);\n $(\"detectMsg\").textContent = \"Found.\";\n } else $(\"detectMsg\").textContent = \"No group message yet \u2014 post one and press Detect again.\";\n }\n\n function input() {\n return {\n platform: state.platform,\n token_env: $(\"tokenEnv\").value.trim(),\n backend: $(\"backend\").value,\n working_directory: $(\"dir\").value.trim(),\n instance_name: $(\"name\").value.trim(),\n group_id: $(\"group\") ? $(\"group\").value.trim() || undefined : undefined,\n guild_id: $(\"guild\") ? $(\"guild\").value || undefined : undefined,\n general_channel_id: $(\"general\") ? $(\"general\").value.trim() || undefined : undefined,\n admin_user_id: $(\"admin\") ? $(\"admin\").value.trim() || undefined : undefined,\n };\n }\n\n $(\"verify\").onclick = async () => {\n if (formFrozen) return;\n $(\"verifyMsg\").textContent = \"Asking the provider\u2026\";\n const token = $(\"token\").value.trim();\n const res = await api(\"api/settings/quickstart/probe\", { method: \"POST\", body: JSON.stringify({ action: \"verify\", platform: state.platform, token }) });\n if (formFrozen) return;\n state.identity = res.body?.identity || { valid: false };\n $(\"verifyMsg\").className = \"msg \" + (state.identity.valid ? \"ok\" : \"err\");\n $(\"verifyMsg\").textContent = state.identity.valid ? \"\u2713 \" + (state.identity.username || \"verified\") : \"\u2717 \" + (state.identity.reason || \"rejected\");\n if (state.identity.valid && state.platform === \"discord\") {\n const guilds = await api(\"api/settings/quickstart/probe\", { method: \"POST\", body: JSON.stringify({ action: \"guilds\", token }) });\n if (formFrozen) return;\n state.guilds = guilds.body?.guilds || [];\n renderPlatformFields();\n }\n };\n\n $(\"preview\").onclick = async () => {\n if (formFrozen) return;\n const res = await api(\"api/settings/quickstart/plan\", { method: \"POST\", body: JSON.stringify(input()) });\n if (formFrozen) return;\n if (!res.ok) { $(\"planOut\").hidden = false; $(\"planOut\").textContent = res.body?.error || \"Failed.\"; return; }\n state.plan = res.body;\n $(\"planOut\").hidden = false;\n $(\"planOut\").textContent = JSON.stringify(res.body, null, 2);\n $(\"finish\").disabled = !state.identity?.valid;\n };\n\n let approvedSetup = false;\n let formFrozen = false;\n let submission = null;\n let pollGeneration = 0;\n const freezeFields = (frozen) => {\n formFrozen = frozen;\n for (const field of document.querySelectorAll(\"main input, main select, #verify, #preview, #pickTelegram, #pickDiscord, #detect\")) field.disabled = frozen;\n };\n const restoreEditing = (message) => {\n pollGeneration++; approvedSetup = false; submission = null; state.identity = null;\n freezeFields(false); $(\"finish\").disabled = true; $(\"finish\").textContent = \"Create and start AgEnD\";\n $(\"finishMsg\").className = \"msg err\"; $(\"finishMsg\").textContent = message;\n };\n for (const field of document.querySelectorAll(\"main input, main select\")) field.addEventListener(\"input\", () => {\n approvedSetup = false; submission = null; $(\"finish\").textContent = \"Create and start AgEnD\";\n });\n const watchPending = (pendingId) => {\n const generation = ++pollGeneration;\n freezeFields(true); $(\"finish\").disabled = true;\n $(\"finishMsg\").textContent = \"On the host, run: agend settings confirm \" + pendingId;\n const poll = async () => {\n if (generation !== pollGeneration) return;\n try {\n const result = await api(\"api/settings/pending/\" + pendingId);\n if (generation !== pollGeneration) return;\n if (!result.ok) { restoreEditing(\"Setup confirmation is no longer available. Review and enter the token again.\"); return; }\n const pending = result.body;\n if (pending.state === \"pending\" || pending.state === \"applying\") { setTimeout(poll, 1000); return; }\n if (pending.state !== \"applied\") { restoreEditing(pending.outcome?.message || \"Change was not applied. Review and enter the token again.\"); return; }\n approvedSetup = true; submission = null; $(\"finish\").disabled = false; $(\"finish\").textContent = \"Start AgEnD\";\n $(\"finishMsg\").textContent = \"Configuration confirmed. Select Start AgEnD to finish setup.\";\n } catch { if (generation === pollGeneration) setTimeout(poll, 1000); }\n };\n setTimeout(poll, 1000);\n };\n $(\"finish\").onclick = async () => {\n $(\"finish\").disabled = true;\n try {\n if (approvedSetup) {\n const finished = await api(\"setup/finish\", { method: \"POST\" });\n if (!finished.ok) { restoreEditing(finished.body?.error || \"Confirmation is no longer current.\"); return; }\n $(\"finishMsg\").textContent = \"AgEnD is starting. Continue in the channel you configured.\";\n if (finished.body?.watch !== false) waitForFleet();\n return;\n }\n // Preserve bytes and key on a lost HTTP response; a deliberate retry after\n // a terminal outcome receives a new key and requires secret reentry.\n submission ||= { key: crypto.randomUUID(), body: JSON.stringify(Object.assign({}, input(), { token: $(\"token\").value.trim() })) };\n freezeFields(true);\n $(\"finishMsg\").textContent = \"Requesting host confirmation\u2026\";\n const commit = await api(\"api/settings/quickstart/commit\", { method: \"POST\", headers: { \"Content-Type\": \"application/json\", \"Idempotency-Key\": submission.key }, body: submission.body });\n if (!commit.ok) { restoreEditing(commit.body?.error || \"Failed.\"); return; }\n if (commit.body?.result !== \"pending_confirmation\" || !commit.body.pending_change?.id) { restoreEditing(\"Expected a pending host confirmation.\"); return; }\n $(\"token\").value = \"\"; submission = null;\n watchPending(commit.body.pending_change.id);\n } catch { $(\"finishMsg\").textContent = \"Connection failed. Retry to find the same confirmation.\"; $(\"finish\").disabled = false; }\n };\n // Same-cookie reloads rediscover an unresolved consent request without a secret.\n api(\"api/settings/pending\").then(result => {\n const pending = result.ok && Array.isArray(result.body) && result.body.find(item => item.state === \"pending\" || item.state === \"applying\");\n if (pending) { watchPending(pending.id); return; }\n api(\"setup/status\").then(status => {\n if (!status.ok || !status.body?.finish_ready || submission || pollGeneration) return;\n approvedSetup = true; freezeFields(true); $(\"finish\").disabled = false; $(\"finish\").textContent = \"Start AgEnD\";\n $(\"finishMsg\").textContent = \"Configuration confirmed. Select Start AgEnD to finish setup.\";\n }).catch(() => {});\n }).catch(() => {});\n\n // The host is gone; the fleet is binding the same port. Connection refused is\n // the expected state in between, so it is not an error until the cap.\n function waitForFleet() {\n const deadline = Date.now() + 60_000;\n const tick = async () => {\n try {\n const res = await fetch(\"/health\", { cache: \"no-store\" });\n if (res.status) {\n $(\"finishMsg\").className = \"msg ok\";\n // #1519 P7: the fleet answers on this address now \u2014 its dashboard is here too.\n $(\"finishMsg\").textContent = \"AgEnD is up. Talk to it in the channel you just set up, or open its dashboard: \";\n const link = document.createElement(\"a\"); link.href = \"/\"; link.textContent = location.origin + \"/\";\n $(\"finishMsg\").append(link, \" (sign in with agend web --code on the machine running AgEnD, or send /web to your bot).\");\n return;\n }\n } catch { /* not listening yet */ }\n if (Date.now() > deadline) {\n $(\"finishMsg\").className = \"msg err\";\n $(\"finishMsg\").textContent = \"AgEnD has not come up within 60 seconds. Run `agend start` on the machine itself and check fleet.log \u2014 this page cannot tell you any more.\";\n return;\n }\n setTimeout(tick, 1000);\n };\n setTimeout(tick, 1500);\n }\n\n $(\"pickTelegram\").onclick = () => setPlatform(\"telegram\");\n $(\"pickDiscord\").onclick = () => setPlatform(\"discord\");\n loadEnvironment();\n setPlatform(\"telegram\");\n})();\n</script>\n</body>\n</html>\n";
18
+ export declare const SETUP_FORM_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Set up AgEnD</title>\n<style>\n * { box-sizing: border-box; }\n body { font-family: Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; margin: 0; background: #ffffff; color: #111827; }\n main { max-width: 640px; margin: 0 auto; padding: 32px 20px 64px; }\n h1 { font-size: 20px; margin: 0 0 4px; }\n .sub { color: #6b7280; font-size: 13px; }\n .step { border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-top: 16px; }\n .step h2 { font-size: 14px; margin: 0 0 12px; }\n label { display: block; font-size: 13px; font-weight: 600; margin: 12px 0 4px; }\n input, select { width: 100%; padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; }\n button { padding: 8px 14px; border: 1px solid #e5e7eb; border-radius: 6px; background: #fff; font-size: 14px; cursor: pointer; }\n button.primary { background: #2563eb; color: #fff; border-color: #2563eb; }\n button:disabled { opacity: .5; cursor: default; }\n .row { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }\n .msg { font-size: 13px; }\n .ok { color: #059669; } .err { color: #dc2626; }\n pre { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px; font-size: 12px; overflow: auto; }\n</style>\n</head>\n<body>\n<main>\n <h1>Set up AgEnD</h1>\n <div class=\"sub\" id=\"intro\">This page is open only while setup is running. It closes itself when you finish, or after 15 minutes.</div>\n\n <div class=\"step\">\n <h2>1 \u00B7 Agent</h2>\n <label for=\"backend\">Backend</label>\n <select id=\"backend\"></select>\n <div class=\"sub\" id=\"backendHint\"></div>\n <label for=\"dir\">Working directory (absolute path)</label>\n <input id=\"dir\" placeholder=\"/home/you/projects/app\">\n <label for=\"name\">First agent name</label>\n <input id=\"name\" value=\"agent-1\">\n </div>\n\n <div class=\"step\">\n <h2>2 \u00B7 Platform</h2>\n <div class=\"row\">\n <button id=\"pickTelegram\" class=\"primary\">Telegram</button>\n <button id=\"pickDiscord\">Discord</button>\n <button id=\"pickWeb\">Web only (no bot)</button>\n </div>\n <div class=\"sub\" id=\"platformHint\">Create a bot with @BotFather, then add it to a group.</div>\n </div>\n\n <div class=\"step\" id=\"credStep\">\n <h2>3 \u00B7 Credentials</h2>\n <label for=\"token\">Bot token</label>\n <input id=\"token\" type=\"password\" placeholder=\"123456:ABC-DEF\u2026\">\n <div class=\"sub\">Written to ~/.agend/.env and never shown again.</div>\n <label for=\"tokenEnv\">Environment variable</label>\n <input id=\"tokenEnv\" value=\"AGEND_BOT_TOKEN\">\n <div class=\"row\"><button id=\"verify\">Verify</button><span class=\"msg\" id=\"verifyMsg\"></span></div>\n <div id=\"platformFields\"></div>\n </div>\n\n <div class=\"step\">\n <h2>4 \u00B7 Review and start</h2>\n <div class=\"row\"><button id=\"preview\">Show what will be written</button></div>\n <pre id=\"planOut\" hidden></pre>\n <div class=\"row\"><button id=\"finish\" class=\"primary\" disabled>Create and start AgEnD</button><span class=\"msg\" id=\"finishMsg\"></span></div>\n </div>\n</main>\n<script>\n(() => {\n \"use strict\";\n const $ = (id) => document.getElementById(id);\n const state = { platform: \"telegram\", identity: null, guilds: [], offset: 0, plan: null };\n // Relative to the page's own directory, which is /s/<sid>/. An absolute path\n // would leave that namespace and 404 \u2014 the sid is where this page lives, not\n // a prefix bolted on to it.\n const api = async (path, opts) => {\n const res = await fetch(path, Object.assign({ headers: { \"Content-Type\": \"application/json\" } }, opts || {}));\n let body = null; try { body = await res.json(); } catch {}\n return { ok: res.ok, status: res.status, body };\n };\n\n async function loadEnvironment() {\n const res = await api(\"api/settings/quickstart/environment\");\n if (formFrozen) return;\n const backends = res.body?.backends || [];\n $(\"backend\").innerHTML = \"\";\n for (const name of backends.length ? backends : [\"claude-code\"]) {\n const opt = document.createElement(\"option\"); opt.value = name; opt.textContent = name; $(\"backend\").append(opt);\n }\n $(\"backendHint\").textContent = backends.length\n ? \"Found on this host: \" + backends.join(\", \")\n : \"No backend CLI found on this host \u2014 install one first.\";\n }\n\n function setPlatform(platform) {\n if (formFrozen) return;\n state.platform = platform;\n state.identity = null; $(\"verifyMsg\").textContent = \"\";\n $(\"pickTelegram\").className = platform === \"telegram\" ? \"primary\" : \"\";\n $(\"pickDiscord\").className = platform === \"discord\" ? \"primary\" : \"\";\n $(\"pickWeb\").className = platform === \"web\" ? \"primary\" : \"\";\n $(\"platformHint\").textContent = platform === \"telegram\"\n ? \"Create a bot with @BotFather, then add it to a group.\"\n : platform === \"discord\" ? \"Create an application in the Discord developer portal and invite the bot to your server.\"\n : \"No bot: you use AgEnD from its web dashboard (chat, sign-ins, settings). Discord or Telegram can be added later in Settings.\";\n // #2233: web only has no credentials step \u2014 no token, no admin id, nothing to verify.\n $(\"credStep\").hidden = platform === \"web\";\n state.plan = null; $(\"planOut\").hidden = true; $(\"finish\").disabled = true;\n renderPlatformFields();\n }\n\n // Only the ids that platform has \u2014 the same question the CLI asks.\n function renderPlatformFields() {\n if (formFrozen) return;\n const host = $(\"platformFields\"); host.innerHTML = \"\";\n if (state.platform === \"web\") return;\n const field = (id, labelText, hint) => {\n const label = document.createElement(\"label\"); label.textContent = labelText; label.htmlFor = id;\n const input = document.createElement(\"input\"); input.id = id;\n host.append(label, input);\n if (hint) { const h = document.createElement(\"div\"); h.className = \"sub\"; h.textContent = hint; host.append(h); }\n return input;\n };\n if (state.platform === \"discord\") {\n const guild = document.createElement(\"select\"); guild.id = \"guild\";\n const label = document.createElement(\"label\"); label.textContent = \"Server (guild)\"; label.htmlFor = \"guild\";\n host.append(label, guild);\n for (const g of state.guilds) { const opt = document.createElement(\"option\"); opt.value = g.id; opt.textContent = g.name + \" (\" + g.id + \")\"; guild.append(opt); }\n field(\"general\", \"General channel id\");\n field(\"admin\", \"Your Discord user id\", \"Becomes the first allowed user.\");\n } else {\n field(\"group\", \"Group id\", \"Or post any message in the group and press Detect.\");\n const row = document.createElement(\"div\"); row.className = \"row\";\n const detect = document.createElement(\"button\"); detect.id = \"detect\"; detect.textContent = \"Detect from a message\";\n detect.onclick = detectGroup; row.append(detect);\n const msg = document.createElement(\"span\"); msg.className = \"msg\"; msg.id = \"detectMsg\"; row.append(msg);\n host.append(row);\n field(\"admin\", \"Your Telegram user id\", \"Becomes the first allowed user.\");\n }\n }\n\n async function detectGroup() {\n if (formFrozen) return;\n $(\"detectMsg\").textContent = \"Waiting for a message in the group\u2026\";\n const res = await api(\"api/settings/quickstart/probe\", { method: \"POST\", body: JSON.stringify({ action: \"await-telegram-start\", token: $(\"token\").value.trim(), offset: state.offset }) });\n if (formFrozen) return;\n if (!res.ok) { $(\"detectMsg\").textContent = res.body?.error || \"Failed.\"; return; }\n state.offset = res.body.offset;\n if (res.body.found) {\n $(\"group\").value = String(res.body.found.groupId);\n $(\"admin\").value = String(res.body.found.userId);\n $(\"detectMsg\").textContent = \"Found.\";\n } else $(\"detectMsg\").textContent = \"No group message yet \u2014 post one and press Detect again.\";\n }\n\n function input() {\n // #2233: the agent alone; a web-only setup carries no connection field at all.\n if (state.platform === \"web\") return { web_only: true, backend: $(\"backend\").value, working_directory: $(\"dir\").value.trim(), instance_name: $(\"name\").value.trim() };\n return {\n platform: state.platform,\n token_env: $(\"tokenEnv\").value.trim(),\n backend: $(\"backend\").value,\n working_directory: $(\"dir\").value.trim(),\n instance_name: $(\"name\").value.trim(),\n group_id: $(\"group\") ? $(\"group\").value.trim() || undefined : undefined,\n guild_id: $(\"guild\") ? $(\"guild\").value || undefined : undefined,\n general_channel_id: $(\"general\") ? $(\"general\").value.trim() || undefined : undefined,\n admin_user_id: $(\"admin\") ? $(\"admin\").value.trim() || undefined : undefined,\n };\n }\n\n $(\"verify\").onclick = async () => {\n if (formFrozen) return;\n $(\"verifyMsg\").textContent = \"Asking the provider\u2026\";\n const token = $(\"token\").value.trim();\n const res = await api(\"api/settings/quickstart/probe\", { method: \"POST\", body: JSON.stringify({ action: \"verify\", platform: state.platform, token }) });\n if (formFrozen) return;\n state.identity = res.body?.identity || { valid: false };\n $(\"verifyMsg\").className = \"msg \" + (state.identity.valid ? \"ok\" : \"err\");\n $(\"verifyMsg\").textContent = state.identity.valid ? \"\u2713 \" + (state.identity.username || \"verified\") : \"\u2717 \" + (state.identity.reason || \"rejected\");\n if (state.identity.valid && state.platform === \"discord\") {\n const guilds = await api(\"api/settings/quickstart/probe\", { method: \"POST\", body: JSON.stringify({ action: \"guilds\", token }) });\n if (formFrozen) return;\n state.guilds = guilds.body?.guilds || [];\n renderPlatformFields();\n }\n };\n\n $(\"preview\").onclick = async () => {\n if (formFrozen) return;\n const res = await api(\"api/settings/quickstart/plan\", { method: \"POST\", body: JSON.stringify(input()) });\n if (formFrozen) return;\n if (!res.ok) { $(\"planOut\").hidden = false; $(\"planOut\").textContent = res.body?.error || \"Failed.\"; return; }\n state.plan = res.body;\n $(\"planOut\").hidden = false;\n $(\"planOut\").textContent = JSON.stringify(res.body, null, 2);\n $(\"finish\").disabled = !(state.platform === \"web\" || state.identity?.valid);\n };\n\n let approvedSetup = false;\n let formFrozen = false;\n let submission = null;\n let pollGeneration = 0;\n const freezeFields = (frozen) => {\n formFrozen = frozen;\n for (const field of document.querySelectorAll(\"main input, main select, #verify, #preview, #pickTelegram, #pickDiscord, #pickWeb, #detect\")) field.disabled = frozen;\n };\n const restoreEditing = (message) => {\n pollGeneration++; approvedSetup = false; submission = null; state.identity = null;\n freezeFields(false); $(\"finish\").disabled = true; $(\"finish\").textContent = \"Create and start AgEnD\";\n $(\"finishMsg\").className = \"msg err\"; $(\"finishMsg\").textContent = message;\n };\n for (const field of document.querySelectorAll(\"main input, main select\")) field.addEventListener(\"input\", () => {\n approvedSetup = false; submission = null; $(\"finish\").textContent = \"Create and start AgEnD\";\n });\n const watchPending = (pendingId) => {\n const generation = ++pollGeneration;\n freezeFields(true); $(\"finish\").disabled = true;\n $(\"finishMsg\").textContent = \"On the host, run: agend settings confirm \" + pendingId;\n const poll = async () => {\n if (generation !== pollGeneration) return;\n try {\n const result = await api(\"api/settings/pending/\" + pendingId);\n if (generation !== pollGeneration) return;\n if (!result.ok) { restoreEditing(\"Setup confirmation is no longer available. Review and enter the token again.\"); return; }\n const pending = result.body;\n if (pending.state === \"pending\" || pending.state === \"applying\") { setTimeout(poll, 1000); return; }\n if (pending.state !== \"applied\") { restoreEditing(pending.outcome?.message || \"Change was not applied. Review and enter the token again.\"); return; }\n approvedSetup = true; submission = null; $(\"finish\").disabled = false; $(\"finish\").textContent = \"Start AgEnD\";\n $(\"finishMsg\").textContent = \"Configuration confirmed. Select Start AgEnD to finish setup.\";\n } catch { if (generation === pollGeneration) setTimeout(poll, 1000); }\n };\n setTimeout(poll, 1000);\n };\n $(\"finish\").onclick = async () => {\n $(\"finish\").disabled = true;\n try {\n if (approvedSetup) {\n const finished = await api(\"setup/finish\", { method: \"POST\" });\n if (!finished.ok) { restoreEditing(finished.body?.error || \"Confirmation is no longer current.\"); return; }\n $(\"finishMsg\").textContent = state.platform === \"web\" ? \"AgEnD is starting. Its dashboard link shows here once it is up.\" : \"AgEnD is starting. Continue in the channel you configured.\";\n if (finished.body?.watch !== false) waitForFleet();\n return;\n }\n // Preserve bytes and key on a lost HTTP response; a deliberate retry after\n // a terminal outcome receives a new key and requires secret reentry.\n submission ||= { key: crypto.randomUUID(), body: JSON.stringify(state.platform === \"web\" ? input() : Object.assign({}, input(), { token: $(\"token\").value.trim() })) };\n freezeFields(true);\n $(\"finishMsg\").textContent = \"Requesting host confirmation\u2026\";\n const commit = await api(\"api/settings/quickstart/commit\", { method: \"POST\", headers: { \"Content-Type\": \"application/json\", \"Idempotency-Key\": submission.key }, body: submission.body });\n if (!commit.ok) { restoreEditing(commit.body?.error || \"Failed.\"); return; }\n if (commit.body?.result !== \"pending_confirmation\" || !commit.body.pending_change?.id) { restoreEditing(\"Expected a pending host confirmation.\"); return; }\n $(\"token\").value = \"\"; submission = null;\n watchPending(commit.body.pending_change.id);\n } catch { $(\"finishMsg\").textContent = \"Connection failed. Retry to find the same confirmation.\"; $(\"finish\").disabled = false; }\n };\n // Same-cookie reloads rediscover an unresolved consent request without a secret.\n api(\"api/settings/pending\").then(result => {\n const pending = result.ok && Array.isArray(result.body) && result.body.find(item => item.state === \"pending\" || item.state === \"applying\");\n if (pending) { watchPending(pending.id); return; }\n api(\"setup/status\").then(status => {\n if (!status.ok || !status.body?.finish_ready || submission || pollGeneration) return;\n approvedSetup = true; freezeFields(true); $(\"finish\").disabled = false; $(\"finish\").textContent = \"Start AgEnD\";\n $(\"finishMsg\").textContent = \"Configuration confirmed. Select Start AgEnD to finish setup.\";\n }).catch(() => {});\n }).catch(() => {});\n\n // The host is gone; the fleet is binding the same port. Connection refused is\n // the expected state in between, so it is not an error until the cap.\n function waitForFleet() {\n const deadline = Date.now() + 60_000;\n const tick = async () => {\n try {\n const res = await fetch(\"/health\", { cache: \"no-store\" });\n if (res.status) {\n $(\"finishMsg\").className = \"msg ok\";\n // #1519 P7: the fleet answers on this address now \u2014 its dashboard is here too.\n const link = document.createElement(\"a\"); link.href = \"/\"; link.textContent = location.origin + \"/\";\n if (state.platform === \"web\") {\n // #2233: no bot \u2014 the dashboard is where AgEnD is used; its sign-in code comes from the machine.\n $(\"finishMsg\").textContent = \"AgEnD is up. Open its dashboard: \";\n $(\"finishMsg\").append(link, \" \u2014 sign in with the code from agend web --code on the machine running AgEnD. Sign your agents' CLIs in from Settings \u2192 Agents \u2192 Sign-ins.\");\n return;\n }\n $(\"finishMsg\").textContent = \"AgEnD is up. Talk to it in the channel you just set up, or open its dashboard: \";\n $(\"finishMsg\").append(link, \" (sign in with agend web --code on the machine running AgEnD, or send /web to your bot).\");\n return;\n }\n } catch { /* not listening yet */ }\n if (Date.now() > deadline) {\n $(\"finishMsg\").className = \"msg err\";\n $(\"finishMsg\").textContent = \"AgEnD has not come up within 60 seconds. Run `agend start` on the machine itself and check fleet.log \u2014 this page cannot tell you any more.\";\n return;\n }\n setTimeout(tick, 1000);\n };\n setTimeout(tick, 1500);\n }\n\n $(\"pickTelegram\").onclick = () => setPlatform(\"telegram\");\n $(\"pickDiscord\").onclick = () => setPlatform(\"discord\");\n $(\"pickWeb\").onclick = () => setPlatform(\"web\");\n loadEnvironment();\n setPlatform(\"telegram\");\n})();\n</script>\n</body>\n</html>\n";
19
19
  /**
20
20
  * #1490: the setup pages' Content-Security-Policy. Their one `<script>` and one `<style>` are inline, so each response
21
21
  * gets a fresh nonce for exactly those (setupPage); nothing else may run, load from elsewhere, post elsewhere, or frame
@@ -123,11 +123,12 @@ export const SETUP_FORM_HTML = `<!DOCTYPE html>
123
123
  <div class="row">
124
124
  <button id="pickTelegram" class="primary">Telegram</button>
125
125
  <button id="pickDiscord">Discord</button>
126
+ <button id="pickWeb">Web only (no bot)</button>
126
127
  </div>
127
128
  <div class="sub" id="platformHint">Create a bot with @BotFather, then add it to a group.</div>
128
129
  </div>
129
130
 
130
- <div class="step">
131
+ <div class="step" id="credStep">
131
132
  <h2>3 · Credentials</h2>
132
133
  <label for="token">Bot token</label>
133
134
  <input id="token" type="password" placeholder="123456:ABC-DEF…">
@@ -178,9 +179,14 @@ export const SETUP_FORM_HTML = `<!DOCTYPE html>
178
179
  state.identity = null; $("verifyMsg").textContent = "";
179
180
  $("pickTelegram").className = platform === "telegram" ? "primary" : "";
180
181
  $("pickDiscord").className = platform === "discord" ? "primary" : "";
182
+ $("pickWeb").className = platform === "web" ? "primary" : "";
181
183
  $("platformHint").textContent = platform === "telegram"
182
184
  ? "Create a bot with @BotFather, then add it to a group."
183
- : "Create an application in the Discord developer portal and invite the bot to your server.";
185
+ : platform === "discord" ? "Create an application in the Discord developer portal and invite the bot to your server."
186
+ : "No bot: you use AgEnD from its web dashboard (chat, sign-ins, settings). Discord or Telegram can be added later in Settings.";
187
+ // #2233: web only has no credentials step — no token, no admin id, nothing to verify.
188
+ $("credStep").hidden = platform === "web";
189
+ state.plan = null; $("planOut").hidden = true; $("finish").disabled = true;
184
190
  renderPlatformFields();
185
191
  }
186
192
 
@@ -188,6 +194,7 @@ export const SETUP_FORM_HTML = `<!DOCTYPE html>
188
194
  function renderPlatformFields() {
189
195
  if (formFrozen) return;
190
196
  const host = $("platformFields"); host.innerHTML = "";
197
+ if (state.platform === "web") return;
191
198
  const field = (id, labelText, hint) => {
192
199
  const label = document.createElement("label"); label.textContent = labelText; label.htmlFor = id;
193
200
  const input = document.createElement("input"); input.id = id;
@@ -228,6 +235,8 @@ export const SETUP_FORM_HTML = `<!DOCTYPE html>
228
235
  }
229
236
 
230
237
  function input() {
238
+ // #2233: the agent alone; a web-only setup carries no connection field at all.
239
+ if (state.platform === "web") return { web_only: true, backend: $("backend").value, working_directory: $("dir").value.trim(), instance_name: $("name").value.trim() };
231
240
  return {
232
241
  platform: state.platform,
233
242
  token_env: $("tokenEnv").value.trim(),
@@ -266,7 +275,7 @@ export const SETUP_FORM_HTML = `<!DOCTYPE html>
266
275
  state.plan = res.body;
267
276
  $("planOut").hidden = false;
268
277
  $("planOut").textContent = JSON.stringify(res.body, null, 2);
269
- $("finish").disabled = !state.identity?.valid;
278
+ $("finish").disabled = !(state.platform === "web" || state.identity?.valid);
270
279
  };
271
280
 
272
281
  let approvedSetup = false;
@@ -275,7 +284,7 @@ export const SETUP_FORM_HTML = `<!DOCTYPE html>
275
284
  let pollGeneration = 0;
276
285
  const freezeFields = (frozen) => {
277
286
  formFrozen = frozen;
278
- for (const field of document.querySelectorAll("main input, main select, #verify, #preview, #pickTelegram, #pickDiscord, #detect")) field.disabled = frozen;
287
+ for (const field of document.querySelectorAll("main input, main select, #verify, #preview, #pickTelegram, #pickDiscord, #pickWeb, #detect")) field.disabled = frozen;
279
288
  };
280
289
  const restoreEditing = (message) => {
281
290
  pollGeneration++; approvedSetup = false; submission = null; state.identity = null;
@@ -310,13 +319,13 @@ export const SETUP_FORM_HTML = `<!DOCTYPE html>
310
319
  if (approvedSetup) {
311
320
  const finished = await api("setup/finish", { method: "POST" });
312
321
  if (!finished.ok) { restoreEditing(finished.body?.error || "Confirmation is no longer current."); return; }
313
- $("finishMsg").textContent = "AgEnD is starting. Continue in the channel you configured.";
322
+ $("finishMsg").textContent = state.platform === "web" ? "AgEnD is starting. Its dashboard link shows here once it is up." : "AgEnD is starting. Continue in the channel you configured.";
314
323
  if (finished.body?.watch !== false) waitForFleet();
315
324
  return;
316
325
  }
317
326
  // Preserve bytes and key on a lost HTTP response; a deliberate retry after
318
327
  // a terminal outcome receives a new key and requires secret reentry.
319
- submission ||= { key: crypto.randomUUID(), body: JSON.stringify(Object.assign({}, input(), { token: $("token").value.trim() })) };
328
+ submission ||= { key: crypto.randomUUID(), body: JSON.stringify(state.platform === "web" ? input() : Object.assign({}, input(), { token: $("token").value.trim() })) };
320
329
  freezeFields(true);
321
330
  $("finishMsg").textContent = "Requesting host confirmation…";
322
331
  const commit = await api("api/settings/quickstart/commit", { method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": submission.key }, body: submission.body });
@@ -347,8 +356,14 @@ export const SETUP_FORM_HTML = `<!DOCTYPE html>
347
356
  if (res.status) {
348
357
  $("finishMsg").className = "msg ok";
349
358
  // #1519 P7: the fleet answers on this address now — its dashboard is here too.
350
- $("finishMsg").textContent = "AgEnD is up. Talk to it in the channel you just set up, or open its dashboard: ";
351
359
  const link = document.createElement("a"); link.href = "/"; link.textContent = location.origin + "/";
360
+ if (state.platform === "web") {
361
+ // #2233: no bot — the dashboard is where AgEnD is used; its sign-in code comes from the machine.
362
+ $("finishMsg").textContent = "AgEnD is up. Open its dashboard: ";
363
+ $("finishMsg").append(link, " — sign in with the code from agend web --code on the machine running AgEnD. Sign your agents' CLIs in from Settings → Agents → Sign-ins.");
364
+ return;
365
+ }
366
+ $("finishMsg").textContent = "AgEnD is up. Talk to it in the channel you just set up, or open its dashboard: ";
352
367
  $("finishMsg").append(link, " (sign in with agend web --code on the machine running AgEnD, or send /web to your bot).");
353
368
  return;
354
369
  }
@@ -365,6 +380,7 @@ export const SETUP_FORM_HTML = `<!DOCTYPE html>
365
380
 
366
381
  $("pickTelegram").onclick = () => setPlatform("telegram");
367
382
  $("pickDiscord").onclick = () => setPlatform("discord");
383
+ $("pickWeb").onclick = () => setPlatform("web");
368
384
  loadEnvironment();
369
385
  setPlatform("telegram");
370
386
  })();
@@ -1 +1 @@
1
- {"version":3,"file":"setup-form.js","sourceRoot":"","sources":["../src/setup-form.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8DnC,CAAC;AAEF,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsS9B,CAAC;AAEF;;;;GAIG;AACH,MAAM,UAAU,0BAA0B,CAAC,KAAa;IACtD,OAAO;QACL,oBAAoB;QACpB,qBAAqB,KAAK,GAAG;QAC7B,oBAAoB,KAAK,GAAG;QAC5B,sBAAsB;QACtB,oBAAoB;QACpB,iBAAiB;QACjB,oBAAoB;QACpB,wBAAwB;KACzB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,+GAA+G;AAC/G,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,KAAa;IACnD,OAAO,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,kBAAkB,KAAK,IAAI,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,iBAAiB,KAAK,IAAI,CAAC,CAAC;AAChH,CAAC"}
1
+ {"version":3,"file":"setup-form.js","sourceRoot":"","sources":["../src/setup-form.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8DnC,CAAC;AAEF,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsT9B,CAAC;AAEF;;;;GAIG;AACH,MAAM,UAAU,0BAA0B,CAAC,KAAa;IACtD,OAAO;QACL,oBAAoB;QACpB,qBAAqB,KAAK,GAAG;QAC7B,oBAAoB,KAAK,GAAG;QAC5B,sBAAsB;QACtB,oBAAoB;QACpB,iBAAiB;QACjB,oBAAoB;QACpB,wBAAwB;KACzB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,+GAA+G;AAC/G,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,KAAa;IACnD,OAAO,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,kBAAkB,KAAK,IAAI,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,iBAAiB,KAAK,IAAI,CAAC,CAAC;AAChH,CAAC"}
@@ -0,0 +1,297 @@
1
+ // #2233: signing a backend in from the dashboard — the dialog, the line every admin page shows while a sign-in runs,
2
+ // and the list Settings → Agents shows. Behind the session gate (/ui/js/); a guest's page never loads it.
3
+ //
4
+ // What the page is told comes from GET /ui/backend-login, as this session may see it (web-backend-login.ts): the
5
+ // session that started a sign-in gets its terminal link, access code and device code; every other admin page only that
6
+ // one runs, on which device, and may cancel it. The stream's `backend_login` event carries nothing — it only says to
7
+ // read again. Starting is two steps, as in chat: check (the server keeps what it found) and start (with its id).
8
+ import { html, useEffect, useState } from "/assets/app-html.js";
9
+ import { t, register } from "/assets/app-i18n.js";
10
+ import { appStore, createStore, useStore } from "/assets/app-store.js";
11
+ import { closeDialog, shellStore, showDialog } from "/assets/app-shell.js";
12
+ import { Dialog } from "/assets/ui-dialog.js";
13
+ import { toast } from "/assets/ui-toast.js";
14
+
15
+ register("signin", {
16
+ en: {
17
+ title: "Sign in to {0}",
18
+ checking: "Checking whether {0} is signed in…",
19
+ confirmDevice: "AgEnD runs {0}'s own sign-in on this machine. You get a link and a one-time code here, to finish in your browser.",
20
+ confirmTerminal: "AgEnD runs {0}'s own sign-in on this machine and opens it as a terminal in your browser (only that sign-in, no shell). You get the link and a one-time access code here.",
21
+ stillSignedIn: "It is still signed in: signing in again replaces that login.",
22
+ logoutFirst: "It signs out first ({0}), then in.",
23
+ publicNote: "Not at this machine? A temporary public link opens the terminal from anywhere, for this sign-in only.",
24
+ go: "Sign in",
25
+ goPublic: "Use a temporary public link",
26
+ cancel: "Cancel",
27
+ close: "Close",
28
+ starting: "Starting…",
29
+ deviceStep: "Open this page and enter the code:",
30
+ linkStep: "Open this page to sign in; if it gives you a code, paste it into the sign-in terminal:",
31
+ terminalStep: "Open the sign-in terminal and enter this access code there. It works once, for {0} minutes.",
32
+ openTerminal: "Open the sign-in terminal",
33
+ accessCode: "Access code",
34
+ code: "Code",
35
+ copy: "Copy",
36
+ copied: "Copied",
37
+ publicLink: "Public link: anyone with the link and the code could use it — keep both to yourself.",
38
+ loopback: "This terminal listens on the machine's own address only. Open it on that machine, or set web_terminal.bind for your network.",
39
+ waitingHint: "Waiting for the sign-in to show its link…",
40
+ signedIn: "{0} is signed in.",
41
+ ended: "The sign-in ended.",
42
+ cancelSignIn: "Cancel sign-in",
43
+ cancelled: "Sign-in cancelled.",
44
+ running: "Signing in to {0} (started on {1}).",
45
+ open: "Open",
46
+ expired: "That took too long — start again.",
47
+ notHere: "Signing in is not available over the public link. Open the dashboard on the machine running AgEnD, or use /login in your chat.",
48
+ notAdmin: "Only a fleet admin signed in on this machine or its network can sign a backend in.",
49
+ notOffered: "A public link is not offered for this sign-in.",
50
+ failed: "That did not work.",
51
+ listTitle: "Sign-ins",
52
+ listHint: "Each backend's login on this machine, and each credential profile an agent uses.",
53
+ stUsable: "signed in",
54
+ stNotSignedIn: "not signed in",
55
+ stNotInstalled: "not installed",
56
+ stTooOld: "too old",
57
+ stUnknown: "not checked",
58
+ signIn: "Sign in",
59
+ signInAgain: "Sign in again",
60
+ onHost: "Sign in on the machine: {0}",
61
+ install: "Install on the machine: {0}",
62
+ update: "Update on the machine: {0}",
63
+ profile: "{0} · profile {1}",
64
+ },
65
+ "zh-TW": {
66
+ title: "登入 {0}",
67
+ checking: "正在確認 {0} 是否已登入…",
68
+ confirmDevice: "AgEnD 會在這台機器上執行 {0} 自己的登入。這裡會給你一個連結和一次性代碼,在瀏覽器完成登入。",
69
+ confirmTerminal: "AgEnD 會在這台機器上執行 {0} 自己的登入,並在瀏覽器以終端機開啟(只有這個登入,沒有 shell)。這裡會給你連結和一次性存取碼。",
70
+ stillSignedIn: "它目前仍是登入狀態:重新登入會取代原本的登入。",
71
+ logoutFirst: "會先登出({0}),再登入。",
72
+ publicNote: "不在這台機器前?臨時公開連結可以從任何地方開啟終端機,只用於這次登入。",
73
+ go: "登入",
74
+ goPublic: "使用臨時公開連結",
75
+ cancel: "取消",
76
+ close: "關閉",
77
+ starting: "啟動中…",
78
+ deviceStep: "開啟這個頁面並輸入代碼:",
79
+ linkStep: "開啟這個頁面登入;如果它給你一組代碼,請貼到登入終端機:",
80
+ terminalStep: "開啟登入終端機,在那裡輸入這個存取碼。只能用一次,{0} 分鐘內有效。",
81
+ openTerminal: "開啟登入終端機",
82
+ accessCode: "存取碼",
83
+ code: "代碼",
84
+ copy: "複製",
85
+ copied: "已複製",
86
+ publicLink: "公開連結:拿到連結和代碼的人都能使用——兩者都只留給自己。",
87
+ loopback: "這個終端機只在本機位址上接聽。請在那台機器上開啟,或為你的網路設定 web_terminal.bind。",
88
+ waitingHint: "等待登入顯示連結…",
89
+ signedIn: "{0} 已登入。",
90
+ ended: "登入已結束。",
91
+ cancelSignIn: "取消登入",
92
+ cancelled: "已取消登入。",
93
+ running: "正在登入 {0}(從 {1} 啟動)。",
94
+ open: "開啟",
95
+ expired: "等太久了——請重新開始。",
96
+ notHere: "公開連結上不能登入。請在執行 AgEnD 的機器上開啟儀表板,或在聊天裡用 /login。",
97
+ notAdmin: "只有在這台機器或它的網路上登入的 fleet 管理員,才能讓後端登入。",
98
+ notOffered: "這次登入不提供公開連結。",
99
+ failed: "沒有成功。",
100
+ listTitle: "登入",
101
+ listHint: "這台機器上每個 backend 的登入,以及代理使用的每個憑證設定檔。",
102
+ stUsable: "已登入",
103
+ stNotSignedIn: "未登入",
104
+ stNotInstalled: "未安裝",
105
+ stTooOld: "版本太舊",
106
+ stUnknown: "未檢查",
107
+ signIn: "登入",
108
+ signInAgain: "重新登入",
109
+ onHost: "在機器上登入:{0}",
110
+ install: "在機器上安裝:{0}",
111
+ update: "在機器上更新:{0}",
112
+ profile: "{0} · 設定檔 {1}",
113
+ },
114
+ });
115
+ const tn = (k, ...v) => t(`signin.${k}`, ...v);
116
+ const shownOf = (backend, profile) => (profile ? tn("profile", backend, profile) : backend);
117
+
118
+ /** What this page knows: the current sign-in as this session may see it, and Settings' list. */
119
+ export const signInStore = createStore({ loaded: false, login: null, targets: [] });
120
+
121
+ let reading = null, again = false;
122
+ /**
123
+ * Read GET /ui/backend-login (one read at a time). Asked again while one is on its way (an event that came after it was
124
+ * sent): one more read follows it, so the last change is never missed (#2262 review).
125
+ */
126
+ export function refreshSignIn() {
127
+ if (reading) { again = true; return reading; }
128
+ reading = fetch("/ui/backend-login", { cache: "no-store" })
129
+ .then((r) => (r.ok ? r.json() : null))
130
+ .then((b) => { if (b) signInStore.set({ loaded: true, login: b.login || null, targets: Array.isArray(b.targets) ? b.targets : [] }); return b; })
131
+ .catch(() => null)
132
+ .finally(() => { reading = null; if (again) { again = false; void refreshSignIn(); } });
133
+ return reading;
134
+ }
135
+
136
+ let installed = false;
137
+ /** Once per admin page: listen for `backend_login`, read once, and show the line while a sign-in runs. */
138
+ export function installSignIn() {
139
+ if (installed || !signInOffered()) return;
140
+ installed = true;
141
+ const on = appStore.get().streamOn;
142
+ if (typeof on === "function") on("backend_login", () => { void refreshSignIn(); });
143
+ void refreshSignIn();
144
+ shellStore.set({ banner: SignInLine });
145
+ }
146
+
147
+ /** Open the dialog for one login (a backend's default one, or a credential profile). */
148
+ export function openSignIn({ backend, profile }) {
149
+ showDialog(SignInDialog, { backend, ...(profile ? { profile } : {}) });
150
+ }
151
+
152
+ /** Open the dialog on the running sign-in (its starter's view, or the cancel-only one). */
153
+ function openRunning(login) { showDialog(SignInDialog, { backend: login.backend, ...(login.profile ? { profile: login.profile } : {}), resume: true }); }
154
+
155
+ async function post(path, body) {
156
+ try {
157
+ const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
158
+ let json = null; try { json = await res.json(); } catch { /* none */ }
159
+ return { ok: res.ok, status: res.status, body: json };
160
+ } catch { return { ok: false, status: 0, body: null }; }
161
+ }
162
+
163
+ const errorText = (code) => (code === "confirm_expired" ? tn("expired") : code === "tunnel_not_offered" ? tn("notOffered")
164
+ : code === "sign_in_not_over_public_link" ? tn("notHere") : code === "sign_in_needs_a_local_admin" ? tn("notAdmin") : tn("failed"));
165
+ /** The public link's page (#2233: Sign in is refused there — never offered). */
166
+ export const signInOffered = () => !(typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1");
167
+ const loopbackHost = (h) => h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
168
+
169
+ function CopyField({ label, value }) {
170
+ const [copied, setCopied] = useState(false);
171
+ const copy = async () => { try { await navigator.clipboard.writeText(value); setCopied(true); } catch { toast(value); } };
172
+ return html`<div class="signin-field"><span class="signin-label">${label}</span><code class="signin-value">${value}</code>
173
+ <button type="button" class="btn btn-sm" onClick=${copy}>${copied ? tn("copied") : tn("copy")}</button></div>`;
174
+ }
175
+
176
+ /** The starter's view of a running sign-in: the device link and code, or the terminal and its access code. */
177
+ function Running({ login }) {
178
+ const { hint, terminal, accessCode } = login;
179
+ const away = terminal && terminal.loopback && typeof location !== "undefined" && !loopbackHost(location.hostname);
180
+ return html`<div class="signin-run">
181
+ ${hint ? html`<p>${hint.code ? tn("deviceStep") : tn("linkStep")}</p>
182
+ ${hint.linkable ? html`<p><a data-trust="navigation" class="btn btn-primary" href=${hint.url} target="_blank" rel="noopener noreferrer">${tn("open")}</a> <span class="signin-url">${hint.url}</span></p>`
183
+ : html`<${CopyField} label="URL" value=${hint.url} />`}
184
+ ${hint.code ? html`<${CopyField} label=${tn("code")} value=${hint.code} />` : null}` : null}
185
+ ${terminal ? html`<p>${tn("terminalStep", terminal.ttlMinutes)}</p>
186
+ <p><a data-trust="navigation" class="btn btn-primary" href=${terminal.url} target="_blank" rel="noopener noreferrer">${tn("openTerminal")}</a></p>
187
+ ${accessCode ? html`<${CopyField} label=${tn("accessCode")} value=${accessCode} />` : null}
188
+ ${terminal.public ? html`<p class="signin-note">${tn("publicLink")}</p>` : null}
189
+ ${away ? html`<p class="signin-note">${tn("loopback")}</p>` : null}` : null}
190
+ ${!hint && !terminal ? html`<p class="signin-note">${tn("waitingHint")}</p>` : null}
191
+ </div>`;
192
+ }
193
+
194
+ export function SignInDialog({ backend, profile, resume = false, onClose }) {
195
+ const s = useStore(signInStore);
196
+ const [step, setStep] = useState(resume ? { kind: "login" } : { kind: "checking" });
197
+ const [busy, setBusy] = useState(false);
198
+ const shown = shownOf(backend, profile);
199
+ const close = onClose || closeDialog;
200
+ useEffect(() => {
201
+ if (resume) return undefined;
202
+ let gone = false;
203
+ post("/ui/backend-login/check", { backend, ...(profile ? { profile } : {}) }).then((r) => {
204
+ if (gone) return;
205
+ if (r.body && r.body.confirm) setStep({ kind: "confirm", confirm: r.body.confirm });
206
+ else setStep({ kind: "message", text: (r.body && (r.body.message || (r.body.error && errorText(r.body.error)))) || tn("failed") });
207
+ });
208
+ return () => { gone = true; };
209
+ }, []);
210
+ const login = s.login;
211
+ const live = step.kind === "login" && login && login.phase === "running";
212
+ // While a running sign-in is on screen, read it every 2 s too (a page without the stream still sees it move).
213
+ useEffect(() => {
214
+ if (!live) return undefined;
215
+ const h = setInterval(() => { void refreshSignIn(); }, 2000);
216
+ return () => clearInterval(h);
217
+ }, [live]);
218
+ const start = async (tunnel) => {
219
+ setBusy(true); setStep({ kind: "starting" });
220
+ const r = await post("/ui/backend-login/start", { confirm: step.confirm.id, ...(tunnel ? { tunnel: true } : {}) });
221
+ setBusy(false);
222
+ if (r.body && r.body.login) { signInStore.set({ login: r.body.login }); setStep({ kind: "login" }); return; }
223
+ setStep({ kind: "message", text: (r.body && (r.body.message || (r.body.error && errorText(r.body.error)))) || tn("failed") });
224
+ };
225
+ const cancelRun = async () => {
226
+ setBusy(true);
227
+ const r = await post("/ui/backend-login/cancel", {});
228
+ setBusy(false);
229
+ toast((r.body && r.body.message) || tn("cancelled"));
230
+ void refreshSignIn();
231
+ };
232
+ let body, actions;
233
+ if (step.kind === "checking" || step.kind === "starting") {
234
+ body = html`<p role="status">${step.kind === "checking" ? tn("checking", shown) : tn("starting")}</p>`;
235
+ actions = html`<button type="button" class="btn" disabled=${busy} onClick=${close}>${tn("cancel")}</button>`;
236
+ } else if (step.kind === "confirm") {
237
+ const c = step.confirm;
238
+ body = html`<p>${c.deviceAuth ? tn("confirmDevice", shown) : tn("confirmTerminal", shown)}</p>
239
+ ${c.tokenPresent ? html`<p>${tn("stillSignedIn")}</p>` : null}
240
+ ${c.logoutFirst ? html`<p>${tn("logoutFirst", c.logoutFirst)}</p>` : null}
241
+ ${c.publicLink ? html`<p class="signin-note">${tn("publicNote")}</p>` : null}`;
242
+ actions = html`<button type="button" class="btn" onClick=${close}>${tn("cancel")}</button>
243
+ ${c.publicLink ? html`<button type="button" class="btn" onClick=${() => start(true)}>${tn("goPublic")}</button>` : null}
244
+ <button type="button" class="btn btn-primary" onClick=${() => start(false)}>${tn("go")}</button>`;
245
+ } else if (step.kind === "message") {
246
+ body = html`<p class="signin-message">${step.text}</p>`;
247
+ actions = html`<button type="button" class="btn btn-primary" onClick=${close}>${tn("close")}</button>`;
248
+ } else if (!login) {
249
+ body = html`<p class="signin-note">${tn("ended")}</p>`;
250
+ actions = html`<button type="button" class="btn btn-primary" onClick=${close}>${tn("close")}</button>`;
251
+ } else if (!login.mine) {
252
+ body = html`<p>${login.phase === "running" ? tn("running", shownOf(login.backend, login.profile), login.startedOn) : tn("ended")}</p>`;
253
+ actions = html`<button type="button" class="btn" onClick=${close}>${tn("close")}</button>
254
+ ${login.phase === "running" ? html`<button type="button" class="btn btn-danger" disabled=${busy} onClick=${cancelRun}>${tn("cancelSignIn")}</button>` : null}`;
255
+ } else {
256
+ const lines = Array.isArray(login.lines) ? login.lines : [];
257
+ body = html`${login.phase === "running" ? html`<${Running} login=${login} />`
258
+ : html`<p role="status" class=${login.ok ? "signin-ok" : "signin-message"}>${login.ok ? tn("signedIn", shownOf(login.backend, login.profile)) : tn("ended")}</p>`}
259
+ ${lines.length ? html`<ul class="signin-lines">${lines.map((l, i) => html`<li key=${i}>${l}</li>`)}</ul>` : null}`;
260
+ actions = html`<button type="button" class="btn" onClick=${close}>${tn("close")}</button>
261
+ ${login.phase === "running" ? html`<button type="button" class="btn btn-danger" disabled=${busy} onClick=${cancelRun}>${tn("cancelSignIn")}</button>` : null}`;
262
+ }
263
+ return html`<${Dialog} title=${tn("title", shown)} onClose=${close} busy=${busy} actions=${actions}>${body}</${Dialog}>`;
264
+ }
265
+
266
+ /** The line every admin page shows while a sign-in runs (the shell's banner slot). */
267
+ export function SignInLine() {
268
+ const { login } = useStore(signInStore);
269
+ if (!login || login.phase !== "running") return null;
270
+ return html`<div data-trust="status" class="conn op-line signin-line" role="status">
271
+ <span>${tn("running", shownOf(login.backend, login.profile), login.startedOn)}</span>
272
+ <button type="button" class="btn btn-sm" onClick=${() => openRunning(login)}>${tn("open")}</button></div>`;
273
+ }
274
+
275
+ const STATE_KEY = { usable: "stUsable", not_signed_in: "stNotSignedIn", not_installed: "stNotInstalled", too_old: "stTooOld", unknown: "stUnknown" };
276
+
277
+ /** Settings → Agents: each login on this machine, with Sign in where the dashboard can do it. */
278
+ export function SignInList() {
279
+ const { targets, loaded, login } = useStore(signInStore);
280
+ useEffect(() => { void refreshSignIn(); }, []);
281
+ if (!loaded || !targets.length) return null;
282
+ const running = login && login.phase === "running";
283
+ return html`<section class="s-group signin-list" aria-label=${tn("listTitle")}>
284
+ <div class="list-head"><span>${tn("listTitle")}</span></div>
285
+ <p class="sub">${tn("listHint")}</p>
286
+ ${targets.map((x) => html`<div key=${`${x.backend}:${x.profile || ""}`} class="s-row">
287
+ <span class="s-name">${shownOf(x.backend, x.profile)}</span>
288
+ <span class="tag">${tn(STATE_KEY[x.state] || "stUnknown")}</span>
289
+ <span class="s-br" aria-hidden="true"></span>
290
+ ${x.install ? html`<span class="s-meta"><code>${tn("install", x.install)}</code></span>` : null}
291
+ ${x.update ? html`<span class="s-meta"><code>${tn("update", x.update)}</code></span>` : null}
292
+ ${x.hostCommand ? html`<span class="s-meta"><code>${tn("onHost", x.hostCommand)}</code></span>` : null}
293
+ <span class="s-actions">${x.signIn ? html`<button type="button" class=${x.state === "not_signed_in" ? "btn btn-sm btn-primary" : "btn btn-sm"} disabled=${!!running}
294
+ onClick=${() => openSignIn(x)}>${x.state === "usable" ? tn("signInAgain") : tn("signIn")}</button>` : null}</span>
295
+ </div>`)}
296
+ </section>`;
297
+ }