@songsid/agend 2.0.11-beta.3 → 2.0.11-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/channel/adapters/discord.d.ts +2 -0
  2. package/dist/channel/adapters/discord.js +39 -34
  3. package/dist/channel/adapters/discord.js.map +1 -1
  4. package/dist/channel/factory.d.ts +4 -0
  5. package/dist/channel/factory.js +1 -0
  6. package/dist/channel/factory.js.map +1 -1
  7. package/dist/channel/mcp-tools.js +3 -0
  8. package/dist/channel/mcp-tools.js.map +1 -1
  9. package/dist/channel/tool-router.js +29 -2
  10. package/dist/channel/tool-router.js.map +1 -1
  11. package/dist/classic-channel-manager.d.ts +59 -12
  12. package/dist/classic-channel-manager.js +121 -30
  13. package/dist/classic-channel-manager.js.map +1 -1
  14. package/dist/cli.js +94 -7
  15. package/dist/cli.js.map +1 -1
  16. package/dist/config-validator.d.ts +20 -0
  17. package/dist/config-validator.js +154 -0
  18. package/dist/config-validator.js.map +1 -0
  19. package/dist/fleet-manager.d.ts +27 -5
  20. package/dist/fleet-manager.js +360 -168
  21. package/dist/fleet-manager.js.map +1 -1
  22. package/dist/instance-lifecycle.d.ts +1 -1
  23. package/dist/instance-lifecycle.js +7 -6
  24. package/dist/instance-lifecycle.js.map +1 -1
  25. package/dist/outbound-schemas.d.ts +1 -0
  26. package/dist/outbound-schemas.js +1 -0
  27. package/dist/outbound-schemas.js.map +1 -1
  28. package/dist/quickstart.js +245 -16
  29. package/dist/quickstart.js.map +1 -1
  30. package/dist/service-installer.d.ts +1 -0
  31. package/dist/service-installer.js +2 -1
  32. package/dist/service-installer.js.map +1 -1
  33. package/dist/settings-api.d.ts +33 -0
  34. package/dist/settings-api.js +283 -0
  35. package/dist/settings-api.js.map +1 -0
  36. package/dist/topic-commands.d.ts +14 -0
  37. package/dist/topic-commands.js +63 -2
  38. package/dist/topic-commands.js.map +1 -1
  39. package/dist/ui/settings.html +457 -0
  40. package/dist/ui/view.html +477 -0
  41. package/dist/view-api.d.ts +29 -0
  42. package/dist/view-api.js +370 -0
  43. package/dist/view-api.js.map +1 -0
  44. package/package.json +1 -1
  45. package/templates/systemd.service.ejs +2 -1
@@ -0,0 +1,457 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>AgEnD Settings</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ :root {
10
+ --bg:#ffffff; --card:#f9fafb; --border:#e5e7eb; --fg:#111827; --muted:#6b7280;
11
+ --accent:#2563eb; --accent-hover:#1d4ed8; --green:#059669; --red:#dc2626;
12
+ --radius:8px; --radius-sm:6px;
13
+ }
14
+ body { background: var(--bg); color: var(--fg); font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.5; padding-bottom: 60px; }
15
+ header { display: flex; align-items: center; gap: 12px; padding: 16px 24px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 20; }
16
+ header h1 { font-size: 18px; font-weight: 700; }
17
+ .grow { flex: 1; }
18
+ main { max-width: 860px; margin: 0 auto; padding: 24px; }
19
+ section { margin-bottom: 28px; }
20
+ .sec-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
21
+ .sec-head h2 { font-size: 16px; font-weight: 600; }
22
+ .sec-head .grow { flex: 1; }
23
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
24
+ .card.empty { padding: 16px; color: var(--muted); }
25
+ .item { padding: 9px 16px; border-bottom: 1px solid var(--border); }
26
+ .item:last-child { border-bottom: none; }
27
+ .item-row { display: flex; align-items: center; gap: 10px; min-height: 24px; }
28
+ .group-head { display: flex; align-items: center; gap: 8px; padding: 7px 16px; cursor: pointer; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .3px; background: #f3f4f6; border-bottom: 1px solid var(--border); user-select: none; }
29
+ .group-head:hover { color: var(--fg); }
30
+ .group-head .caret { width: 12px; flex: 0 0 12px; font-size: 10px; }
31
+ .group-head .gcount { color: var(--muted); font-weight: 400; }
32
+ .item .del-btn { visibility: hidden; }
33
+ .item:hover .del-btn { visibility: visible; }
34
+ .dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; background: #9ca3af; }
35
+ .dot.running { background: var(--green); } .dot.crashed { background: var(--red); } .dot.stopped { background: #9ca3af; }
36
+ .name { font-weight: 600; }
37
+ .tag { font-size: 12px; color: var(--muted); background: #eef2ff; color: #3730a3; padding: 1px 7px; border-radius: 10px; }
38
+ .tag.persona { background: #fdf2f8; color: #9d174d; }
39
+ .tag.tg { background: #e0f2fe; color: #075985; }
40
+ .sub { font-size: 13px; color: var(--muted); margin-top: 3px; margin-left: 19px; }
41
+ .spacer { flex: 1; }
42
+ button { font-family: inherit; font-size: 13px; border-radius: var(--radius-sm); padding: 6px 12px; border: 1px solid var(--border); background: #fff; color: var(--fg); cursor: pointer; }
43
+ button:hover { background: #f3f4f6; }
44
+ button.primary { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
45
+ button.primary:hover { background: var(--accent-hover); }
46
+ button.ghost { border-color: transparent; background: transparent; color: var(--muted); }
47
+ button.ghost:hover { background: #f3f4f6; color: var(--fg); }
48
+ button.danger { color: var(--red); }
49
+ button.danger:hover { background: #fef2f2; }
50
+ button.sm { padding: 4px 9px; font-size: 12px; }
51
+ label { display: block; font-size: 12px; font-weight: 500; color: var(--muted); margin: 10px 0 4px; }
52
+ input[type=text], select { width: 100%; font-family: inherit; font-size: 13px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; color: var(--fg); }
53
+ input:focus, select:focus { outline: 2px solid #bfdbfe; border-color: var(--accent); }
54
+ .edit { padding: 14px 16px; background: #fff; border-top: 1px solid var(--border); }
55
+ .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
56
+ .actions { display: flex; gap: 8px; margin-top: 12px; align-items: center; flex-wrap: wrap; }
57
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
58
+ .chip { display: inline-flex; align-items: center; gap: 6px; background: #fff; border: 1px solid var(--border); border-radius: 14px; padding: 3px 6px 3px 10px; font-size: 12px; }
59
+ .chip .x { cursor: pointer; color: var(--muted); font-weight: 700; } .chip .x:hover { color: var(--red); }
60
+ .chip-add { display: flex; gap: 6px; margin-top: 6px; max-width: 320px; }
61
+ .field { padding: 12px 16px; border-bottom: 1px solid var(--border); }
62
+ .field:last-child { border-bottom: none; }
63
+ .msg { font-size: 12px; margin-left: 4px; }
64
+ .msg .e { color: var(--red); } .msg .w { color: #b45309; } .msg .s { color: var(--green); }
65
+ .hint { font-size: 12px; color: var(--muted); margin-top: 6px; }
66
+ .adv-body { display: none; padding: 14px 16px; }
67
+ .adv-body.open { display: block; }
68
+ .toolbar { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
69
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
70
+ .seg button { border: none; border-radius: 0; }
71
+ .seg button.on { background: var(--accent); color: #fff; }
72
+ textarea { width: 100%; min-height: 380px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.5; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; color: var(--fg); background: #fff; resize: vertical; }
73
+ pre.preview { background: #f9fafb; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.5; overflow: auto; max-height: 480px; white-space: pre; min-height: 380px; }
74
+ .banner { display: none; padding: 10px 24px; font-size: 13px; background: #ecfdf5; color: var(--green); border-bottom: 1px solid var(--border); }
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <header>
79
+ <h1>⚙️ AgEnD Settings</h1>
80
+ <div class="grow"></div>
81
+ <button id="reloadBtn" class="primary" title="Apply saved config to running instances (SIGHUP)">↻ Reload Fleet</button>
82
+ </header>
83
+ <div class="banner" id="banner"></div>
84
+ <main>
85
+ <section>
86
+ <div class="sec-head"><h2>🤖 My Agents</h2><div class="grow"></div><input type="text" id="agentSearch" placeholder="🔍 Search…" style="width:180px;margin-right:8px"><button class="sm" id="newAgentBtn">+ New</button></div>
87
+ <div id="newAgent"></div>
88
+ <div class="card" id="agents"></div>
89
+ </section>
90
+
91
+ <section>
92
+ <div class="sec-head"><h2>🔌 Bots</h2><div class="grow"></div><button class="sm" id="newBotBtn">+ Add</button></div>
93
+ <div id="newBot"></div>
94
+ <div class="card" id="bots"></div>
95
+ </section>
96
+
97
+ <section>
98
+ <div class="sec-head"><h2>⚙️ General Settings</h2></div>
99
+ <div class="card" id="general"></div>
100
+ </section>
101
+
102
+ <section>
103
+ <div class="sec-head"><h2>📋 Advanced</h2><div class="grow"></div><button class="sm" id="advToggle">▶ Expand</button></div>
104
+ <div class="card"><div class="adv-body" id="advBody"></div></div>
105
+ </section>
106
+ </main>
107
+ <script>
108
+ (() => {
109
+ "use strict";
110
+ const token = new URLSearchParams(location.search).get("token") || "";
111
+ const $ = (id) => document.getElementById(id);
112
+ const el = (tag, attrs = {}, ...kids) => { const e = document.createElement(tag); for (const k in attrs) { if (k === "class") e.className = attrs[k]; else if (k === "html") e.innerHTML = attrs[k]; else if (k.startsWith("on")) e.addEventListener(k.slice(2), attrs[k]); else e.setAttribute(k, attrs[k]); } for (const c of kids) if (c != null) e.append(c); return e; };
113
+ const esc = (s) => String(s).replace(/[&<>]/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;" }[c]));
114
+ const BACKENDS = ["claude-code", "kiro-cli", "codex", "opencode", "gemini-cli", "antigravity"];
115
+ const CH_TYPES = ["discord", "telegram"];
116
+
117
+ // ── YAML (config subset; round-trip verified) ──
118
+ function toYaml(value, indent = 0) {
119
+ const pad = " ".repeat(indent);
120
+ const scalar = (v) => { if (v === null || v === undefined) return "null"; if (typeof v === "boolean" || typeof v === "number") return String(v); const s = String(v); if (s === "") return '""'; if (/^(true|false|null|~|-?\d+(\.\d+)?)$/i.test(s) || /[:#\[\]{}&*!|>'"%@`]/.test(s) || /^[\s-]/.test(s) || /\s$/.test(s) || s.includes("\n")) return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; return s; };
121
+ if (Array.isArray(value)) { if (!value.length) return "[]"; return value.map(item => { if (item !== null && typeof item === "object") { const d = toYaml(item, indent + 1); const lines = d.split("\n"); return pad + "- " + lines[0].slice((indent + 1) * 2) + (lines.length > 1 ? "\n" + lines.slice(1).join("\n") : ""); } return pad + "- " + scalar(item); }).join("\n"); }
122
+ if (value !== null && typeof value === "object") { const keys = Object.keys(value); if (!keys.length) return "{}"; return keys.map(k => { const v = value[k]; if (v !== null && typeof v === "object" && (Array.isArray(v) ? v.length : Object.keys(v).length)) return pad + k + ":\n" + toYaml(v, indent + 1); if (v !== null && typeof v === "object") return pad + k + ": " + (Array.isArray(v) ? "[]" : "{}"); return pad + k + ": " + scalar(v); }).join("\n"); }
123
+ return pad + scalar(value);
124
+ }
125
+ function parseScalar(s) { s = s.trim(); if (s === "" || s === "~" || s === "null") return null; if (s === "true") return true; if (s === "false") return false; if (/^-?\d+$/.test(s)) return parseInt(s, 10); if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s); if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\"); if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1); if (s === "[]") return []; if (s === "{}") return {}; return s; }
126
+ function fromYaml(text) {
127
+ const lines = [];
128
+ for (const raw of text.replace(/\r/g, "").split("\n")) { let inS = false, inD = false, out = ""; for (let i = 0; i < raw.length; i++) { const c = raw[i]; if (c === "'" && !inD) inS = !inS; else if (c === '"' && !inS) inD = !inD; else if (c === "#" && !inS && !inD) break; out += c; } if (out.trim() === "") continue; lines.push({ indent: out.match(/^ */)[0].length, text: out.trim() }); }
129
+ let pos = 0;
130
+ function block() { if (pos >= lines.length) return null; const first = lines[pos]; if (first.text.startsWith("- ")) { const arr = []; while (pos < lines.length && lines[pos].indent === first.indent && lines[pos].text.startsWith("- ")) { const at = lines[pos].indent, rest = lines[pos].text.slice(2); if (rest.includes(":") && !/^".*"$/.test(rest) && !rest.startsWith("[") && !rest.startsWith("{")) { lines[pos] = { indent: at + 2, text: rest }; arr.push(map(at + 2)); } else { arr.push(parseScalar(rest)); pos++; } } return arr; } return map(first.indent); }
131
+ function map(indent) { const obj = {}; while (pos < lines.length && lines[pos].indent === indent) { const line = lines[pos].text, ci = line.indexOf(":"); if (ci < 0) { pos++; continue; } const key = line.slice(0, ci).trim(), val = line.slice(ci + 1).trim(); if (val === "") { pos++; if (pos < lines.length && lines[pos].indent > indent) obj[key] = block(); else obj[key] = null; } else { obj[key] = parseScalar(val); pos++; } } return obj; }
132
+ return block();
133
+ }
134
+
135
+ // ── API ──
136
+ async function api(path, opts = {}) {
137
+ const headers = Object.assign({ "X-Agend-Token": token }, opts.headers || {});
138
+ if (opts.body) headers["Content-Type"] = "application/json";
139
+ const r = await fetch(path, Object.assign({}, opts, { headers }));
140
+ let body = null; try { body = await r.json(); } catch {}
141
+ return { ok: r.ok, status: r.status, body };
142
+ }
143
+ function resultMsg(elm, res) {
144
+ const b = res.body || {}; let html = "";
145
+ if (b.errors && b.errors.length) html += b.errors.map(e => `<span class="e">✗ ${esc(e.path ? e.path + ": " : "")}${esc(e.message)}</span>`).join(" ");
146
+ if (b.warnings && b.warnings.length) html += " " + b.warnings.map(w => `<span class="w">⚠ ${esc(w.message)}</span>`).join(" ");
147
+ if (res.ok) html = `<span class="s">✓ Saved</span>` + (html ? " " + html : "");
148
+ else if (!html) html = `<span class="e">✗ ${esc((b && b.error) || ("HTTP " + res.status))}</span>`;
149
+ elm.innerHTML = html;
150
+ }
151
+
152
+ // ── State ──
153
+ const state = { fleet: {}, classic: {}, live: {}, fleetUp: false, advFmt: "yaml", advEdit: false, collapsedGroups: new Set() };
154
+ const channels = () => state.fleet.channels || (state.fleet.channel ? [state.fleet.channel] : []);
155
+ const channelIds = () => channels().map(c => c.id || c.type).filter(Boolean);
156
+ const chLabel = (i) => i === 0 ? "primary" : "persona";
157
+ function chById(id) { return channels().find(c => (c.id || c.type) === id); }
158
+
159
+ function chipList(arr, onChange, placeholder) {
160
+ const box = el("div"); const chips = el("div", { class: "chips" });
161
+ const redraw = () => { chips.innerHTML = ""; arr.forEach((v, i) => chips.append(el("span", { class: "chip" }, String(v), el("span", { class: "x", onclick: () => { arr.splice(i, 1); redraw(); onChange && onChange(); } }, "×")))); };
162
+ const inp = el("input", { type: "text", placeholder: placeholder || "add…" });
163
+ const add = () => { const v = inp.value.trim(); if (v) { arr.push(/^-?\d+$/.test(v) ? Number(v) : v); inp.value = ""; redraw(); onChange && onChange(); } };
164
+ inp.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); add(); } });
165
+ box.append(chips, el("div", { class: "chip-add" }, inp, el("button", { class: "sm", onclick: add }, "+ Add")));
166
+ redraw(); return box;
167
+ }
168
+ function select(value, options) { const s = el("select"); for (const o of options) { const opt = el("option", {}, o); opt.value = o; if (o === value) opt.selected = true; s.append(opt); } return s; }
169
+
170
+ // ── My Agents ──
171
+ let editing = null;
172
+ function agentChannelLabel(inst) {
173
+ if (inst.general_topic) return "general";
174
+ if (!inst.channel_id) return "";
175
+ const idx = channels().findIndex(c => (c.id || c.type) === inst.channel_id);
176
+ const ch = channels()[idx];
177
+ return ch ? `${ch.type} (${chLabel(idx)})` : inst.channel_id;
178
+ }
179
+ // Display name: profile display_name → else strip the -t<topicId> suffix.
180
+ const shortName = (name, inst) => (inst && inst.display_name) || name.replace(/-t\d+$/, "");
181
+ const STATUS_TEXT = { running: "🟢 Running", crashed: "🔴 Crashed", stopped: "⚪ Stopped" };
182
+ const STATUS_ORDER = { running: 0, crashed: 1, stopped: 2 };
183
+ const groupOf = (inst) => (inst.tags && inst.tags[0]) || "Other";
184
+ function compactRow(name, inst) {
185
+ const st = state.live[name]?.status || "stopped";
186
+ const running = st === "running";
187
+ const short = shortName(name, inst);
188
+ const chTag = agentChannelLabel(inst);
189
+ const item = el("div", { class: "item" });
190
+ // Single fixed-height line: dot + short name + backend + channel tag + actions.
191
+ // Full ID lives in the tooltip and the Edit form; working dir only in Edit.
192
+ item.append(el("div", { class: "item-row" },
193
+ el("span", { class: "dot " + st, title: STATUS_TEXT[st] || st }),
194
+ el("span", { class: "name", title: name }, short),
195
+ el("span", { class: "sub", style: "margin:0" }, inst.backend || state.fleet.defaults?.backend || "claude-code"),
196
+ chTag ? el("span", { class: "tag" + (chTag.includes("persona") ? " persona" : chTag.includes("telegram") ? " tg" : "") }, chTag) : null,
197
+ el("span", { class: "spacer" }),
198
+ el("button", { class: "sm ghost", onclick: () => { editing = editing === name ? null : name; renderAgents(); } }, editing === name ? "Close" : "Edit"),
199
+ el("button", { class: "sm", onclick: () => toggleAgent(name, running) }, running ? "Stop" : "Start"),
200
+ el("button", { class: "sm danger del-btn", title: "Delete", onclick: () => delAgent(name) }, "🗑️"),
201
+ ));
202
+ if (editing === name) item.append(agentEditForm(name, inst));
203
+ return item;
204
+ }
205
+ function renderAgents() {
206
+ const host = $("agents"); host.innerHTML = "";
207
+ const insts = state.fleet.instances || {};
208
+ if (!Object.keys(insts).length) { host.className = "card empty"; host.textContent = "No agents yet — click + New."; return; }
209
+ const filter = ($("agentSearch")?.value || "").trim().toLowerCase();
210
+ let names = Object.keys(insts);
211
+ if (filter) names = names.filter(n => n.toLowerCase().includes(filter) || shortName(n, insts[n]).toLowerCase().includes(filter));
212
+ if (!names.length) { host.className = "card empty"; host.textContent = "No agents match \"" + filter + "\"."; return; }
213
+ host.className = "card";
214
+ // Group by first tag; "Other" sorts last. Within a group: Running → Crashed
215
+ // → Stopped, then name.
216
+ const groups = {};
217
+ for (const n of names) (groups[groupOf(insts[n])] ??= []).push(n);
218
+ const gnames = Object.keys(groups).sort((a, b) => a === "Other" ? 1 : b === "Other" ? -1 : a.localeCompare(b));
219
+ for (const g of gnames) {
220
+ const members = groups[g].sort((a, b) => {
221
+ const sa = STATUS_ORDER[state.live[a]?.status || "stopped"], sb = STATUS_ORDER[state.live[b]?.status || "stopped"];
222
+ return sa !== sb ? sa - sb : a.localeCompare(b);
223
+ });
224
+ const collapsed = state.collapsedGroups.has(g) && !filter; // searching expands all
225
+ host.append(el("div", { class: "group-head", onclick: () => { state.collapsedGroups.has(g) ? state.collapsedGroups.delete(g) : state.collapsedGroups.add(g); renderAgents(); } },
226
+ el("span", { class: "caret" }, collapsed ? "▶" : "▼"), el("span", {}, g), el("span", { class: "gcount" }, `(${members.length})`)));
227
+ if (collapsed) continue;
228
+ for (const name of members) host.append(compactRow(name, insts[name]));
229
+ }
230
+ }
231
+ function agentEditForm(name, inst) {
232
+ const box = el("div", { class: "edit" });
233
+ const fBackend = select(inst.backend || state.fleet.defaults?.backend || "claude-code", BACKENDS);
234
+ const fModel = el("input", { type: "text", value: inst.model || "", placeholder: "(inherit default)" });
235
+ const fWd = el("input", { type: "text", value: inst.working_directory || "" });
236
+ const fChan = select(inst.channel_id || "", ["", ...channelIds()]);
237
+ const fDesc = el("input", { type: "text", value: inst.description || "" });
238
+ const fSys = el("input", { type: "text", value: inst.systemPrompt || "", placeholder: "e.g. file:prompts/dev.md" });
239
+ const tags = [...(inst.tags || [])];
240
+ const fGeneral = el("input", { type: "checkbox" }); fGeneral.checked = !!inst.general_topic;
241
+ const msg = el("span", { class: "msg" });
242
+ box.append(
243
+ el("div", { class: "sub", style: "margin:0 0 8px" }, "ID: " + name),
244
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, "Backend"), fBackend), el("div", {}, el("label", {}, "Model"), fModel)),
245
+ el("label", {}, "Working directory"), fWd,
246
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, "Channel binding"), fChan), el("div", {}, el("label", {}, "Description"), fDesc)),
247
+ el("label", {}, "System prompt"), fSys,
248
+ el("label", {}, "Tags"), chipList(tags, null, "tag"),
249
+ el("label", { style: "display:flex;align-items:center;gap:8px;cursor:pointer" }, fGeneral, el("span", {}, "General topic (fleet dispatcher)")),
250
+ el("div", { class: "actions" },
251
+ el("button", { class: "primary sm", onclick: async () => {
252
+ const patch = {
253
+ backend: fBackend.value, model: fModel.value || undefined,
254
+ working_directory: fWd.value, channel_id: fChan.value || undefined,
255
+ description: fDesc.value || undefined, systemPrompt: fSys.value || undefined,
256
+ tags: tags.length ? tags : undefined, general_topic: fGeneral.checked,
257
+ };
258
+ const res = await api(`/api/settings/fleet/instances/${encodeURIComponent(name)}`, { method: "PATCH", body: JSON.stringify(patch) });
259
+ resultMsg(msg, res); if (res.ok) await reload();
260
+ } }, "Save"),
261
+ el("button", { class: "sm ghost", onclick: () => { editing = null; renderAgents(); } }, "Cancel"),
262
+ msg),
263
+ );
264
+ return box;
265
+ }
266
+ async function toggleAgent(name, running) {
267
+ if (running) await api(`/stop/${encodeURIComponent(name)}`, { method: "POST" });
268
+ else await api(`/api/instance/${encodeURIComponent(name)}/start`, { method: "POST" });
269
+ setTimeout(reloadLive, 800);
270
+ }
271
+ async function delAgent(name) {
272
+ if (!confirm(`Delete agent "${name}"? (config removed; running daemon stops on next Reload)`)) return;
273
+ const res = await api(`/api/settings/fleet/instances/${encodeURIComponent(name)}`, { method: "DELETE" });
274
+ if (res.ok) await reload(); else alert((res.body && res.body.error) || "Delete failed");
275
+ }
276
+ function renderNewAgent(open) {
277
+ const host = $("newAgent"); host.innerHTML = "";
278
+ if (!open) return;
279
+ const box = el("div", { class: "card", style: "margin-bottom:12px" });
280
+ const fName = el("input", { type: "text", placeholder: "agent name" });
281
+ const fBackend = select(state.fleet.defaults?.backend || "claude-code", BACKENDS);
282
+ const fWd = el("input", { type: "text", placeholder: "~/Projects/… (optional)" });
283
+ const fChan = select("", ["", ...channelIds()]);
284
+ const msg = el("span", { class: "msg" });
285
+ box.append(el("div", { class: "edit", style: "border:none" },
286
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, "Name"), fName), el("div", {}, el("label", {}, "Backend"), fBackend)),
287
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, "Working directory"), fWd), el("div", {}, el("label", {}, "Channel binding"), fChan)),
288
+ el("div", { class: "actions" }, el("button", { class: "primary sm", onclick: async () => {
289
+ const name = fName.value.trim(); if (!name) { msg.innerHTML = `<span class="e">✗ name required</span>`; return; }
290
+ const body = { working_directory: fWd.value, backend: fBackend.value, channel_id: fChan.value || undefined };
291
+ const res = await api(`/api/settings/fleet/instances/${encodeURIComponent(name)}`, { method: "POST", body: JSON.stringify(body) });
292
+ resultMsg(msg, res); if (res.ok) { $("newAgentBtn").textContent = "+ New"; renderNewAgent(false); await reload(); }
293
+ } }, "Create"), el("button", { class: "sm ghost", onclick: () => { $("newAgentBtn").textContent = "+ New"; renderNewAgent(false); } }, "Cancel"), msg),
294
+ ));
295
+ host.append(box);
296
+ }
297
+
298
+ // ── Bots ──
299
+ function renderBots() {
300
+ const host = $("bots"); host.innerHTML = ""; const chs = channels();
301
+ if (!chs.length) { host.className = "card empty"; host.textContent = "No bots configured."; return; }
302
+ host.className = "card";
303
+ chs.forEach((ch, i) => {
304
+ const item = el("div", { class: "item" });
305
+ const type = ch.type || "?";
306
+ item.append(el("div", { class: "item-row" },
307
+ el("span", { class: "name" }, type === "telegram" ? "Telegram" : "Discord"),
308
+ el("span", { class: "tag" + (i === 0 ? "" : " persona") + (type === "telegram" ? " tg" : "") }, chLabel(i)),
309
+ el("span", { class: "sub", style: "margin:0" }, ch.bot_token_env || "(no token env)"),
310
+ el("span", { class: "spacer" }),
311
+ el("span", { style: "color:var(--green);font-size:13px" }, state.fleetUp ? "✅ Online" : "—"),
312
+ el("button", { class: "sm danger", onclick: () => delBot(i) }, "🗑️"),
313
+ ));
314
+ host.append(item);
315
+ });
316
+ }
317
+ async function saveChannels(msgEl) {
318
+ const res = await api("/api/settings/fleet/channels", { method: "PUT", body: JSON.stringify(channels()) });
319
+ if (msgEl) resultMsg(msgEl, res);
320
+ return res;
321
+ }
322
+ async function delBot(i) {
323
+ const chs = channels(); const ch = chs[i];
324
+ if (!confirm(`Remove bot "${ch.type} (${chLabel(i)})"? (the token in .env is left untouched)`)) return;
325
+ chs.splice(i, 1);
326
+ state.fleet.channels = chs; delete state.fleet.channel;
327
+ const res = await saveChannels(); if (res.ok) await reload(); else alert((res.body && res.body.error) || "Failed");
328
+ }
329
+ function renderNewBot(open) {
330
+ const host = $("newBot"); host.innerHTML = ""; if (!open) return;
331
+ const box = el("div", { class: "card", style: "margin-bottom:12px" });
332
+ const fType = select("discord", CH_TYPES);
333
+ const fId = el("input", { type: "text", placeholder: "adapter id (e.g. persona1)" });
334
+ const fEnv = el("input", { type: "text", placeholder: "BOT_TOKEN_ENV" });
335
+ const fGroup = el("input", { type: "text", placeholder: "guild / supergroup id" });
336
+ const msg = el("span", { class: "msg" });
337
+ box.append(el("div", { class: "edit", style: "border:none" },
338
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, "Type"), fType), el("div", {}, el("label", {}, "Adapter id"), fId)),
339
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, "Bot token env var"), fEnv), el("div", {}, el("label", {}, "Group / guild id"), fGroup)),
340
+ el("div", { class: "hint" }, "Put the actual token in ~/.agend/.env under this env var, then Reload Fleet."),
341
+ el("div", { class: "actions" }, el("button", { class: "primary sm", onclick: async () => {
342
+ if (!fEnv.value.trim()) { msg.innerHTML = `<span class="e">✗ token env required</span>`; return; }
343
+ const chs = channels();
344
+ chs.push({ id: fId.value.trim() || undefined, type: fType.value, bot_token_env: fEnv.value.trim(), group_id: fGroup.value.trim() || undefined, access: { mode: "locked", allowed_users: [] } });
345
+ state.fleet.channels = chs; delete state.fleet.channel;
346
+ const res = await saveChannels(msg); if (res.ok) { $("newBotBtn").textContent = "+ Add"; renderNewBot(false); await reload(); }
347
+ } }, "Save"), el("button", { class: "sm ghost", onclick: () => { $("newBotBtn").textContent = "+ Add"; renderNewBot(false); } }, "Cancel"), msg),
348
+ ));
349
+ host.append(box);
350
+ }
351
+
352
+ // ── General Settings ──
353
+ function renderGeneral() {
354
+ const host = $("general"); host.innerHTML = "";
355
+ state.fleet.defaults = state.fleet.defaults || {};
356
+ state.classic.defaults = state.classic.defaults || {};
357
+ const d = state.fleet.defaults, c = state.classic.defaults;
358
+ const primary = channels()[0];
359
+ if (primary) primary.access = primary.access || { mode: "locked", allowed_users: [] };
360
+ const allowedUsers = primary ? (primary.access.allowed_users = primary.access.allowed_users || []) : [];
361
+ c.admin_users = c.admin_users || []; c.allowed_guilds = c.allowed_guilds || [];
362
+
363
+ const fBackend = select(d.backend || "claude-code", BACKENDS);
364
+ const fModel = el("input", { type: "text", value: d.model || "", placeholder: "e.g. claude-opus-4-8" });
365
+ const msg = el("span", { class: "msg" });
366
+ host.append(
367
+ el("div", { class: "field" }, el("label", {}, "Default backend"), fBackend),
368
+ el("div", { class: "field" }, el("label", {}, "Default model"), fModel),
369
+ el("div", { class: "field" }, el("label", {}, "Allowed users (primary bot)"), chipList(allowedUsers, null, "user id")),
370
+ el("div", { class: "field" }, el("label", {}, "ClassicBot admins"), chipList(c.admin_users, null, "user id")),
371
+ el("div", { class: "field" }, el("label", {}, "Allowed guilds"), chipList(c.allowed_guilds, null, "guild id")),
372
+ el("div", { class: "field" }, el("div", { class: "actions" }, el("button", { class: "primary", onclick: async () => {
373
+ d.backend = fBackend.value; d.model = fModel.value || undefined;
374
+ const r1 = await api("/api/settings/fleet/defaults", { method: "PUT", body: JSON.stringify({ backend: d.backend, model: d.model }) });
375
+ let r2 = { ok: true, body: {} };
376
+ if (primary) r2 = await saveChannels();
377
+ const r3 = await api("/api/settings/classic/defaults", { method: "PUT", body: JSON.stringify({ backend: c.backend, admin_users: c.admin_users, allowed_guilds: c.allowed_guilds }) });
378
+ const bad = [r1, r2, r3].find(r => !r.ok);
379
+ resultMsg(msg, bad || { ok: true, body: { warnings: [...(r1.body?.warnings || []), ...(r3.body?.warnings || [])] } });
380
+ } }, "Save"), msg)),
381
+ );
382
+ }
383
+
384
+ // ── Advanced (YAML/JSON) ──
385
+ const fleetModel = () => ({ ...(state.fleet.channels ? { channels: state.fleet.channels } : state.fleet.channel ? { channel: state.fleet.channel } : {}), defaults: state.fleet.defaults || {}, instances: state.fleet.instances || {} });
386
+ function renderAdvanced() {
387
+ const host = $("advBody"); host.innerHTML = "";
388
+ const fmt = state.advFmt; const model = fleetModel();
389
+ const ser = fmt === "yaml" ? toYaml(model) : JSON.stringify(model, null, 2);
390
+ const msg = el("div", { class: "msg", style: "margin-top:8px" });
391
+ const toolbar = el("div", { class: "toolbar" },
392
+ el("div", { class: "seg" },
393
+ el("button", { class: "sm" + (fmt === "yaml" ? " on" : ""), onclick: () => { state.advFmt = "yaml"; renderAdvanced(); } }, "YAML"),
394
+ el("button", { class: "sm" + (fmt === "json" ? " on" : ""), onclick: () => { state.advFmt = "json"; renderAdvanced(); } }, "JSON")),
395
+ el("button", { class: "sm", onclick: () => { state.advEdit = !state.advEdit; renderAdvanced(); } }, state.advEdit ? "View" : "Edit"),
396
+ el("button", { class: "sm", onclick: () => navigator.clipboard.writeText(ser).then(() => { msg.innerHTML = `<span class="s">Copied</span>`; }) }, "Copy"),
397
+ el("button", { class: "sm", onclick: () => { const blob = new Blob([toYaml(fleetModel())], { type: "text/plain" }); const a = el("a", { href: URL.createObjectURL(blob), download: "fleet.yaml" }); a.click(); URL.revokeObjectURL(a.href); } }, "⬇ fleet.yaml"),
398
+ );
399
+ host.append(toolbar);
400
+ if (state.advEdit) {
401
+ const ta = el("textarea"); ta.value = ser;
402
+ host.append(ta, el("div", { class: "actions" }, el("button", { class: "primary sm", onclick: async () => {
403
+ let parsed; try { parsed = fmt === "yaml" ? fromYaml(ta.value) : JSON.parse(ta.value || "{}"); } catch (e) { msg.innerHTML = `<span class="e">✗ ${esc(e.message)}</span>`; return; }
404
+ await applyFullModel(parsed, msg);
405
+ } }, "Apply & Save"), msg));
406
+ } else {
407
+ const pre = el("pre", { class: "preview" }); pre.textContent = ser; host.append(pre, msg);
408
+ }
409
+ }
410
+ // Apply the edited full model: PUT defaults + channels, and diff instances.
411
+ async function applyFullModel(m, msg) {
412
+ if (!m || typeof m !== "object") { msg.innerHTML = `<span class="e">✗ expected a mapping</span>`; return; }
413
+ const results = [];
414
+ if (m.defaults) results.push(await api("/api/settings/fleet/defaults", { method: "PUT", body: JSON.stringify(m.defaults) }));
415
+ const chs = m.channels || (m.channel ? [m.channel] : null);
416
+ if (chs) results.push(await api("/api/settings/fleet/channels", { method: "PUT", body: JSON.stringify(chs) }));
417
+ if (m.instances && typeof m.instances === "object") {
418
+ const cur = state.fleet.instances || {};
419
+ for (const [n, cfg] of Object.entries(m.instances)) {
420
+ const exists = !!cur[n];
421
+ results.push(await api(`/api/settings/fleet/instances/${encodeURIComponent(n)}`, { method: exists ? "PATCH" : "POST", body: JSON.stringify(cfg) }));
422
+ }
423
+ for (const n of Object.keys(cur)) if (!(n in m.instances)) results.push(await api(`/api/settings/fleet/instances/${encodeURIComponent(n)}`, { method: "DELETE" }));
424
+ }
425
+ const bad = results.find(r => !r.ok);
426
+ resultMsg(msg, bad || { ok: true, body: {} });
427
+ if (!bad) { state.advEdit = false; await reload(); }
428
+ }
429
+
430
+ // ── Load / render ──
431
+ function renderAll() { renderAgents(); renderBots(); renderGeneral(); renderAdvanced(); }
432
+ async function reloadLive() {
433
+ const f = await api("/api/fleet");
434
+ state.fleetUp = f.ok;
435
+ state.live = {}; if (f.ok && f.body?.instances) for (const i of f.body.instances) state.live[i.name] = { status: i.status };
436
+ renderAgents(); renderBots();
437
+ }
438
+ async function reload() {
439
+ const f = await api("/api/settings/fleet");
440
+ if (!f.ok) { $("banner").style.display = "block"; $("banner").style.background = "#fef2f2"; $("banner").style.color = "var(--red)"; $("banner").textContent = "Failed to load config — check the token in the URL."; return; }
441
+ state.fleet = f.body || {};
442
+ const c = await api("/api/settings/classic"); state.classic = c.body || {};
443
+ await reloadLive();
444
+ renderAll();
445
+ }
446
+
447
+ $("agentSearch").oninput = () => renderAgents();
448
+ $("newAgentBtn").onclick = () => { const open = $("newAgentBtn").textContent === "+ New"; $("newAgentBtn").textContent = open ? "Cancel" : "+ New"; renderNewAgent(open); };
449
+ $("newBotBtn").onclick = () => { const open = $("newBotBtn").textContent === "+ Add"; $("newBotBtn").textContent = open ? "Cancel" : "+ Add"; renderNewBot(open); };
450
+ $("advToggle").onclick = () => { const b = $("advBody"); const open = !b.classList.contains("open"); b.classList.toggle("open", open); $("advToggle").textContent = open ? "▼ Collapse" : "▶ Expand"; };
451
+ $("reloadBtn").onclick = async () => { const r = await api("/api/settings/reload", { method: "POST" }); const b = $("banner"); b.style.display = "block"; b.style.background = "#ecfdf5"; b.style.color = "var(--green)"; b.textContent = r.ok ? "✓ Reload signal sent — instances reconciling." : "Reload failed."; setTimeout(() => b.style.display = "none", 4000); setTimeout(reloadLive, 1500); };
452
+
453
+ reload();
454
+ })();
455
+ </script>
456
+ </body>
457
+ </html>