From e8dc0b465e4938a2147e4a70921edffc3262914f Mon Sep 17 00:00:00 2001 From: Joey Date: Sun, 5 Jul 2026 14:58:12 +0200 Subject: [PATCH] Fix local agent deselection --- docs/index.html | 8 ++--- src/__tests__/local-agent-selection.test.ts | 37 +++++++++++++++++++++ src/agent/local-agents.ts | 13 ++++++++ src/server.ts | 8 +++-- 4 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/local-agent-selection.test.ts diff --git a/docs/index.html b/docs/index.html index 0a686f2..0a9fbc6 100755 --- a/docs/index.html +++ b/docs/index.html @@ -24825,14 +24825,14 @@ Be specific and actionable. Format as structured JSON.`; } }; - async function connect(ids) { - if (!ids.length) { if(window.toast) toast('Tick at least one ready agent first','warning'); return; } + async function connect(ids, replaceSelection) { + if (!ids.length && !replaceSelection) { if(window.toast) toast('Tick at least one ready agent first','warning'); return; } const deep = (document.getElementById('localAgentsDeepPing')||{}).checked; const btn = document.getElementById('localAgentsConnectAllBtn'); const orig = btn ? btn.innerHTML : ''; if (btn) { btn.disabled=true; btn.innerHTML='⚡ Connecting…'; btn.style.opacity='0.7'; } try { - const data = await api('/api/agents/local/connect', { ids: ids, ping: !!deep }); + const data = await api('/api/agents/local/connect', { ids: ids, ping: !!deep, replace: !!replaceSelection }); (data.results||[]).forEach(function(r){ if (r.ping) pingStatus[r.id] = { ok:r.ping.ok, latencyMs:r.ping.latencyMs, error:r.ping.error }; }); render(lastDetected, (data.connected||[]).map(function(c){return c.id;})); persistConnected(connectedIds); // remember for silent auto-reconnect after refresh @@ -24855,7 +24855,7 @@ Be specific and actionable. Format as structured JSON.`; window.connectSelectedLocalAgents = function() { const ids = Array.prototype.slice.call(document.querySelectorAll('.la-sel:checked')).map(function(c){return c.dataset.id;}); - connect(ids); + connect(ids, true); }; window.connectOneLocalAgent = function(id) { connect([id]); }; diff --git a/src/__tests__/local-agent-selection.test.ts b/src/__tests__/local-agent-selection.test.ts new file mode 100644 index 0000000..aa1efa8 --- /dev/null +++ b/src/__tests__/local-agent-selection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { syncLocalAgentSelection } from '../agent/local-agents.js'; + +describe('syncLocalAgentSelection', () => { + it('disconnects agents omitted from the bulk selection', () => { + const connected = new Map([ + ['codex', { id: 'codex' }], + ['hermes', { id: 'hermes' }], + ]); + + syncLocalAgentSelection(connected, ['hermes'], true); + + expect([...connected.keys()]).toEqual(['hermes']); + }); + + it('preserves existing agents for an additive single-agent connect', () => { + const connected = new Map([ + ['codex', { id: 'codex' }], + ['hermes', { id: 'hermes' }], + ]); + + syncLocalAgentSelection(connected, ['hermes'], false); + + expect([...connected.keys()]).toEqual(['codex', 'hermes']); + }); + + it('disconnects every agent when the bulk selection is empty', () => { + const connected = new Map([ + ['codex', { id: 'codex' }], + ['hermes', { id: 'hermes' }], + ]); + + syncLocalAgentSelection(connected, [], true); + + expect(connected.size).toBe(0); + }); +}); diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index 4899c72..ace7659 100644 --- a/src/agent/local-agents.ts +++ b/src/agent/local-agents.ts @@ -71,6 +71,19 @@ function childEnv(): NodeJS.ProcessEnv { export type LocalAgentId = 'claude' | 'codex' | 'hermes'; +/** Apply an authoritative bulk selection; single-agent connects remain additive. */ +export function syncLocalAgentSelection( + connected: Map, + selectedIds: string[], + replace: boolean, +): void { + if (!replace) return; + const selected = new Set(selectedIds); + for (const id of connected.keys()) { + if (!selected.has(id)) connected.delete(id); + } +} + interface AgentSpec { id: LocalAgentId; label: string; diff --git a/src/server.ts b/src/server.ts index 4fd8f52..e43a86e 100755 --- a/src/server.ts +++ b/src/server.ts @@ -22,7 +22,7 @@ import { LLMBackbone } from './llm/index.js'; import { TempestCommand } from './index.js'; import { OpGeneral } from './general/index.js'; import type { Directive } from './general/index.js'; -import { detectLocalAgents, pingLocalAgent, runLocalAgent } from './agent/local-agents.js'; +import { detectLocalAgents, pingLocalAgent, runLocalAgent, syncLocalAgentSelection } from './agent/local-agents.js'; import { FRONTIER_ARSENAL_MILESTONE, NETWORK_COMMANDS, SAFE_COMMANDS, TOOL_ADAPTERS, adapterForBinary, adaptersForFamily, summarizeToolCatalog } from './arsenal/catalog.js'; import { AGENT_PROMPT_PACKS, FOREFRONT_PRESSURE_LANES, OPERATOR_RUNBOOKS, RESOURCE_PACKS, WORKFLOW_PRESETS, forefrontPressureForFamily, promptPacksForFamily, resourcesForFamily, runbookForFamily, searchResources, workflowPresetsForFamily } from './resources/index.js'; import { AI_REDTEAM_PLAYBOOK, AI_REDTEAM_TECHNIQUE_IDS, aiRedTeamBriefing } from './resources/ai-redteam-playbook.js'; @@ -7304,12 +7304,13 @@ app.get('/api/agents/local/detect', async (_req: Request, res: Response): Promis } }); -// POST /api/agents/local/connect { ids:[...] | id, ping?:bool } — enlist one or many; optional round-trip +// POST /api/agents/local/connect { ids:[...] | id, ping?:bool, replace?:bool } — enlist one or many; optional round-trip app.post('/api/agents/local/connect', async (req: Request, res: Response): Promise => { const body = req.body || {}; const ids: string[] = Array.isArray(body.ids) ? body.ids : (body.id ? [body.id] : []); const doPing: boolean = body.ping === true; - if (!ids.length) { res.status(400).json({ error: 'provide ids:[] or id' }); return; } + const replace: boolean = body.replace === true; + if (!ids.length && !replace) { res.status(400).json({ error: 'provide ids:[] or id' }); return; } const detected = await detectLocalAgents(); const results: Array> = []; for (const id of ids) { @@ -7321,6 +7322,7 @@ app.post('/api/agents/local/connect', async (req: Request, res: Response): Promi connectedLocalAgents.set(id, { id, label: d.label, version: d.version, connectedAt: Date.now(), lastPing: ping }); results.push({ id, status: 'active', label: d.label, version: d.version, authMethod: d.authMethod, ping }); } + syncLocalAgentSelection(connectedLocalAgents, ids, replace); res.json({ results, connected: Array.from(connectedLocalAgents.values()) }); });