ScuttleBot

Merge pull request #145 from ConflictHQ/feature/agent-bulk-delete feat: bulk delete agents + auto-reap UI

noreply 2026-04-05 16:51 trunk merge
Commit 50ba2ecd6edac82c70e9914ba68b5e85a2e56cb773147d072ee084d591b4de43
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -149,10 +149,39 @@
149149
writeError(w, http.StatusInternalServerError, "deletion failed")
150150
return
151151
}
152152
w.WriteHeader(http.StatusNoContent)
153153
}
154
+
155
+// handleBulkDeleteAgents handles POST /v1/agents/bulk-delete.
156
+func (s *Server) handleBulkDeleteAgents(w http.ResponseWriter, r *http.Request) {
157
+ var req struct {
158
+ Nicks []string `json:"nicks"`
159
+ }
160
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
161
+ writeError(w, http.StatusBadRequest, "invalid request body")
162
+ return
163
+ }
164
+ if len(req.Nicks) == 0 {
165
+ writeError(w, http.StatusBadRequest, "nicks list is required")
166
+ return
167
+ }
168
+
169
+ var deleted, failed int
170
+ for _, nick := range req.Nicks {
171
+ if agent, err := s.registry.Get(nick); err == nil {
172
+ s.removeAgentModes(nick, agent.Channels)
173
+ }
174
+ if err := s.registry.Delete(nick); err != nil {
175
+ s.log.Warn("bulk delete: failed", "nick", nick, "err", err)
176
+ failed++
177
+ } else {
178
+ deleted++
179
+ }
180
+ }
181
+ writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted, "failed": failed})
182
+}
154183
155184
func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) {
156185
nick := r.PathValue("nick")
157186
var req struct {
158187
Channels []string `json:"channels"`
159188
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -149,10 +149,39 @@
149 writeError(w, http.StatusInternalServerError, "deletion failed")
150 return
151 }
152 w.WriteHeader(http.StatusNoContent)
153 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
155 func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) {
156 nick := r.PathValue("nick")
157 var req struct {
158 Channels []string `json:"channels"`
159
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -149,10 +149,39 @@
149 writeError(w, http.StatusInternalServerError, "deletion failed")
150 return
151 }
152 w.WriteHeader(http.StatusNoContent)
153 }
154
155 // handleBulkDeleteAgents handles POST /v1/agents/bulk-delete.
156 func (s *Server) handleBulkDeleteAgents(w http.ResponseWriter, r *http.Request) {
157 var req struct {
158 Nicks []string `json:"nicks"`
159 }
160 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
161 writeError(w, http.StatusBadRequest, "invalid request body")
162 return
163 }
164 if len(req.Nicks) == 0 {
165 writeError(w, http.StatusBadRequest, "nicks list is required")
166 return
167 }
168
169 var deleted, failed int
170 for _, nick := range req.Nicks {
171 if agent, err := s.registry.Get(nick); err == nil {
172 s.removeAgentModes(nick, agent.Channels)
173 }
174 if err := s.registry.Delete(nick); err != nil {
175 s.log.Warn("bulk delete: failed", "nick", nick, "err", err)
176 failed++
177 } else {
178 deleted++
179 }
180 }
181 writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted, "failed": failed})
182 }
183
184 func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) {
185 nick := r.PathValue("nick")
186 var req struct {
187 Channels []string `json:"channels"`
188
--- internal/api/server.go
+++ internal/api/server.go
@@ -75,10 +75,11 @@
7575
apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister))
7676
apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate))
7777
apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt))
7878
apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke))
7979
apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete))
80
+ apiMux.HandleFunc("POST /v1/agents/bulk-delete", s.requireScope(auth.ScopeAgents, s.handleBulkDeleteAgents))
8081
8182
// Channels — channels scope (read), chat scope (send).
8283
if s.bridge != nil {
8384
apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels))
8485
apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel))
8586
--- internal/api/server.go
+++ internal/api/server.go
@@ -75,10 +75,11 @@
75 apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister))
76 apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate))
77 apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt))
78 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke))
79 apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete))
 
80
81 // Channels — channels scope (read), chat scope (send).
82 if s.bridge != nil {
83 apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels))
84 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel))
85
--- internal/api/server.go
+++ internal/api/server.go
@@ -75,10 +75,11 @@
75 apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister))
76 apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate))
77 apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt))
78 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke))
79 apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete))
80 apiMux.HandleFunc("POST /v1/agents/bulk-delete", s.requireScope(auth.ScopeAgents, s.handleBulkDeleteAgents))
81
82 // Channels — channels scope (read), chat scope (send).
83 if s.bridge != nil {
84 apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels))
85 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel))
86
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -457,10 +457,11 @@
457457
<option value="revoked">revoked</option>
458458
</select>
459459
<div class="spacer"></div>
460460
<span class="badge" id="agent-count" style="margin-right:4px">0</span>
461461
<button class="sm" onclick="loadAgents()">↻ refresh</button>
462
+ <button class="sm danger" id="bulk-delete-btn" style="display:none" onclick="bulkDeleteAgents()">delete selected</button>
462463
<button class="sm primary" onclick="openDrawer()">+ register agent</button>
463464
</div>
464465
<div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px">
465466
<button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button>
466467
<span id="agent-page-info"></span>
@@ -1677,11 +1678,11 @@
16771678
const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
16781679
const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
16791680
const seen = a.last_seen ? relTime(a.last_seen) : 'never';
16801681
const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
16811682
return `<tr${a.revoked?' style="opacity:0.5"':''}>
1682
- <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
1683
+ <td><input type="checkbox" class="agent-select" value="${esc(a.nick)}" onchange="updateBulkBtn()" style="margin-right:6px">${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
16831684
<td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
16841685
<td>${chs||'<span style="color:#8b949e">—</span>'}</td>
16851686
<td style="white-space:nowrap;${seenStyle}">${seen}</td>
16861687
<td><div class="actions">${!a.revoked?`
16871688
<button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
@@ -1702,10 +1703,27 @@
17021703
async function deleteAgent(nick) {
17031704
if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return;
17041705
try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); }
17051706
catch(e) { alert('Delete failed: '+e.message); }
17061707
}
1708
+function updateBulkBtn() {
1709
+ const checked = document.querySelectorAll('.agent-select:checked');
1710
+ const btn = document.getElementById('bulk-delete-btn');
1711
+ btn.style.display = checked.length > 0 ? '' : 'none';
1712
+ btn.textContent = `delete selected (${checked.length})`;
1713
+}
1714
+async function bulkDeleteAgents() {
1715
+ const nicks = [...document.querySelectorAll('.agent-select:checked')].map(cb => cb.value);
1716
+ if (!nicks.length) return;
1717
+ if (!confirm(`Delete ${nicks.length} agent(s)? This permanently removes them from the registry.\n\n${nicks.join(', ')}`)) return;
1718
+ try {
1719
+ const result = await api('POST', '/v1/agents/bulk-delete', {nicks});
1720
+ await loadAgents();
1721
+ await loadStatus();
1722
+ if (result.failed > 0) alert(`Deleted ${result.deleted}, failed ${result.failed}`);
1723
+ } catch(e) { alert('Bulk delete failed: ' + e.message); }
1724
+}
17071725
async function rotateAgent(nick) {
17081726
try {
17091727
const creds = await api('POST', `/v1/agents/${nick}/rotate`);
17101728
// Show result in whichever drawer is relevant.
17111729
showCredentials(nick, creds, null, 'rotate');
17121730
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -457,10 +457,11 @@
457 <option value="revoked">revoked</option>
458 </select>
459 <div class="spacer"></div>
460 <span class="badge" id="agent-count" style="margin-right:4px">0</span>
461 <button class="sm" onclick="loadAgents()">↻ refresh</button>
 
462 <button class="sm primary" onclick="openDrawer()">+ register agent</button>
463 </div>
464 <div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px">
465 <button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button>
466 <span id="agent-page-info"></span>
@@ -1677,11 +1678,11 @@
1677 const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1678 const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1679 const seen = a.last_seen ? relTime(a.last_seen) : 'never';
1680 const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
1681 return `<tr${a.revoked?' style="opacity:0.5"':''}>
1682 <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
1683 <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
1684 <td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1685 <td style="white-space:nowrap;${seenStyle}">${seen}</td>
1686 <td><div class="actions">${!a.revoked?`
1687 <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
@@ -1702,10 +1703,27 @@
1702 async function deleteAgent(nick) {
1703 if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return;
1704 try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); }
1705 catch(e) { alert('Delete failed: '+e.message); }
1706 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1707 async function rotateAgent(nick) {
1708 try {
1709 const creds = await api('POST', `/v1/agents/${nick}/rotate`);
1710 // Show result in whichever drawer is relevant.
1711 showCredentials(nick, creds, null, 'rotate');
1712
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -457,10 +457,11 @@
457 <option value="revoked">revoked</option>
458 </select>
459 <div class="spacer"></div>
460 <span class="badge" id="agent-count" style="margin-right:4px">0</span>
461 <button class="sm" onclick="loadAgents()">↻ refresh</button>
462 <button class="sm danger" id="bulk-delete-btn" style="display:none" onclick="bulkDeleteAgents()">delete selected</button>
463 <button class="sm primary" onclick="openDrawer()">+ register agent</button>
464 </div>
465 <div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px">
466 <button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button>
467 <span id="agent-page-info"></span>
@@ -1677,11 +1678,11 @@
1678 const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1679 const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1680 const seen = a.last_seen ? relTime(a.last_seen) : 'never';
1681 const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
1682 return `<tr${a.revoked?' style="opacity:0.5"':''}>
1683 <td><input type="checkbox" class="agent-select" value="${esc(a.nick)}" onchange="updateBulkBtn()" style="margin-right:6px">${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
1684 <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
1685 <td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1686 <td style="white-space:nowrap;${seenStyle}">${seen}</td>
1687 <td><div class="actions">${!a.revoked?`
1688 <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
@@ -1702,10 +1703,27 @@
1703 async function deleteAgent(nick) {
1704 if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return;
1705 try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); }
1706 catch(e) { alert('Delete failed: '+e.message); }
1707 }
1708 function updateBulkBtn() {
1709 const checked = document.querySelectorAll('.agent-select:checked');
1710 const btn = document.getElementById('bulk-delete-btn');
1711 btn.style.display = checked.length > 0 ? '' : 'none';
1712 btn.textContent = `delete selected (${checked.length})`;
1713 }
1714 async function bulkDeleteAgents() {
1715 const nicks = [...document.querySelectorAll('.agent-select:checked')].map(cb => cb.value);
1716 if (!nicks.length) return;
1717 if (!confirm(`Delete ${nicks.length} agent(s)? This permanently removes them from the registry.\n\n${nicks.join(', ')}`)) return;
1718 try {
1719 const result = await api('POST', '/v1/agents/bulk-delete', {nicks});
1720 await loadAgents();
1721 await loadStatus();
1722 if (result.failed > 0) alert(`Deleted ${result.deleted}, failed ${result.failed}`);
1723 } catch(e) { alert('Bulk delete failed: ' + e.message); }
1724 }
1725 async function rotateAgent(nick) {
1726 try {
1727 const creds = await api('POST', `/v1/agents/${nick}/rotate`);
1728 // Show result in whichever drawer is relevant.
1729 showCredentials(nick, creds, null, 'rotate');
1730

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button