@mooncompany/uplink-chat 0.5.0

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.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,1525 @@
1
+ // ============================================
2
+ // AGENTS MODULE
3
+ // Agent management panel orchestrator
4
+ // Imports data API and UI modules
5
+ // ============================================
6
+
7
+ import * as AgentsData from './agents-data.js';
8
+ import * as AgentsUI from './agents-ui.js';
9
+
10
+ // Re-export for backward compat
11
+ const { TOOL_GROUPS, ALL_TOOLS, escapeHtml, escapeAttr, showToast, TOOL_PRESETS } = AgentsUI;
12
+
13
+ // State (now managed by data module, but keep local refs for compatibility)
14
+ let agents = [];
15
+ let defaults = {};
16
+ let bindings = [];
17
+ let globalTools = {};
18
+ let channels = [];
19
+ let configHash = null;
20
+ let loaded = false;
21
+ let loading = false;
22
+ let currentView = 'list'; // 'list' | 'detail' | 'create'
23
+ let selectedAgentId = null;
24
+
25
+ // Edit state
26
+ let editingSection = null; // which section is being edited
27
+ let pendingChanges = {}; // accumulated changes before save
28
+ let saving = false;
29
+ let restartPending = false;
30
+
31
+ // Current container reference for re-renders
32
+ let currentContainer = null;
33
+
34
+ // Sync local state with data module
35
+ function syncState() {
36
+ const state = AgentsData.getState();
37
+ agents = state.agents;
38
+ defaults = state.defaults;
39
+ bindings = state.bindings;
40
+ globalTools = state.globalTools;
41
+ channels = state.channels;
42
+ configHash = state.configHash;
43
+ loaded = state.loaded;
44
+ loading = state.loading;
45
+ saving = state.saving;
46
+ restartPending = state.restartPending;
47
+ }
48
+
49
+ // ============================================
50
+ // DATA FETCHING (delegates to data module)
51
+ // ============================================
52
+
53
+ async function fetchAgents() {
54
+ await AgentsData.fetchAgents();
55
+ syncState();
56
+ }
57
+
58
+ async function saveAgentChanges(agentId, changes) {
59
+ try {
60
+ const result = await AgentsData.saveAgentChanges(agentId, changes, () => {
61
+ showToast('Config was modified externally. Refreshing...', 'warning');
62
+ syncState();
63
+ rerender();
64
+ });
65
+ syncState();
66
+ if (result) {
67
+ showToast('Changes applied', 'success');
68
+ }
69
+ return result;
70
+ } catch (err) {
71
+ showToast(`Save failed: ${err.message}`, 'error');
72
+ return false;
73
+ }
74
+ }
75
+
76
+ // ============================================
77
+ // RENDERING
78
+ // ============================================
79
+
80
+ function render(container) {
81
+ if (!container) return;
82
+ currentContainer = container;
83
+
84
+ if (!loaded && !loading) {
85
+ fetchAgents().then(() => render(container));
86
+ container.innerHTML = renderLoading();
87
+ return;
88
+ }
89
+
90
+ if (loading) {
91
+ container.innerHTML = renderLoading();
92
+ return;
93
+ }
94
+
95
+ if (restartPending) {
96
+ container.innerHTML = renderRestartOverlay();
97
+ return;
98
+ }
99
+
100
+ if (currentView === 'create') {
101
+ container.innerHTML = renderCreateForm();
102
+ bindCreateEvents(container);
103
+ } else if (currentView === 'detail' && selectedAgentId) {
104
+ container.innerHTML = renderAgentDetail(selectedAgentId);
105
+ bindDetailEvents(container);
106
+ } else {
107
+ container.innerHTML = renderAgentList();
108
+ bindListEvents(container);
109
+ }
110
+ }
111
+
112
+ function rerender() {
113
+ if (currentContainer) render(currentContainer);
114
+ }
115
+
116
+ function renderLoading() {
117
+ return `
118
+ <div class="agents-loading">
119
+ <span class="agents-loading-dot"></span>
120
+ <span class="agents-loading-text">Loading agents...</span>
121
+ </div>
122
+ `;
123
+ }
124
+
125
+ function renderAgentList() {
126
+ if (agents.length === 0) {
127
+ return `
128
+ <div class="agents-empty">
129
+ <span class="agents-empty-icon">🤖</span>
130
+ <span class="agents-empty-text">No agents configured</span>
131
+ </div>
132
+ `;
133
+ }
134
+
135
+ const items = agents.map(agent => {
136
+ const modelDisplay = getModelDisplay(agent);
137
+ const sandboxBadge = getSandboxBadge(agent);
138
+ const toolsSummary = getToolsSummary(agent);
139
+ const bindingCount = bindings.filter(b => b.agentId === agent.id).length;
140
+ const emoji = agent.identity?.emoji || '🤖';
141
+ const name = agent.identity?.name || agent.name || agent.id;
142
+ const theme = agent.identity?.theme || '';
143
+
144
+ const avatarHtml = `<span class="panel-item-icon agent-item-avatar" data-fallback-emoji="${emoji}">
145
+ <img src="/img/agents/${encodeURIComponent(agent.id)}.png" alt="" class="agent-list-avatar-img" data-agent-id="${encodeURIComponent(agent.id)}" loading="lazy">
146
+ </span>`;
147
+
148
+ return `
149
+ <div class="panel-item agent-item" data-agent-id="${escapeHtml(agent.id)}">
150
+ ${avatarHtml}
151
+ <div class="agent-item-info">
152
+ <div class="agent-item-name-row">
153
+ <span class="panel-item-name agent-item-name">${escapeHtml(name)}</span>
154
+ ${agent.default ? '<span class="agent-badge agent-badge-default">default</span>' : ''}
155
+ ${sandboxBadge}
156
+ </div>
157
+ ${theme ? `<div class="agent-item-theme">${escapeHtml(theme)}</div>` : ''}
158
+ <div class="agent-item-meta">
159
+ <span class="agent-meta-model">${escapeHtml(modelDisplay)}</span>
160
+ ${toolsSummary ? `<span class="agent-meta-sep">·</span><span class="agent-meta-tools">${toolsSummary}</span>` : ''}
161
+ ${bindingCount > 0 ? `<span class="agent-meta-sep">·</span><span class="agent-meta-bindings">${bindingCount} route${bindingCount !== 1 ? 's' : ''}</span>` : ''}
162
+ </div>
163
+ </div>
164
+ <span class="agent-item-chevron">›</span>
165
+ </div>
166
+ `;
167
+ }).join('');
168
+
169
+ return `
170
+ <div class="agents-list-header">
171
+ <span class="agents-list-count">${agents.length} agent${agents.length !== 1 ? 's' : ''}</span>
172
+ <button class="agents-refresh-btn" title="Refresh">↻</button>
173
+ </div>
174
+ <div class="agents-list" role="listbox" aria-label="Agent list">
175
+ ${items}
176
+ </div>
177
+ <div class="agents-list-actions">
178
+ <button class="agents-create-btn" id="createAgentBtn">+ New Agent</button>
179
+ </div>
180
+ `;
181
+ }
182
+
183
+ // ============================================
184
+ // AGENT DETAIL VIEW
185
+ // ============================================
186
+
187
+ function renderAgentDetail(agentId) {
188
+ const agent = agents.find(a => a.id === agentId);
189
+ if (!agent) {
190
+ return `<div class="agents-empty"><span>Agent not found</span></div>`;
191
+ }
192
+
193
+ const emoji = agent.identity?.emoji || '🤖';
194
+ const name = agent.identity?.name || agent.name || agent.id;
195
+ const theme = agent.identity?.theme || '';
196
+ const workspace = agent.workspace || defaults.workspace || 'default';
197
+ const agentBindings = bindings.filter(b => b.agentId === agent.id);
198
+
199
+ return `
200
+ <div class="agent-detail">
201
+ <div class="agent-detail-hero" data-agent-id="${escapeHtml(agent.id)}">
202
+ <img class="agent-detail-hero-img" src="/img/agents/${escapeHtml(agent.id)}.png" alt="">
203
+ <span class="agent-detail-hero-fallback">${emoji}</span>
204
+ <div class="agent-detail-hero-overlay">
205
+ <div class="agent-detail-hero-nav">
206
+ <button class="agent-detail-back" aria-label="Back to agent list">← Back</button>
207
+ </div>
208
+ <div class="agent-detail-hero-info">
209
+ <span class="agent-detail-name">${escapeHtml(name)}</span>
210
+ <span class="agent-detail-id">${escapeHtml(agent.id)}</span>
211
+ </div>
212
+ </div>
213
+ <span class="agent-avatar-overlay">📷</span>
214
+ <input type="file" class="agent-avatar-input" accept="image/png,image/jpeg,image/webp" style="display:none">
215
+ ${agent.default ? '<span class="agent-badge agent-badge-default">default</span>' : ''}
216
+ </div>
217
+
218
+ ${theme ? `<div class="agent-detail-theme-block"><span class="agent-detail-theme">${escapeHtml(theme)}</span></div>` : ''}
219
+
220
+ ${renderEditableSection('identity', 'Identity', renderIdentityView(agent), renderIdentityEdit(agent))}
221
+ ${renderEditableSection('model', 'Model', renderModelView(agent), renderModelEdit(agent))}
222
+
223
+ ${renderDetailSection('Workspace', `
224
+ <div class="agent-detail-row">
225
+ <span class="agent-detail-label">Path</span>
226
+ <span class="agent-detail-value agent-detail-mono">${escapeHtml(workspace)}${!agent.workspace ? ' <span class="agent-detail-inherited">(inherited)</span>' : ''}</span>
227
+ </div>
228
+ `)}
229
+
230
+ ${renderEditableSection('sandbox', 'Sandbox', renderSandboxView(agent), renderSandboxEdit(agent))}
231
+ ${renderEditableSection('tools', 'Tools', renderToolsView(agent), renderToolsEdit(agent))}
232
+ ${renderEditableSection('subagents', 'Sub-agents', renderSubagentsView(agent), renderSubagentsEdit(agent))}
233
+
234
+ ${renderEditableSection('routing', 'Routing', renderRoutingView(agent, agentBindings), renderRoutingEdit(agent, agentBindings))}
235
+
236
+ ${agent.groupChat?.mentionPatterns ? renderDetailSection('Group Chat', `
237
+ <div class="agent-detail-row">
238
+ <span class="agent-detail-label">Mentions</span>
239
+ <span class="agent-detail-value">${agent.groupChat.mentionPatterns.map(p => escapeHtml(p)).join(', ')}</span>
240
+ </div>
241
+ `) : ''}
242
+
243
+ ${renderRawJsonSection(agent)}
244
+
245
+ ${agent.id !== 'main' ? `
246
+ <div class="agent-detail-danger-zone">
247
+ <button class="agent-detail-delete" aria-label="Delete agent">Delete Agent</button>
248
+ </div>` : ''}
249
+ </div>
250
+ `;
251
+ }
252
+
253
+ function renderDetailSection(title, content) {
254
+ return `
255
+ <div class="agent-section">
256
+ <div class="agent-section-header">
257
+ <span class="agent-section-title">${title}</span>
258
+ </div>
259
+ <div class="agent-section-body">
260
+ ${content}
261
+ </div>
262
+ </div>
263
+ `;
264
+ }
265
+
266
+ function renderEditableSection(sectionId, title, viewContent, editContent) {
267
+ const isEditing = editingSection === sectionId;
268
+ return `
269
+ <div class="agent-section" data-section="${sectionId}">
270
+ <div class="agent-section-header">
271
+ <span class="agent-section-title">${title}</span>
272
+ ${isEditing
273
+ ? `<div class="agent-section-actions">
274
+ <button class="agent-section-btn agent-section-cancel" data-action="cancel" data-section="${sectionId}">Cancel</button>
275
+ <button class="agent-section-btn agent-section-save" data-action="save" data-section="${sectionId}">Save</button>
276
+ </div>`
277
+ : `<button class="agent-section-btn agent-section-edit" data-action="edit" data-section="${sectionId}">Edit</button>`
278
+ }
279
+ </div>
280
+ <div class="agent-section-body">
281
+ ${isEditing ? editContent : viewContent}
282
+ </div>
283
+ </div>
284
+ `;
285
+ }
286
+
287
+ // ============================================
288
+ // VIEW RENDERERS (read-only display)
289
+ // ============================================
290
+
291
+ function renderIdentityView(agent) {
292
+ const name = agent.identity?.name || agent.name || agent.id;
293
+ const emoji = agent.identity?.emoji || '🤖';
294
+ const theme = agent.identity?.theme || '';
295
+
296
+ let html = `
297
+ <div class="agent-detail-row">
298
+ <span class="agent-detail-label">Name</span>
299
+ <span class="agent-detail-value">${escapeHtml(name)}</span>
300
+ </div>
301
+ <div class="agent-detail-row">
302
+ <span class="agent-detail-label">Emoji</span>
303
+ <span class="agent-detail-value">${emoji}</span>
304
+ </div>
305
+ `;
306
+ if (theme) {
307
+ html += `
308
+ <div class="agent-detail-row">
309
+ <span class="agent-detail-label">Theme</span>
310
+ <span class="agent-detail-value">${escapeHtml(theme)}</span>
311
+ </div>
312
+ `;
313
+ }
314
+ return html;
315
+ }
316
+
317
+ function renderModelView(agent) {
318
+ const model = getModelDisplay(agent);
319
+ const isInherited = !agent.model;
320
+ const fallbacks = agent.model?.fallbacks || defaults.model?.fallbacks || [];
321
+
322
+ let html = `
323
+ <div class="agent-detail-row">
324
+ <span class="agent-detail-label">Primary</span>
325
+ <span class="agent-detail-value">${escapeHtml(model)}${isInherited ? ' <span class="agent-detail-inherited">(inherited)</span>' : ''}</span>
326
+ </div>
327
+ `;
328
+
329
+ if (fallbacks.length > 0) {
330
+ html += `
331
+ <div class="agent-detail-row">
332
+ <span class="agent-detail-label">Fallbacks</span>
333
+ <div class="agent-tool-tags">
334
+ ${fallbacks.map(f => `<span class="agent-tool-tag">${escapeHtml(f)}</span>`).join('')}
335
+ </div>
336
+ </div>
337
+ `;
338
+ }
339
+ return html;
340
+ }
341
+
342
+ function renderSandboxView(agent) {
343
+ const sandbox = agent.sandbox || defaults.sandbox;
344
+ if (!sandbox) {
345
+ return `<div class="agent-detail-row"><span class="agent-detail-value agent-detail-muted">Inherits defaults (off)</span></div>`;
346
+ }
347
+
348
+ const mode = sandbox.mode || 'off';
349
+ const scope = sandbox.scope || 'session';
350
+ const access = sandbox.workspaceAccess || 'rw';
351
+
352
+ let html = `
353
+ <div class="agent-detail-row">
354
+ <span class="agent-detail-label">Mode</span>
355
+ <span class="agent-detail-value">
356
+ <span class="agent-badge agent-badge-${mode === 'off' ? 'off' : 'sandbox'}">${mode}</span>
357
+ </span>
358
+ </div>
359
+ `;
360
+
361
+ if (mode !== 'off') {
362
+ html += `
363
+ <div class="agent-detail-row">
364
+ <span class="agent-detail-label">Scope</span>
365
+ <span class="agent-detail-value">${scope}</span>
366
+ </div>
367
+ <div class="agent-detail-row">
368
+ <span class="agent-detail-label">Workspace Access</span>
369
+ <span class="agent-detail-value">${access}</span>
370
+ </div>
371
+ `;
372
+ }
373
+ return html;
374
+ }
375
+
376
+ function renderToolsView(agent) {
377
+ const tools = agent.tools;
378
+ if (!tools) {
379
+ return `<div class="agent-detail-row"><span class="agent-detail-value agent-detail-muted">Full access (no restrictions)</span></div>`;
380
+ }
381
+
382
+ let html = '';
383
+
384
+ if (tools.profile) {
385
+ html += `
386
+ <div class="agent-detail-row">
387
+ <span class="agent-detail-label">Profile</span>
388
+ <span class="agent-detail-value">${escapeHtml(tools.profile)}</span>
389
+ </div>
390
+ `;
391
+ }
392
+
393
+ if (tools.allow && tools.allow.length > 0) {
394
+ html += `
395
+ <div class="agent-detail-row">
396
+ <span class="agent-detail-label">Allowed</span>
397
+ <div class="agent-tool-tags">
398
+ ${tools.allow.map(t => `<span class="agent-tool-tag agent-tool-allow">${escapeHtml(t)}</span>`).join('')}
399
+ </div>
400
+ </div>
401
+ `;
402
+ }
403
+
404
+ if (tools.deny && tools.deny.length > 0) {
405
+ html += `
406
+ <div class="agent-detail-row">
407
+ <span class="agent-detail-label">Denied</span>
408
+ <div class="agent-tool-tags">
409
+ ${tools.deny.map(t => `<span class="agent-tool-tag agent-tool-deny">${escapeHtml(t)}</span>`).join('')}
410
+ </div>
411
+ </div>
412
+ `;
413
+ }
414
+
415
+ return html || `<div class="agent-detail-row"><span class="agent-detail-value agent-detail-muted">Full access</span></div>`;
416
+ }
417
+
418
+ function renderSubagentsView(agent) {
419
+ const sub = agent.subagents;
420
+ const defSub = defaults.subagents || {};
421
+
422
+ if (!sub && !defSub.maxConcurrent) {
423
+ return `<div class="agent-detail-row"><span class="agent-detail-value agent-detail-muted">No sub-agent configuration</span></div>`;
424
+ }
425
+
426
+ let html = '';
427
+ const effectiveSub = sub || {};
428
+
429
+ if (effectiveSub.model) {
430
+ html += `
431
+ <div class="agent-detail-row">
432
+ <span class="agent-detail-label">Default Model</span>
433
+ <span class="agent-detail-value">${escapeHtml(effectiveSub.model)}</span>
434
+ </div>
435
+ `;
436
+ }
437
+
438
+ if (effectiveSub.thinking) {
439
+ html += `
440
+ <div class="agent-detail-row">
441
+ <span class="agent-detail-label">Thinking</span>
442
+ <span class="agent-detail-value">${escapeHtml(effectiveSub.thinking)}</span>
443
+ </div>
444
+ `;
445
+ }
446
+
447
+ if (effectiveSub.allowAgents && effectiveSub.allowAgents.length > 0) {
448
+ html += `
449
+ <div class="agent-detail-row">
450
+ <span class="agent-detail-label">Allowed Models</span>
451
+ <div class="agent-tool-tags">
452
+ ${effectiveSub.allowAgents.map(a => `<span class="agent-tool-tag">${escapeHtml(a)}</span>`).join('')}
453
+ </div>
454
+ </div>
455
+ `;
456
+ }
457
+
458
+ const maxConc = effectiveSub.maxConcurrent || defSub.maxConcurrent;
459
+ if (maxConc) {
460
+ html += `
461
+ <div class="agent-detail-row">
462
+ <span class="agent-detail-label">Max Concurrent</span>
463
+ <span class="agent-detail-value">${maxConc}${!effectiveSub.maxConcurrent && defSub.maxConcurrent ? ' <span class="agent-detail-inherited">(default)</span>' : ''}</span>
464
+ </div>
465
+ `;
466
+ }
467
+
468
+ return html || `<div class="agent-detail-row"><span class="agent-detail-value agent-detail-muted">Inherits defaults</span></div>`;
469
+ }
470
+
471
+ function renderBindingsDetail(agentBindings) {
472
+ return agentBindings.map(b => {
473
+ const match = b.match || {};
474
+ let desc = match.channel || 'any';
475
+ if (match.accountId && match.accountId !== '*') desc += ` / ${match.accountId}`;
476
+ if (match.peer) desc += ` → ${match.peer.kind}: ${match.peer.id}`;
477
+ if (match.guildId) desc += ` (guild: ${match.guildId})`;
478
+ if (match.teamId) desc += ` (team: ${match.teamId})`;
479
+
480
+ return `
481
+ <div class="agent-detail-row">
482
+ <span class="agent-detail-value agent-detail-mono">${escapeHtml(desc)}</span>
483
+ </div>
484
+ `;
485
+ }).join('');
486
+ }
487
+
488
+ function renderRoutingView(agent, agentBindings) {
489
+ if (agentBindings.length === 0) {
490
+ let msg = 'No routes configured';
491
+ if (agent.default) {
492
+ msg += ' — receives all unmatched messages';
493
+ } else {
494
+ msg += ' — will not receive any messages';
495
+ }
496
+ return `<div class="agent-detail-row"><span class="agent-detail-value agent-detail-muted">${msg}</span></div>`;
497
+ }
498
+ return renderBindingsDetail(agentBindings);
499
+ }
500
+
501
+ function renderRoutingEdit(agent, agentBindings) {
502
+ const channelOptions = channels.map(ch =>
503
+ `<option value="${escapeAttr(ch)}">${escapeHtml(ch)}</option>`
504
+ ).join('');
505
+
506
+ const agentOptions = agents.map(a =>
507
+ `<option value="${escapeAttr(a.id)}">${escapeHtml(a.identity?.name || a.name || a.id)}</option>`
508
+ ).join('');
509
+
510
+ // Render existing bindings as editable rows
511
+ const rows = agentBindings.map((b, i) => renderBindingRow(b, i, channelOptions)).join('');
512
+
513
+ return `
514
+ <div class="agent-binding-info">
515
+ Routes determine which messages this agent receives. Most-specific match wins.
516
+ ${agent.default ? '<br><em>As the default agent, unmatched messages come here automatically.</em>' : ''}
517
+ </div>
518
+ <div class="agent-bindings-list" id="bindingsList">
519
+ ${rows || '<div class="agent-detail-muted agent-bindings-empty">No routes — add one below</div>'}
520
+ </div>
521
+ <button class="agent-binding-add" id="addBindingBtn">+ Add Route</button>
522
+ `;
523
+ }
524
+
525
+ function renderBindingRow(binding, index, _channelOptions) {
526
+ const match = binding.match || {};
527
+ const peerKind = match.peer?.kind || '';
528
+ const peerId = match.peer?.id || '';
529
+
530
+ // Build channel options with proper selected state
531
+ const chOpts = channels.map(ch =>
532
+ `<option value="${escapeAttr(ch)}" ${ch === (match.channel || '') ? 'selected' : ''}>${escapeHtml(ch)}</option>`
533
+ ).join('');
534
+
535
+ return `
536
+ <div class="agent-binding-row" data-binding-index="${index}">
537
+ <div class="agent-binding-fields">
538
+ <div class="agent-edit-field">
539
+ <label class="agent-edit-label">Channel</label>
540
+ <select class="agent-edit-select agent-binding-channel" data-index="${index}">
541
+ <option value="" ${!match.channel ? 'selected' : ''}>Any channel</option>
542
+ ${chOpts}
543
+ </select>
544
+ </div>
545
+ <div class="agent-edit-field">
546
+ <label class="agent-edit-label">Account ID</label>
547
+ <input type="text" class="agent-edit-input agent-binding-account" data-index="${index}"
548
+ value="${escapeAttr(match.accountId || '')}" placeholder="* (any)">
549
+ </div>
550
+ <div class="agent-edit-field">
551
+ <label class="agent-edit-label">Peer Type</label>
552
+ <select class="agent-edit-select agent-binding-peer-kind" data-index="${index}">
553
+ <option value="" ${!peerKind ? 'selected' : ''}>None</option>
554
+ <option value="direct" ${peerKind === 'direct' ? 'selected' : ''}>Direct (DM)</option>
555
+ <option value="group" ${peerKind === 'group' ? 'selected' : ''}>Group</option>
556
+ <option value="channel" ${peerKind === 'channel' ? 'selected' : ''}>Channel</option>
557
+ </select>
558
+ </div>
559
+ <div class="agent-edit-field">
560
+ <label class="agent-edit-label">Peer ID</label>
561
+ <input type="text" class="agent-edit-input agent-binding-peer-id" data-index="${index}"
562
+ value="${escapeAttr(peerId)}" placeholder="e.g. +15551234567 or group ID"
563
+ ${!peerKind ? 'disabled' : ''}>
564
+ </div>
565
+ ${match.guildId ? `
566
+ <div class="agent-edit-field">
567
+ <label class="agent-edit-label">Guild ID <span class="agent-edit-hint">(Discord)</span></label>
568
+ <input type="text" class="agent-edit-input agent-binding-guild" data-index="${index}"
569
+ value="${escapeAttr(match.guildId || '')}">
570
+ </div>` : ''}
571
+ ${match.teamId ? `
572
+ <div class="agent-edit-field">
573
+ <label class="agent-edit-label">Team ID <span class="agent-edit-hint">(Slack)</span></label>
574
+ <input type="text" class="agent-edit-input agent-binding-team" data-index="${index}"
575
+ value="${escapeAttr(match.teamId || '')}">
576
+ </div>` : ''}
577
+ </div>
578
+ <button class="agent-binding-remove" data-index="${index}" title="Remove route">✕</button>
579
+ </div>
580
+ `;
581
+ }
582
+
583
+ // ============================================
584
+ // RAW JSON VIEW
585
+ // ============================================
586
+
587
+ function renderRawJsonSection(agent) {
588
+ // Build the raw config object (only non-null fields)
589
+ const raw = { id: agent.id };
590
+ if (agent.name && agent.name !== agent.id) raw.name = agent.name;
591
+ if (agent.default) raw.default = true;
592
+ if (agent.model) raw.model = agent.model;
593
+ if (agent.identity) raw.identity = agent.identity;
594
+ if (agent.workspace) raw.workspace = agent.workspace;
595
+ if (agent.sandbox) raw.sandbox = agent.sandbox;
596
+ if (agent.tools) raw.tools = agent.tools;
597
+ if (agent.subagents) raw.subagents = agent.subagents;
598
+ if (agent.groupChat) raw.groupChat = agent.groupChat;
599
+
600
+ const json = JSON.stringify(raw, null, 2);
601
+
602
+ return `
603
+ <div class="agent-section agent-section-collapsible" data-section="raw">
604
+ <div class="agent-section-header agent-raw-toggle">
605
+ <span class="agent-section-title">Raw Config</span>
606
+ <span class="agent-raw-chevron">▸</span>
607
+ </div>
608
+ <div class="agent-section-body agent-raw-body" style="display:none">
609
+ <pre class="agent-raw-json">${escapeHtml(json)}</pre>
610
+ <button class="agent-raw-copy" title="Copy to clipboard">Copy JSON</button>
611
+ </div>
612
+ </div>
613
+ `;
614
+ }
615
+
616
+ // ============================================
617
+ // EDIT RENDERERS (form inputs)
618
+ // ============================================
619
+
620
+ function renderIdentityEdit(agent) {
621
+ const name = agent.identity?.name || agent.name || '';
622
+ const emoji = agent.identity?.emoji || '';
623
+ const theme = agent.identity?.theme || '';
624
+
625
+ return `
626
+ <div class="agent-edit-field">
627
+ <label class="agent-edit-label">Display Name</label>
628
+ <input type="text" class="agent-edit-input" data-field="identity.name" value="${escapeAttr(name)}" placeholder="Agent name">
629
+ </div>
630
+ <div class="agent-edit-field">
631
+ <label class="agent-edit-label">Emoji</label>
632
+ <input type="text" class="agent-edit-input agent-edit-input-short" data-field="identity.emoji" value="${escapeAttr(emoji)}" placeholder="🤖" maxlength="4">
633
+ </div>
634
+ <div class="agent-edit-field">
635
+ <label class="agent-edit-label">Persona Theme <span class="agent-edit-hint">(injected into system prompt)</span></label>
636
+ <input type="text" class="agent-edit-input" data-field="identity.theme" value="${escapeAttr(theme)}" placeholder="e.g. helpful assistant, space lobster">
637
+ </div>
638
+ `;
639
+ }
640
+
641
+ function renderModelEdit(agent) {
642
+ const currentModel = agent.model?.primary || agent.model || '';
643
+ const isObj = typeof agent.model === 'object' && agent.model !== null;
644
+ const fallbacks = isObj ? (agent.model.fallbacks || []) : [];
645
+ const availableModels = defaults.models || [];
646
+
647
+ // Build model options from the defaults catalog
648
+ const modelOptions = availableModels.map(m =>
649
+ `<option value="${escapeAttr(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`
650
+ ).join('');
651
+
652
+ return `
653
+ <div class="agent-edit-field">
654
+ <label class="agent-edit-label">Primary Model</label>
655
+ <select class="agent-edit-select" data-field="model.primary">
656
+ <option value="">Inherit from defaults</option>
657
+ ${modelOptions}
658
+ </select>
659
+ </div>
660
+ <div class="agent-edit-field">
661
+ <label class="agent-edit-label">Fallbacks <span class="agent-edit-hint">(comma-separated)</span></label>
662
+ <input type="text" class="agent-edit-input" data-field="model.fallbacks" value="${escapeAttr(fallbacks.join(', '))}" placeholder="model/name-1, model/name-2">
663
+ </div>
664
+ `;
665
+ }
666
+
667
+ function renderSandboxEdit(agent) {
668
+ const sandbox = agent.sandbox || {};
669
+ const mode = sandbox.mode || 'off';
670
+ const scope = sandbox.scope || 'session';
671
+ const access = sandbox.workspaceAccess || 'rw';
672
+
673
+ return `
674
+ <div class="agent-edit-field">
675
+ <label class="agent-edit-label">Mode</label>
676
+ <select class="agent-edit-select" data-field="sandbox.mode">
677
+ <option value="off" ${mode === 'off' ? 'selected' : ''}>Off</option>
678
+ <option value="non-main" ${mode === 'non-main' ? 'selected' : ''}>Non-main sessions</option>
679
+ <option value="all" ${mode === 'all' ? 'selected' : ''}>All sessions</option>
680
+ </select>
681
+ </div>
682
+ <div class="agent-edit-field">
683
+ <label class="agent-edit-label">Scope</label>
684
+ <select class="agent-edit-select" data-field="sandbox.scope">
685
+ <option value="session" ${scope === 'session' ? 'selected' : ''}>Per session</option>
686
+ <option value="agent" ${scope === 'agent' ? 'selected' : ''}>Per agent</option>
687
+ <option value="shared" ${scope === 'shared' ? 'selected' : ''}>Shared</option>
688
+ </select>
689
+ </div>
690
+ <div class="agent-edit-field">
691
+ <label class="agent-edit-label">Workspace Access</label>
692
+ <select class="agent-edit-select" data-field="sandbox.workspaceAccess">
693
+ <option value="rw" ${access === 'rw' ? 'selected' : ''}>Read/Write</option>
694
+ <option value="ro" ${access === 'ro' ? 'selected' : ''}>Read Only</option>
695
+ <option value="none" ${access === 'none' ? 'selected' : ''}>None</option>
696
+ </select>
697
+ </div>
698
+ `;
699
+ }
700
+
701
+ // Tool presets (imported from AgentsUI module)
702
+
703
+ function renderToolsEdit(agent) {
704
+ const tools = agent.tools || {};
705
+ const denyList = tools.deny || [];
706
+ const allowList = tools.allow || [];
707
+ const mode = allowList.length > 0 ? 'allow' : denyList.length > 0 ? 'deny' : 'full';
708
+
709
+ const presetButtons = Object.entries(TOOL_PRESETS).map(([key, preset]) =>
710
+ `<button class="agent-preset-btn" data-preset="${key}">${preset.label}</button>`
711
+ ).join('');
712
+
713
+ let html = `
714
+ <div class="agent-edit-field">
715
+ <label class="agent-edit-label">Presets</label>
716
+ <div class="agent-preset-bar">${presetButtons}</div>
717
+ </div>
718
+ <div class="agent-edit-field">
719
+ <label class="agent-edit-label">Mode</label>
720
+ <select class="agent-edit-select" data-field="tools.mode" id="toolsModeSelect">
721
+ <option value="full" ${mode === 'full' ? 'selected' : ''}>Full access (no restrictions)</option>
722
+ <option value="allow" ${mode === 'allow' ? 'selected' : ''}>Allowlist (only selected tools)</option>
723
+ <option value="deny" ${mode === 'deny' ? 'selected' : ''}>Denylist (block selected tools)</option>
724
+ </select>
725
+ </div>
726
+ <div class="agent-tools-grid" id="toolsGrid" style="${mode === 'full' ? 'display:none' : ''}">
727
+ `;
728
+
729
+ // Render tool groups with checkboxes
730
+ for (const [groupName, group] of Object.entries(TOOL_GROUPS)) {
731
+ const activeList = mode === 'allow' ? allowList : denyList;
732
+ const groupChecked = activeList.includes(group.id);
733
+ const individualChecked = group.tools.filter(t => activeList.includes(t));
734
+
735
+ html += `
736
+ <div class="agent-tool-group">
737
+ <label class="agent-tool-group-label">
738
+ <input type="checkbox" class="agent-tool-group-check" data-group="${group.id}"
739
+ ${groupChecked ? 'checked' : ''}>
740
+ <span>${groupName}</span>
741
+ <span class="agent-tool-group-hint">${group.tools.join(', ')}</span>
742
+ </label>
743
+ <div class="agent-tool-group-items">
744
+ ${group.tools.map(tool => `
745
+ <label class="agent-tool-item-label">
746
+ <input type="checkbox" class="agent-tool-item-check" data-tool="${tool}" data-group-id="${group.id}"
747
+ ${groupChecked || individualChecked.includes(tool) ? 'checked' : ''}>
748
+ <span>${tool}</span>
749
+ </label>
750
+ `).join('')}
751
+ </div>
752
+ </div>
753
+ `;
754
+ }
755
+
756
+ html += '</div>';
757
+ return html;
758
+ }
759
+
760
+ function renderSubagentsEdit(agent) {
761
+ const sub = agent.subagents || {};
762
+ const allowAgents = sub.allowAgents || [];
763
+ const maxConc = sub.maxConcurrent || '';
764
+
765
+ return `
766
+ <div class="agent-edit-field">
767
+ <label class="agent-edit-label">Allowed Models <span class="agent-edit-hint">(one per line)</span></label>
768
+ <textarea class="agent-edit-textarea" data-field="subagents.allowAgents" rows="4" placeholder="anthropic/claude-sonnet-4-5&#10;nvidia/moonshotai/kimi-k2.5">${escapeHtml(allowAgents.join('\n'))}</textarea>
769
+ </div>
770
+ <div class="agent-edit-field">
771
+ <label class="agent-edit-label">Max Concurrent</label>
772
+ <input type="number" class="agent-edit-input agent-edit-input-short" data-field="subagents.maxConcurrent" value="${maxConc}" placeholder="Default" min="1" max="100">
773
+ </div>
774
+ `;
775
+ }
776
+
777
+ // ============================================
778
+ // EDIT STATE MANAGEMENT
779
+ // ============================================
780
+
781
+ function collectSectionChanges(container, sectionId) {
782
+ const section = container.querySelector(`[data-section="${sectionId}"]`);
783
+ if (!section) return null;
784
+
785
+ const agent = agents.find(a => a.id === selectedAgentId);
786
+ if (!agent) return null;
787
+
788
+ const changes = {};
789
+
790
+ if (sectionId === 'identity') {
791
+ const name = section.querySelector('[data-field="identity.name"]')?.value?.trim();
792
+ const emoji = section.querySelector('[data-field="identity.emoji"]')?.value?.trim();
793
+ const theme = section.querySelector('[data-field="identity.theme"]')?.value?.trim();
794
+
795
+ changes.identity = {
796
+ ...(agent.identity || {}),
797
+ name: name || undefined,
798
+ emoji: emoji || undefined,
799
+ theme: theme || undefined,
800
+ };
801
+ // Clean undefined values
802
+ Object.keys(changes.identity).forEach(k => {
803
+ if (changes.identity[k] === undefined || changes.identity[k] === '') delete changes.identity[k];
804
+ });
805
+ if (Object.keys(changes.identity).length === 0) changes.identity = null;
806
+ }
807
+
808
+ if (sectionId === 'model') {
809
+ const primary = section.querySelector('[data-field="model.primary"]')?.value?.trim();
810
+ const fallbacksRaw = section.querySelector('[data-field="model.fallbacks"]')?.value?.trim();
811
+ const fallbacks = fallbacksRaw ? fallbacksRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
812
+
813
+ if (!primary) {
814
+ changes.model = null; // inherit from defaults
815
+ } else {
816
+ changes.model = { primary };
817
+ if (fallbacks.length > 0) changes.model.fallbacks = fallbacks;
818
+ }
819
+ }
820
+
821
+ if (sectionId === 'sandbox') {
822
+ const mode = section.querySelector('[data-field="sandbox.mode"]')?.value;
823
+ if (mode === 'off') {
824
+ changes.sandbox = null; // remove sandbox config
825
+ } else {
826
+ changes.sandbox = {
827
+ mode,
828
+ scope: section.querySelector('[data-field="sandbox.scope"]')?.value || 'session',
829
+ workspaceAccess: section.querySelector('[data-field="sandbox.workspaceAccess"]')?.value || 'rw',
830
+ };
831
+ }
832
+ }
833
+
834
+ if (sectionId === 'tools') {
835
+ const mode = section.querySelector('[data-field="tools.mode"]')?.value;
836
+ if (mode === 'full') {
837
+ changes.tools = null; // remove tool restrictions
838
+ } else {
839
+ const checkedGroups = Array.from(section.querySelectorAll('.agent-tool-group-check:checked')).map(el => el.dataset.group);
840
+ const checkedTools = Array.from(section.querySelectorAll('.agent-tool-item-check:checked')).map(el => el.dataset.tool);
841
+
842
+ // Merge: use group IDs where all tools in group are checked, individual tools otherwise
843
+ const toolList = [...new Set([...checkedGroups, ...checkedTools])];
844
+
845
+ if (mode === 'allow') {
846
+ changes.tools = { allow: toolList };
847
+ } else {
848
+ changes.tools = { deny: toolList };
849
+ }
850
+ }
851
+ }
852
+
853
+ if (sectionId === 'subagents') {
854
+ const allowRaw = section.querySelector('[data-field="subagents.allowAgents"]')?.value?.trim();
855
+ const maxConc = section.querySelector('[data-field="subagents.maxConcurrent"]')?.value?.trim();
856
+ const allowAgents = allowRaw ? allowRaw.split('\n').map(s => s.trim()).filter(Boolean) : [];
857
+
858
+ if (allowAgents.length === 0 && !maxConc) {
859
+ changes.subagents = null;
860
+ } else {
861
+ changes.subagents = {};
862
+ if (allowAgents.length > 0) changes.subagents.allowAgents = allowAgents;
863
+ if (maxConc) changes.subagents.maxConcurrent = parseInt(maxConc, 10);
864
+ }
865
+ }
866
+
867
+ return changes;
868
+ }
869
+
870
+ // ============================================
871
+ // ROUTING / BINDINGS SAVE
872
+ // ============================================
873
+
874
+ function collectBindingsFromEditor(container) {
875
+ const rows = container.querySelectorAll('.agent-binding-row');
876
+ const agentBindings = [];
877
+
878
+ rows.forEach(row => {
879
+ const channel = row.querySelector('.agent-binding-channel')?.value?.trim();
880
+ const accountId = row.querySelector('.agent-binding-account')?.value?.trim();
881
+ const peerKind = row.querySelector('.agent-binding-peer-kind')?.value?.trim();
882
+ const peerId = row.querySelector('.agent-binding-peer-id')?.value?.trim();
883
+ const guildId = row.querySelector('.agent-binding-guild')?.value?.trim();
884
+ const teamId = row.querySelector('.agent-binding-team')?.value?.trim();
885
+
886
+ const match = {};
887
+ if (channel) match.channel = channel;
888
+ if (accountId && accountId !== '*') match.accountId = accountId;
889
+ if (peerKind && peerId) match.peer = { kind: peerKind, id: peerId };
890
+ if (guildId) match.guildId = guildId;
891
+ if (teamId) match.teamId = teamId;
892
+
893
+ agentBindings.push({ agentId: selectedAgentId, match });
894
+ });
895
+
896
+ return agentBindings;
897
+ }
898
+
899
+ async function saveBindings(container) {
900
+ try {
901
+ // Collect the edited bindings for this agent
902
+ const editedBindings = collectBindingsFromEditor(container);
903
+
904
+ // Build the full bindings array: other agents' bindings + this agent's edited ones
905
+ const otherBindings = bindings.filter(b => b.agentId !== selectedAgentId);
906
+ const allBindings = [...otherBindings, ...editedBindings];
907
+
908
+ const result = await AgentsData.saveBindings(allBindings, () => {
909
+ showToast('Config was modified externally. Refreshing...', 'warning');
910
+ syncState();
911
+ rerender();
912
+ });
913
+
914
+ syncState();
915
+ if (result) {
916
+ showToast('Routes updated', 'success');
917
+ }
918
+ return result;
919
+ } catch (err) {
920
+ showToast(`Save failed: ${err.message}`, 'error');
921
+ return false;
922
+ }
923
+ }
924
+
925
+ // ============================================
926
+ // CREATE FORM
927
+ // ============================================
928
+
929
+ function renderCreateForm() {
930
+ const availableModels = defaults.models || [];
931
+ const modelOptions = availableModels.map(m =>
932
+ `<option value="${escapeAttr(m)}">${escapeHtml(m)}</option>`
933
+ ).join('');
934
+
935
+ return `
936
+ <div class="agent-detail">
937
+ <div class="agent-detail-header">
938
+ <button class="agent-detail-back" aria-label="Back to agent list">← Back</button>
939
+ </div>
940
+
941
+ <div class="agent-create-title">New Agent</div>
942
+ <div class="agent-create-warning">
943
+ ⚠️ Creating an agent requires a gateway restart. Active sessions will briefly disconnect.
944
+ </div>
945
+
946
+ <div class="agent-section">
947
+ <div class="agent-section-header"><span class="agent-section-title">Identity</span></div>
948
+ <div class="agent-section-body">
949
+ <div class="agent-edit-field">
950
+ <label class="agent-edit-label">Agent ID <span class="agent-edit-hint">(lowercase, alphanumeric + hyphens, permanent)</span></label>
951
+ <input type="text" class="agent-edit-input" id="newAgentId" placeholder="my-agent" pattern="[a-z0-9][a-z0-9-]*">
952
+ </div>
953
+ <div class="agent-edit-field">
954
+ <label class="agent-edit-label">Display Name</label>
955
+ <input type="text" class="agent-edit-input" id="newAgentName" placeholder="My Agent">
956
+ </div>
957
+ <div class="agent-edit-field">
958
+ <label class="agent-edit-label">Emoji</label>
959
+ <input type="text" class="agent-edit-input agent-edit-input-short" id="newAgentEmoji" placeholder="🤖" maxlength="4">
960
+ </div>
961
+ </div>
962
+ </div>
963
+
964
+ <div class="agent-section">
965
+ <div class="agent-section-header"><span class="agent-section-title">Model</span></div>
966
+ <div class="agent-section-body">
967
+ <div class="agent-edit-field">
968
+ <label class="agent-edit-label">Primary Model</label>
969
+ <select class="agent-edit-select" id="newAgentModel">
970
+ <option value="">Inherit from defaults</option>
971
+ ${modelOptions}
972
+ </select>
973
+ </div>
974
+ </div>
975
+ </div>
976
+
977
+ <div class="agent-section">
978
+ <div class="agent-section-header"><span class="agent-section-title">Workspace</span></div>
979
+ <div class="agent-section-body">
980
+ <div class="agent-edit-field">
981
+ <label class="agent-edit-label">Path <span class="agent-edit-hint">(leave empty for default)</span></label>
982
+ <input type="text" class="agent-edit-input" id="newAgentWorkspace" placeholder="Auto-generated">
983
+ </div>
984
+ </div>
985
+ </div>
986
+
987
+ <div class="agents-list-actions" style="padding-top: 12px;">
988
+ <button class="agents-create-btn agents-create-submit" id="submitCreateAgent">Create Agent & Restart Gateway</button>
989
+ </div>
990
+ </div>
991
+ `;
992
+ }
993
+
994
+ function bindCreateEvents(container) {
995
+ container.querySelector('.agent-detail-back')?.addEventListener('click', () => {
996
+ currentView = 'list';
997
+ rerender();
998
+ });
999
+
1000
+ container.querySelector('#submitCreateAgent')?.addEventListener('click', async () => {
1001
+ const id = container.querySelector('#newAgentId')?.value?.trim();
1002
+ const name = container.querySelector('#newAgentName')?.value?.trim();
1003
+ const emoji = container.querySelector('#newAgentEmoji')?.value?.trim();
1004
+ const model = container.querySelector('#newAgentModel')?.value?.trim();
1005
+ const workspace = container.querySelector('#newAgentWorkspace')?.value?.trim();
1006
+
1007
+ if (!id) {
1008
+ showToast('Agent ID is required', 'error');
1009
+ return;
1010
+ }
1011
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
1012
+ showToast('Agent ID must be lowercase alphanumeric + hyphens', 'error');
1013
+ return;
1014
+ }
1015
+ if (agents.some(a => a.id === id)) {
1016
+ showToast(`Agent "${id}" already exists`, 'error');
1017
+ return;
1018
+ }
1019
+
1020
+ const agent = { id };
1021
+ if (name) agent.name = name;
1022
+ if (emoji || name) {
1023
+ agent.identity = {};
1024
+ if (name) agent.identity.name = name;
1025
+ if (emoji) agent.identity.emoji = emoji;
1026
+ }
1027
+ if (model) agent.model = model;
1028
+ if (workspace) agent.workspace = workspace;
1029
+
1030
+ await createAgent(agent);
1031
+ });
1032
+ }
1033
+
1034
+ // ============================================
1035
+ // DELETE CONFIRMATION
1036
+ // ============================================
1037
+
1038
+ function showDeleteConfirm(agentId, container) {
1039
+ const agent = agents.find(a => a.id === agentId);
1040
+ const name = agent?.identity?.name || agent?.name || agentId;
1041
+
1042
+ const overlay = document.createElement('div');
1043
+ overlay.className = 'agent-confirm-overlay';
1044
+ overlay.innerHTML = `
1045
+ <div class="agent-confirm-dialog">
1046
+ <div class="agent-confirm-title">Delete Agent</div>
1047
+ <div class="agent-confirm-body">
1048
+ Are you sure you want to delete <strong>${escapeHtml(name)}</strong>?
1049
+ <div class="agent-create-warning" style="margin-top:8px;">
1050
+ ⚠️ This requires a gateway restart. Active sessions will briefly disconnect. Any bindings routing to this agent will also be removed.
1051
+ </div>
1052
+ </div>
1053
+ <div class="agent-confirm-actions">
1054
+ <button class="agent-section-btn agent-section-cancel" id="confirmCancel">Cancel</button>
1055
+ <button class="agent-section-btn agent-confirm-delete" id="confirmDelete">Delete & Restart</button>
1056
+ </div>
1057
+ </div>
1058
+ `;
1059
+
1060
+ container.appendChild(overlay);
1061
+
1062
+ overlay.querySelector('#confirmCancel').addEventListener('click', () => overlay.remove());
1063
+ overlay.querySelector('#confirmDelete').addEventListener('click', async () => {
1064
+ overlay.remove();
1065
+ await deleteAgent(agentId);
1066
+ });
1067
+ }
1068
+
1069
+ // ============================================
1070
+ // RESTART OVERLAY
1071
+ // ============================================
1072
+
1073
+ function renderRestartOverlay() {
1074
+ return `
1075
+ <div class="agent-restart-overlay">
1076
+ <div class="agent-restart-content">
1077
+ <span class="agents-loading-dot"></span>
1078
+ <div class="agent-restart-title">Gateway Restarting...</div>
1079
+ <div class="agent-restart-sub">Active sessions will reconnect automatically.</div>
1080
+ </div>
1081
+ </div>
1082
+ `;
1083
+ }
1084
+
1085
+ function startRestartWatcher() {
1086
+ restartPending = true;
1087
+ rerender();
1088
+
1089
+ let attempts = 0;
1090
+ const maxAttempts = 20;
1091
+ const interval = setInterval(async () => {
1092
+ attempts++;
1093
+ try {
1094
+ const response = await fetch('/api/agents', { signal: AbortSignal.timeout(3000) });
1095
+ if (response.ok) {
1096
+ clearInterval(interval);
1097
+ restartPending = false;
1098
+ loaded = false;
1099
+ await fetchAgents();
1100
+ currentView = 'list';
1101
+ selectedAgentId = null;
1102
+ editingSection = null;
1103
+ rerender();
1104
+ showToast('Gateway restarted successfully', 'success');
1105
+ }
1106
+ } catch (e) {
1107
+ // Still down, keep trying
1108
+ }
1109
+
1110
+ if (attempts >= maxAttempts) {
1111
+ clearInterval(interval);
1112
+ restartPending = false;
1113
+ rerender();
1114
+ showToast('Gateway didn\'t come back. Check the terminal.', 'error');
1115
+ }
1116
+ }, 2000);
1117
+ }
1118
+
1119
+ // ============================================
1120
+ // CREATE / DELETE API
1121
+ // ============================================
1122
+
1123
+ async function createAgent(agent) {
1124
+ try {
1125
+ const data = await AgentsData.createAgent(agent);
1126
+ syncState();
1127
+
1128
+ if (data.requiresRestart) {
1129
+ startRestartWatcher();
1130
+ } else {
1131
+ loaded = false;
1132
+ await fetchAgents();
1133
+ currentView = 'list';
1134
+ rerender();
1135
+ showToast(`Agent "${agent.id}" created`, 'success');
1136
+ }
1137
+ } catch (err) {
1138
+ showToast(`Create failed: ${err.message}`, 'error');
1139
+ }
1140
+ }
1141
+
1142
+ async function deleteAgent(agentId) {
1143
+ try {
1144
+ const data = await AgentsData.deleteAgent(agentId);
1145
+ syncState();
1146
+
1147
+ if (data.requiresRestart) {
1148
+ startRestartWatcher();
1149
+ } else {
1150
+ loaded = false;
1151
+ await fetchAgents();
1152
+ currentView = 'list';
1153
+ selectedAgentId = null;
1154
+ rerender();
1155
+ showToast(`Agent "${agentId}" deleted`, 'success');
1156
+ }
1157
+ } catch (err) {
1158
+ showToast(`Delete failed: ${err.message}`, 'error');
1159
+ }
1160
+ }
1161
+
1162
+ // ============================================
1163
+ // HELPERS
1164
+ // ============================================
1165
+
1166
+ function getModelDisplay(agent) {
1167
+ if (!agent.model) {
1168
+ const primary = defaults.model?.primary || defaults.model;
1169
+ if (typeof primary === 'string') return shortenModel(primary);
1170
+ if (primary?.primary) return shortenModel(primary.primary);
1171
+ return 'default';
1172
+ }
1173
+ if (typeof agent.model === 'string') return shortenModel(agent.model);
1174
+ if (agent.model.primary) return shortenModel(agent.model.primary);
1175
+ return 'default';
1176
+ }
1177
+
1178
+ function shortenModel(model) {
1179
+ if (!model) return 'default';
1180
+ const parts = model.split('/');
1181
+ return parts.length > 1 ? parts.slice(1).join('/') : model;
1182
+ }
1183
+
1184
+ function getSandboxBadge(agent) {
1185
+ const sandbox = agent.sandbox || defaults.sandbox;
1186
+ if (!sandbox || sandbox.mode === 'off') return '';
1187
+ if (sandbox.mode === 'all') return '<span class="agent-badge agent-badge-sandbox">sandboxed</span>';
1188
+ if (sandbox.mode === 'non-main') return '<span class="agent-badge agent-badge-partial">partial sandbox</span>';
1189
+ return '';
1190
+ }
1191
+
1192
+ function getToolsSummary(agent) {
1193
+ if (!agent.tools) return '';
1194
+ if (agent.tools.allow && agent.tools.allow.length > 0) {
1195
+ return `${agent.tools.allow.length} allowed`;
1196
+ }
1197
+ if (agent.tools.deny && agent.tools.deny.length > 0) {
1198
+ return `${agent.tools.deny.length} denied`;
1199
+ }
1200
+ return '';
1201
+ }
1202
+
1203
+ // escapeHtml and escapeAttr now imported from AgentsUI module
1204
+
1205
+ // ============================================
1206
+ // EVENT BINDING
1207
+ // ============================================
1208
+
1209
+ function bindBindingRowEvents(container, row) {
1210
+ if (!row) return;
1211
+
1212
+ // Remove button
1213
+ const removeBtn = row.querySelector('.agent-binding-remove');
1214
+ if (removeBtn) {
1215
+ removeBtn.addEventListener('click', () => {
1216
+ row.remove();
1217
+ // If no rows left, show empty message
1218
+ const list = container.querySelector('#bindingsList');
1219
+ if (list && list.querySelectorAll('.agent-binding-row').length === 0) {
1220
+ list.innerHTML = '<div class="agent-detail-muted agent-bindings-empty">No routes — add one below</div>';
1221
+ }
1222
+ });
1223
+ }
1224
+
1225
+ // Peer kind toggle — enable/disable peer ID field
1226
+ const peerKindSelect = row.querySelector('.agent-binding-peer-kind');
1227
+ const peerIdInput = row.querySelector('.agent-binding-peer-id');
1228
+ if (peerKindSelect && peerIdInput) {
1229
+ peerKindSelect.addEventListener('change', () => {
1230
+ peerIdInput.disabled = !peerKindSelect.value;
1231
+ if (!peerKindSelect.value) peerIdInput.value = '';
1232
+ });
1233
+ }
1234
+ }
1235
+
1236
+ function bindListEvents(container) {
1237
+ // Attach avatar error handlers
1238
+ container.querySelectorAll('.agent-list-avatar-img').forEach(img => {
1239
+ img.addEventListener('error', function() {
1240
+ const parent = this.parentElement;
1241
+ const emoji = parent.dataset.fallbackEmoji || '🤖';
1242
+ parent.innerHTML = emoji;
1243
+ });
1244
+ });
1245
+
1246
+ container.querySelectorAll('.agent-item').forEach(item => {
1247
+ item.addEventListener('click', () => {
1248
+ selectedAgentId = item.dataset.agentId;
1249
+ currentView = 'detail';
1250
+ editingSection = null;
1251
+ pendingChanges = {};
1252
+ render(container);
1253
+ });
1254
+ });
1255
+
1256
+ const refreshBtn = container.querySelector('.agents-refresh-btn');
1257
+ if (refreshBtn) {
1258
+ refreshBtn.addEventListener('click', async (e) => {
1259
+ e.stopPropagation();
1260
+ loaded = false;
1261
+ render(container);
1262
+ });
1263
+ }
1264
+
1265
+ const createBtn = container.querySelector('#createAgentBtn');
1266
+ if (createBtn) {
1267
+ createBtn.addEventListener('click', () => {
1268
+ currentView = 'create';
1269
+ render(container);
1270
+ });
1271
+ }
1272
+ }
1273
+
1274
+ function bindDetailEvents(container) {
1275
+ // Back button
1276
+ const backBtn = container.querySelector('.agent-detail-back');
1277
+ if (backBtn) {
1278
+ backBtn.addEventListener('click', () => {
1279
+ currentView = 'list';
1280
+ selectedAgentId = null;
1281
+ editingSection = null;
1282
+ pendingChanges = {};
1283
+ render(container);
1284
+ });
1285
+ }
1286
+
1287
+ // Delete button
1288
+ const deleteBtn = container.querySelector('.agent-detail-delete');
1289
+ if (deleteBtn) {
1290
+ deleteBtn.addEventListener('click', () => {
1291
+ showDeleteConfirm(selectedAgentId, container);
1292
+ });
1293
+ }
1294
+
1295
+ // Hero image load/error handling (no inline handlers — CSP-safe)
1296
+ const heroImg = container.querySelector('.agent-detail-hero-img');
1297
+ const heroFallback = container.querySelector('.agent-detail-hero-fallback');
1298
+ if (heroImg && heroFallback) {
1299
+ heroImg.addEventListener('load', () => {
1300
+ heroImg.style.display = 'block';
1301
+ heroFallback.style.display = 'none';
1302
+ });
1303
+ heroImg.addEventListener('error', () => {
1304
+ heroImg.style.display = 'none';
1305
+ heroFallback.style.display = 'flex';
1306
+ });
1307
+ // If already loaded/errored (cached), handle immediately
1308
+ if (heroImg.complete) {
1309
+ if (heroImg.naturalWidth > 0) {
1310
+ heroImg.style.display = 'block';
1311
+ heroFallback.style.display = 'none';
1312
+ } else {
1313
+ heroImg.style.display = 'none';
1314
+ heroFallback.style.display = 'flex';
1315
+ }
1316
+ }
1317
+ }
1318
+
1319
+ // Avatar upload — hero banner (detail view) or circular widget (list view)
1320
+ const avatarUpload = container.querySelector('.agent-detail-hero') || container.querySelector('.agent-avatar-upload');
1321
+ if (avatarUpload) {
1322
+ const fileInput = avatarUpload.querySelector('.agent-avatar-input');
1323
+ avatarUpload.addEventListener('click', (e) => {
1324
+ // Don't trigger file upload when clicking Back/Delete buttons
1325
+ if (e.target.closest('.agent-detail-back') || e.target.closest('.agent-detail-delete')) return;
1326
+ if (e.target !== fileInput) fileInput.click();
1327
+ });
1328
+ fileInput.addEventListener('change', async () => {
1329
+ const file = fileInput.files[0];
1330
+ if (!file) return;
1331
+ const agentId = avatarUpload.dataset.agentId;
1332
+ try {
1333
+ await AgentsData.uploadAgentAvatar(agentId, file);
1334
+ // Refresh the preview — handle both hero and circular layouts
1335
+ const preview = avatarUpload.querySelector('.agent-detail-hero-img') || avatarUpload.querySelector('.agent-avatar-preview');
1336
+ const fallback = avatarUpload.querySelector('.agent-detail-hero-fallback') || avatarUpload.querySelector('.agent-avatar-fallback');
1337
+ if (preview) {
1338
+ preview.src = `/img/agents/${agentId}.png?t=${Date.now()}`;
1339
+ preview.style.display = 'block';
1340
+ }
1341
+ if (fallback) fallback.style.display = 'none';
1342
+ showToast('Avatar updated', 'success');
1343
+ } catch (err) {
1344
+ showToast('Failed to upload avatar', 'error');
1345
+ }
1346
+ });
1347
+ }
1348
+
1349
+ // Edit / Cancel / Save buttons
1350
+ container.querySelectorAll('.agent-section-btn').forEach(btn => {
1351
+ btn.addEventListener('click', async (e) => {
1352
+ e.stopPropagation();
1353
+ const action = btn.dataset.action;
1354
+ const sectionId = btn.dataset.section;
1355
+
1356
+ if (action === 'edit') {
1357
+ editingSection = sectionId;
1358
+ render(container);
1359
+ } else if (action === 'cancel') {
1360
+ editingSection = null;
1361
+ pendingChanges = {};
1362
+ render(container);
1363
+ } else if (action === 'save') {
1364
+ let ok = false;
1365
+ if (sectionId === 'routing') {
1366
+ // Routing uses a separate save path (PUT /api/bindings)
1367
+ ok = await saveBindings(container);
1368
+ } else {
1369
+ const changes = collectSectionChanges(container, sectionId);
1370
+ if (changes && selectedAgentId) {
1371
+ ok = await saveAgentChanges(selectedAgentId, changes);
1372
+ }
1373
+ }
1374
+ if (ok) {
1375
+ editingSection = null;
1376
+ pendingChanges = {};
1377
+ render(container);
1378
+ }
1379
+ }
1380
+ });
1381
+ });
1382
+
1383
+ // Tools mode toggle — show/hide grid
1384
+ const toolsModeSelect = container.querySelector('#toolsModeSelect');
1385
+ if (toolsModeSelect) {
1386
+ toolsModeSelect.addEventListener('change', () => {
1387
+ const grid = container.querySelector('#toolsGrid');
1388
+ if (grid) {
1389
+ grid.style.display = toolsModeSelect.value === 'full' ? 'none' : '';
1390
+ }
1391
+ });
1392
+ }
1393
+
1394
+ // Routing: Add binding button
1395
+ const addBindingBtn = container.querySelector('#addBindingBtn');
1396
+ if (addBindingBtn) {
1397
+ addBindingBtn.addEventListener('click', () => {
1398
+ const list = container.querySelector('#bindingsList');
1399
+ if (!list) return;
1400
+
1401
+ // Remove empty message if present
1402
+ const emptyMsg = list.querySelector('.agent-bindings-empty');
1403
+ if (emptyMsg) emptyMsg.remove();
1404
+
1405
+ const index = list.querySelectorAll('.agent-binding-row').length;
1406
+ const channelOpts = channels.map(ch =>
1407
+ `<option value="${escapeAttr(ch)}">${escapeHtml(ch)}</option>`
1408
+ ).join('');
1409
+
1410
+ const newBinding = { agentId: selectedAgentId, match: {} };
1411
+ const rowHtml = renderBindingRow(newBinding, index, channelOpts);
1412
+ list.insertAdjacentHTML('beforeend', rowHtml);
1413
+
1414
+ // Bind events for the new row
1415
+ bindBindingRowEvents(container, list.querySelector(`[data-binding-index="${index}"]`));
1416
+ });
1417
+ }
1418
+
1419
+ // Routing: Bind events for existing rows
1420
+ container.querySelectorAll('.agent-binding-row').forEach(row => {
1421
+ bindBindingRowEvents(container, row);
1422
+ });
1423
+
1424
+ // Raw JSON toggle
1425
+ const rawToggle = container.querySelector('.agent-raw-toggle');
1426
+ if (rawToggle) {
1427
+ rawToggle.addEventListener('click', () => {
1428
+ const body = rawToggle.closest('.agent-section').querySelector('.agent-raw-body');
1429
+ const chevron = rawToggle.querySelector('.agent-raw-chevron');
1430
+ if (body) {
1431
+ const isHidden = body.style.display === 'none';
1432
+ body.style.display = isHidden ? '' : 'none';
1433
+ if (chevron) chevron.textContent = isHidden ? '▾' : '▸';
1434
+ }
1435
+ });
1436
+ }
1437
+
1438
+ // Raw JSON copy
1439
+ const rawCopy = container.querySelector('.agent-raw-copy');
1440
+ if (rawCopy) {
1441
+ rawCopy.addEventListener('click', () => {
1442
+ const json = container.querySelector('.agent-raw-json')?.textContent;
1443
+ if (json) {
1444
+ navigator.clipboard.writeText(json).then(() => showToast('Copied to clipboard', 'success'));
1445
+ }
1446
+ });
1447
+ }
1448
+
1449
+ // Tool preset buttons
1450
+ container.querySelectorAll('.agent-preset-btn').forEach(btn => {
1451
+ btn.addEventListener('click', () => {
1452
+ const preset = TOOL_PRESETS[btn.dataset.preset];
1453
+ if (!preset) return;
1454
+
1455
+ const modeSelect = container.querySelector('#toolsModeSelect');
1456
+ const grid = container.querySelector('#toolsGrid');
1457
+
1458
+ if (modeSelect) {
1459
+ modeSelect.value = preset.mode;
1460
+ if (grid) grid.style.display = preset.mode === 'full' ? 'none' : '';
1461
+ }
1462
+
1463
+ // Uncheck everything first
1464
+ container.querySelectorAll('.agent-tool-group-check, .agent-tool-item-check').forEach(cb => { cb.checked = false; });
1465
+
1466
+ // Check the preset items
1467
+ preset.list.forEach(item => {
1468
+ const groupCb = container.querySelector(`.agent-tool-group-check[data-group="${item}"]`);
1469
+ if (groupCb) {
1470
+ groupCb.checked = true;
1471
+ // Also check all items in that group
1472
+ const groupId = groupCb.dataset.group;
1473
+ container.querySelectorAll(`.agent-tool-item-check[data-group-id="${groupId}"]`).forEach(cb => { cb.checked = true; });
1474
+ } else {
1475
+ const toolCb = container.querySelector(`.agent-tool-item-check[data-tool="${item}"]`);
1476
+ if (toolCb) toolCb.checked = true;
1477
+ }
1478
+ });
1479
+
1480
+ // Highlight active preset
1481
+ container.querySelectorAll('.agent-preset-btn').forEach(b => b.classList.remove('active'));
1482
+ btn.classList.add('active');
1483
+ });
1484
+ });
1485
+
1486
+ // Tool group checkboxes — toggle all items in group
1487
+ container.querySelectorAll('.agent-tool-group-check').forEach(groupCheck => {
1488
+ groupCheck.addEventListener('change', () => {
1489
+ const groupId = groupCheck.dataset.group;
1490
+ const items = container.querySelectorAll(`.agent-tool-item-check[data-group-id="${groupId}"]`);
1491
+ items.forEach(item => { item.checked = groupCheck.checked; });
1492
+ });
1493
+ });
1494
+ }
1495
+
1496
+ // ============================================
1497
+ // PUBLIC API
1498
+ // ============================================
1499
+
1500
+ function resetView() {
1501
+ currentView = 'list';
1502
+ selectedAgentId = null;
1503
+ editingSection = null;
1504
+ pendingChanges = {};
1505
+ }
1506
+
1507
+ function refresh() {
1508
+ loaded = false;
1509
+ loading = false;
1510
+ }
1511
+
1512
+ export const UplinkAgents = {
1513
+ render,
1514
+ resetView,
1515
+ refresh,
1516
+ fetchAgents,
1517
+ getAgents: () => agents,
1518
+ getDefaults: () => defaults,
1519
+ isLoaded: () => loaded,
1520
+ };
1521
+
1522
+ // Backward compat: assign to window
1523
+ window.UplinkAgents = UplinkAgents;
1524
+
1525
+ logger.debug('Agents: Module loaded');