feat(llm): add Hugging Face provider + selectable local Claude Code models (#127)

* feat(llm): add Hugging Face provider + selectable local Claude Code models

Adds two operator-selectable LLM backbones, wired through both the backend
and the War Room UI.

Hugging Face (OpenAI-compatible Inference Providers router):
- New `huggingface` provider reusing OpenAIAdapter (router.huggingface.co/v1)
- HF_TOKEN / HUGGINGFACE_API_KEY / HUGGINGFACE_TOKEN env resolution, config,
  setup wizard, CLI, factory, best-available, and .env.example
- Settings key card + 4 inference models in the picker + direct client routing
- Tests: src/__tests__/huggingface-provider.test.ts

Local Claude from Claude Code:
- Explicit "active backbone" pin in Settings so a connected agent (Claude Code)
  can be chosen; a pin outranks stored API keys in both the client resolver and
  mission dispatch
- Claude Code model picker (Account default / Opus / Sonnet / Haiku), threaded
  to the CLI's --model via an "agentId::model" encoding parsed by LocalAgentAdapter;
  requireLiveLocalAgent strips the suffix for connected-agent validation

* fix(llm): address PR #127 review — self-contained keyless path + centralized pin/model routing

Finding 1 — keyless mission path was not self-contained:
- Add the `local-agent` case to ConfigManager.getLLMConfig so provider:'local-agent'
  resolves (keyless, agent id — optionally "agentId::model" — in `model`) instead of
  hitting the default "Unknown provider" throw.

Finding 2 — inconsistent pin/model routing across dispatch surfaces:
- Centralize backend agent resolution in window.t3mpDispatchAgent (single source of truth).
  It honors an explicit pin OVER a stored API key and encodes the picked local model as
  "agentId::model" so it is never dropped.
- Route all three surfaces through it: mission dispatch, General (getGeneralConfig), and
  Admiral (_admBackbone). Previously General/Admiral only picked an agent when no key
  existed and sent the bare agent id, so a stored key overrode the pin and the selected
  Claude model was lost.

Tests:
- getLLMConfig('local-agent') regression coverage (self-contained keyless path)
- LocalAgentAdapter "agentId::model" passthrough reaches the CLI --model flag
- t3mpDispatchAgent behavior: pin>key precedence, model encoding, fallbacks (evaluated
  from the shipped docs/index.html)
- centralization invariant: mission/General/Admiral all route through t3mpDispatchAgent
- update the Op-Admiral keyless-routing static test to the centralized resolver

---------

Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
This commit is contained in:
2alfandJoseph Magly authored and GitHub committed 2026-07-27 15:47:06 -04:00
1 parent e52c2fb2b7
commit 8ccea7c82a
13 files changed
+630 -44

No files matched your search

+175 -26
View File
@@ -5680,6 +5680,18 @@
<button class="btn btn-primary btn-sm" onclick="saveApiKey('venice')">Save</button>
</div>
<div class="card">
<h4 style="margin-bottom: 12px; font-size: 14px;">Hugging Face Token</h4>
<div class="form-group">
<div class="api-key-input">
<input type="password" class="form-input" id="huggingfaceKey" placeholder="hf_...">
<button class="api-key-toggle" onclick="toggleApiKey('huggingfaceKey')">Show</button>
</div>
<div class="form-hint">Open models via the OpenAI-compatible Inference Providers router. Get a token from <a href="https://huggingface.co/settings/tokens" target="_blank" style="color: var(--accent);">huggingface.co/settings/tokens</a></div>
</div>
<button class="btn btn-primary btn-sm" onclick="saveApiKey('huggingface')">Save</button>
</div>
<div class="card">
<h4 style="margin-bottom: 12px; font-size: 14px;">Anthropic API Key</h4>
<div class="form-group">
@@ -5779,7 +5791,7 @@
<div class="settings-section">
<h2 class="settings-title">🔌 Local Agents</h2>
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 12px;">Enlist agents you've already authed on this machine — <b style="color:#bcd;">no API keys needed</b>. T3MP3ST detects each CLI and can drive it as an operator. Tick several and connect them all at once.</p>
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 12px;">Enlist agents you've already authed on this machine — <b style="color:#bcd;">no API keys needed</b>. T3MP3ST detects each CLI (<b style="color:#bcd;">Claude Code</b> / Codex / Hermes) and drives it as your LLM backbone using its own login. Connect one, then hit <b style="color:#2fd6ff;">☆ Use</b> to pin it as the <b style="color:#2fd6ff;">★ active backbone</b> for missions and analysis — a pinned local model outranks any stored API key.</p>
<div id="localAgentsCard" style="padding: 18px 20px; background: linear-gradient(135deg, rgba(0,200,255,0.06), rgba(160,90,255,0.04)); border: 1px solid rgba(0,200,255,0.25); border-radius: 12px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<div style="font-size: 15px; font-weight: 800; color: #2fd6ff; letter-spacing: 0.4px; display:flex; align-items:center; gap:8px;">🔌 Connect Local Agents</div>
@@ -5788,6 +5800,15 @@
<div id="localAgentsList" style="display:grid; gap:8px; margin:14px 0 4px;">
<div style="color:#5f7079; font-size:12px; padding:8px 0;">Detecting local agents…</div>
</div>
<!-- Claude Code model picker — only shown once Claude Code is connected -->
<div id="claudeModelPicker" style="display:none; align-items:center; gap:10px; margin:10px 0 2px; padding:10px 12px; background:rgba(255,140,40,0.06); border:1px solid rgba(255,140,40,0.28); border-radius:8px; flex-wrap:wrap;">
<span style="font-size:18px;">🟠</span>
<div style="flex:1; min-width:160px;">
<div style="font-size:12.5px; font-weight:700; color:#ffd0a8;">Claude Code model</div>
<div style="font-size:10.5px; color:#a78b76;">Which local Claude model to run (passed to <code style="color:#ffb37a;">claude --model</code>)</div>
</div>
<select id="claudeModelSelect" onchange="setClaudeModel(this.value)" style="padding:8px 10px; font-size:12px; background:rgba(0,0,0,0.35); border:1px solid rgba(255,140,40,0.4); border-radius:6px; color:#ffe6d2; font-family:'JetBrains Mono',monospace; cursor:pointer; min-width:200px;"></select>
</div>
<div style="display:flex; gap:10px; margin-top:12px; flex-wrap:wrap; align-items:center;">
<button id="localAgentsConnectAllBtn" onclick="connectSelectedLocalAgents()" style="padding:11px 22px; font-size:13px; font-weight:800; background:linear-gradient(135deg,#2fd6ff,#5b8cff); border:none; border-radius:8px; color:#001624; cursor:pointer; letter-spacing:0.4px; box-shadow:0 4px 14px rgba(0,200,255,0.28);">⚡ Connect Selected</button>
<button id="localAgentsRefreshBtn" onclick="detectLocalAgents()" style="padding:11px 18px; font-size:13px; font-weight:600; background:rgba(255,255,255,0.05); border:1px solid rgba(0,200,255,0.3); border-radius:8px; color:#bcd; cursor:pointer;">↻ Re-scan</button>
@@ -7230,6 +7251,11 @@ Correlating findings across agents, identifying patterns, producing actionable i
{ id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'DeepSeek' },
// Mistral
{ id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'Mistral' },
// Hugging Face — open models via the OpenAI-compatible Inference Providers router (full HF repo ids)
{ id: 'meta-llama/Llama-3.3-70B-Instruct', name: 'Llama 3.3 70B Instruct (HF)', provider: 'HuggingFace' },
{ id: 'Qwen/Qwen2.5-72B-Instruct', name: 'Qwen2.5 72B Instruct (HF)', provider: 'HuggingFace' },
{ id: 'deepseek-ai/DeepSeek-R1', name: 'DeepSeek R1 (HF)', provider: 'HuggingFace' },
{ id: 'mistralai/Mistral-Small-24B-Instruct-2501', name: 'Mistral Small 24B (HF)', provider: 'HuggingFace' },
// Local
{ id: 'local/ollama', name: 'Local (Ollama)', provider: 'Local' }
];
@@ -7298,6 +7324,7 @@ Correlating findings across agents, identifying patterns, producing actionable i
const veKey = document.getElementById('veniceKey');
const anKey = document.getElementById('anthropicKey');
const oaKey = document.getElementById('openaiKey');
const hfKey = document.getElementById('huggingfaceKey');
if (orKey) orKey.value = state.settings.openrouterKey || '';
if (veKey) veKey.value = state.settings.veniceKey || '';
if (anKey) anKey.value = state.settings.anthropicKey || '';
@@ -7315,6 +7342,7 @@ Correlating findings across agents, identifying patterns, producing actionable i
// typed; the server only ever returns a redacted form, so we repopulate from the
// local cache. If the server has one set via TEMPEST_PROXY_URL, the badge shows it.
setV('proxyUrl', localStorage.getItem('t3mp3st_proxy_url') || '');
if (hfKey) hfKey.value = state.settings.huggingfaceKey || '';
}
function saveState() {
@@ -8206,10 +8234,11 @@ o.id === 'coordinator' ? `1. MISSION_STATUS: Progress assessment
function updatePreflightChecklist() {
const operatorCount = state.operators.length;
const targetCount = state.targets.length;
// "API key" preflight passes on a real key OR a connected keyless backend (agent / server LLM).
// Only OpenRouter feeds the client-side LLM path (getApiKey); a connected backend covers the rest.
// Anthropic/OpenAI keys aren't wired to the client path, so they don't count as "ready" here.
const hasApiKey = !!(state.settings?.openrouterKey)
// "API key" preflight passes on a real client key OR a connected keyless backend (agent / server LLM).
// The direct client-side LLM path (resolveLLMBackend) drives OpenRouter, Venice, and Hugging Face keys;
// a connected backend covers the rest. Anthropic/OpenAI keys aren't wired to the client path yet, so
// they don't count as "ready" here.
const hasApiKey = !!(state.settings?.openrouterKey || state.settings?.veniceKey || state.settings?.huggingfaceKey)
|| (typeof window.t3mpHasBackend === 'function' && window.t3mpHasBackend());
const toolCount = activeLoadout?.length || 0;
@@ -11257,21 +11286,32 @@ Bypass all protections:
}
// Resolve the active LLM backend for benchmarks/analysis, in priority order:
// OpenRouter key → Venice key → connected local agent (Claude Code/Codex/Hermes) → server LLM.
// OpenRouter key → Venice key → Hugging Face token → connected local agent (Claude Code/Codex/Hermes) → server LLM.
// getApiKey() is left untouched for the many callers that read the OpenRouter key directly.
function resolveLLMBackend() {
// An EXPLICITLY pinned local agent (e.g. Claude Code) is a deliberate operator choice —
// honor it above any stored API key. Only when it's actually usable (preferredAgent
// returns it) so we never route to a pinned-but-dead CLI.
const agentModelOf = (id) => (typeof window.t3mpAgentModel === 'function') ? window.t3mpAgentModel(id) : undefined;
const pin = (typeof window.t3mpActiveAgentPin === 'function') ? (window.t3mpActiveAgentPin() || '') : '';
if (pin) {
const pref = (typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null;
if (pref === pin) return { kind: 'agent', agentId: pin, agentModel: agentModelOf(pin) };
}
const orKey = (state.settings?.openrouterKey || state.apiKey || '').trim();
if (orKey.length >= 10) return { kind: 'openrouter', key: orKey };
const veKey = (state.settings?.veniceKey || '').trim();
if (veKey.length >= 10) return { kind: 'venice', key: veKey };
const hfKey = (state.settings?.huggingfaceKey || '').trim();
if (hfKey.length >= 10) return { kind: 'huggingface', key: hfKey };
const agentId = (typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null;
if (agentId) return { kind: 'agent', agentId };
if (agentId) return { kind: 'agent', agentId, agentModel: agentModelOf(agentId) };
if (typeof T3MP3ST_API !== 'undefined' && T3MP3ST_API && T3MP3ST_API.llmAvailable) return { kind: 'server' };
return { kind: 'none' };
}
window.resolveLLMBackend = resolveLLMBackend;
const LLM_BACKEND_HINT = 'No LLM backend — add an OpenRouter or Venice key in Settings, or connect a local agent (Claude Code / Codex / Hermes)';
const LLM_BACKEND_HINT = 'No LLM backend — add an OpenRouter, Venice, or Hugging Face key in Settings, or connect a local agent (Claude Code / Codex / Hermes)';
// Flatten a messages array (or string) to plain text for the agent/server endpoints.
function promptToText(prompt) {
@@ -11287,6 +11327,16 @@ Bypass all protections:
// Venice is OpenAI-compatible but rejects OpenRouter-style ids ("vendor/model") — map to a Venice model.
function veniceModel(m) { return (!m || m.indexOf('/') >= 0) ? 'llama-3.3-70b' : m; }
// Hugging Face's router expects a full HF repo id (org/repo). OpenRouter-style ids
// ("anthropic/claude-...", "openai/...") aren't served there, so only pass through
// known HF repo ids from the model list; otherwise use a solid open-weights default.
const HF_DEFAULT_MODEL = 'meta-llama/Llama-3.3-70B-Instruct';
function huggingfaceModel(m) {
if (!m) return HF_DEFAULT_MODEL;
const known = MODELS.some(x => x.provider === 'HuggingFace' && x.id === m);
return known ? m : HF_DEFAULT_MODEL;
}
// Shared safe LLM call — resolves the backend, then routes the call. Keyless backends
// (connected agent / server LLM) run single-shot through the server; key backends
// (OpenRouter / Venice) call the provider directly and keep the model-fallback chain.
@@ -11309,6 +11359,8 @@ Bypass all protections:
let models;
if (backend.kind === 'venice') {
models = [veniceModel(options.model || state.settings?.selectedModel)];
} else if (backend.kind === 'huggingface') {
models = [huggingfaceModel(options.model || state.settings?.selectedModel)];
} else {
models = options._noFallback ? [primaryModel]
: (primaryModel === fallbackModel ? [primaryModel] : [primaryModel, fallbackModel]);
@@ -11545,9 +11597,12 @@ Bypass all protections:
// A bare '/api/...' would hit the Pages origin (404/CORS). Empty base = same-origin (stays relative).
const base = (typeof T3MP3ST_API !== 'undefined' && T3MP3ST_API && T3MP3ST_API.baseUrl) || '';
if (backend.kind === 'agent') {
// Prefer the operator's picked local model (e.g. Claude Code → opus/sonnet/haiku); fall
// back to options.model only when no local model is pinned for this agent.
const agentModel = (backend.agentModel != null && backend.agentModel !== '') ? backend.agentModel : options.model;
const res = await fetch(base + '/api/agents/local/dispatch', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: backend.agentId, prompt: text, model: options.model, timeoutMs: timeout }),
body: JSON.stringify({ id: backend.agentId, prompt: text, model: agentModel, timeoutMs: timeout }),
signal: AbortSignal.timeout(timeout + 10000)
});
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`Agent dispatch ${res.status}: ${t.substring(0, 200)}`); }
@@ -11572,6 +11627,8 @@ Bypass all protections:
addIntel('API', `→ ${model} (${backend.kind})`, 'info');
const endpoint = backend.kind === 'venice'
? 'https://api.venice.ai/api/v1/chat/completions'
: backend.kind === 'huggingface'
? 'https://router.huggingface.co/v1/chat/completions'
: 'https://openrouter.ai/api/v1/chat/completions';
const headers = { 'Authorization': `Bearer ${backend.key}`, 'Content-Type': 'application/json' };
if (backend.kind === 'openrouter') { headers['HTTP-Referer'] = window.location.href; headers['X-Title'] = options.title || 'T3MP3ST'; }
@@ -17102,14 +17159,17 @@ ACTION_INPUT: {"output":"Found confirmed SQLi on /login endpoint..."}`;
}
const apiKey = getApiKey();
const savedProvider = localStorage.getItem('t3mp3st_general_provider');
const connectedAgent = (!apiKey && typeof window.t3mpPreferredAgent === 'function')
? window.t3mpPreferredAgent()
: null;
const provider = savedProvider
|| (connectedAgent ? 'local-agent' : undefined)
|| (T3MP3ST_API?.connected && !T3MP3ST_API?.llmAvailable ? 'codex' : undefined);
// Centralized resolution: an explicit pin (e.g. Claude Code) outranks a stored key AND a
// remembered general-provider value, and the picked Claude model rides along as
// "agentId::model" — identical precedence to the mission path.
const dispatchAgent = (typeof window.t3mpDispatchAgent === 'function') ? window.t3mpDispatchAgent(apiKey) : null;
const explicitPin = (typeof window.t3mpActiveAgentPin === 'function') ? (window.t3mpActiveAgentPin() || '') : '';
const provider = (explicitPin && dispatchAgent) ? 'local-agent'
: (savedProvider
|| (dispatchAgent ? 'local-agent' : undefined)
|| (T3MP3ST_API?.connected && !T3MP3ST_API?.llmAvailable ? 'codex' : undefined));
const model = provider === 'local-agent'
? connectedAgent
? (dispatchAgent ? dispatchAgent.model : (typeof window.t3mpPreferredAgent === 'function' ? window.t3mpPreferredAgent() : undefined))
: (provider === 'codex' ? 'codex-default' : (provider ? (state.settings?.selectedModel || 'anthropic/claude-sonnet-4') : undefined));
return { apiKey, provider, model };
}
@@ -19631,12 +19691,17 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
addIntel('BACKEND', `Local model mode — dispatching mission through local LLM (${model})`, 'success');
addMissionLog('info', `LLM backend: local (${model}) — no API key needed`);
} else {
const agentBackend = (!apiKey && typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null;
if (!apiKey && agentBackend) {
// Centralized agent resolution (shared with General + Admiral): an EXPLICIT pin
// (e.g. Claude Code) outranks a stored key, and the picked model rides along as
// "agentId::model" so the mission runs THAT model.
const dispatchAgent = (typeof window.t3mpDispatchAgent === 'function') ? window.t3mpDispatchAgent(apiKey) : null;
if (dispatchAgent) {
provider = 'local-agent';
model = agentBackend; // the agent id (codex|claude|hermes) travels in `model`
addIntel('BACKEND', `No API key — routing the mission through your connected agent: ${agentBackend.toUpperCase()}`, 'success');
addMissionLog('info', `LLM backend: local agent (${agentBackend}) — no API key needed`);
model = dispatchAgent.model;
const picked = dispatchAgent.model.includes('::') ? dispatchAgent.model.split('::')[1] : '';
const shownModel = picked ? ` · model: ${picked}` : '';
addIntel('BACKEND', `Routing the mission through your connected local agent: ${dispatchAgent.id.toUpperCase()}${shownModel}`, 'success');
addMissionLog('info', `LLM backend: local agent (${dispatchAgent.id})${shownModel} — no API key needed`);
} else if (!apiKey) {
addIntel('BACKEND', 'No browser API key — using the server-configured LLM backend', 'success');
addMissionLog('info', 'LLM backend: server default — no browser API key sent');
@@ -26243,6 +26308,25 @@ Be specific and actionable. Format as structured JSON.`;
let connectedIds = [];
const pingStatus = {}; // id -> { ok, latencyMs, error } from a real round-trip
// Explicit operator choice of WHICH connected agent to drive as the LLM backbone. Persisted so
// a refresh keeps it. When set, preferredAgent() honors it over the codex→hermes→claude default
// order — this is how "use my local Claude from Claude Code" becomes a first-class, sticky choice.
function getActiveAgent(){ try { return localStorage.getItem('t3mp3st_active_agent') || ''; } catch(e) { return ''; } }
function setActiveAgentStored(id){ try { if (id) localStorage.setItem('t3mp3st_active_agent', id); else localStorage.removeItem('t3mp3st_active_agent'); } catch(e) {} }
// Which Claude model the local Claude Code CLI should run (passed through as its --model flag).
// '' = the CLI's own account default. Claude Code accepts the aliases opus/sonnet/haiku or a full id.
const CLAUDE_MODELS = [
{ id: '', label: 'Account default (Claude Code setting)' },
{ id: 'opus', label: 'Claude Opus (latest)' },
{ id: 'sonnet', label: 'Claude Sonnet (latest)' },
{ id: 'haiku', label: 'Claude Haiku (latest, fast)' },
];
function getClaudeModel(){ try { return localStorage.getItem('t3mp3st_claude_model') || ''; } catch(e) { return ''; } }
function setClaudeModelStored(m){ try { if (m) localStorage.setItem('t3mp3st_claude_model', m); else localStorage.removeItem('t3mp3st_claude_model'); } catch(e) {} }
// The underlying model to run for a given agent id — only Claude Code exposes a picker today.
function agentModelFor(id){ return id === 'claude' ? (getClaudeModel() || undefined) : undefined; }
function pill(text, color) {
const c = { green:['rgba(0,255,136,0.16)','#00ff88','rgba(0,255,136,0.4)'],
amber:['rgba(255,170,0,0.16)','#ffaa00','rgba(255,170,0,0.4)'],
@@ -26264,18 +26348,20 @@ Be specific and actionable. Format as structured JSON.`;
else if (connected) { status='● Connected · unchecked'; color='amber'; }
else { status='Detected'; color='cyan'; }
const disabled = !a.ready;
const borderColor = stale ? 'rgba(255,80,80,0.4)' : live ? 'rgba(0,255,136,0.35)' : connected ? 'rgba(255,170,0,0.28)' : 'rgba(0,200,255,0.14)';
const isActive = connected && getActiveAgent() === a.id;
const borderColor = isActive ? 'rgba(0,200,255,0.6)' : stale ? 'rgba(255,80,80,0.4)' : live ? 'rgba(0,255,136,0.35)' : connected ? 'rgba(255,170,0,0.28)' : 'rgba(0,200,255,0.14)';
const sub = !a.installed ? 'binary not found on PATH'
: stale ? ('⚠ '+(ps.error ? ps.error.slice(0,70) : 'authentication failed — re-login this CLI'))
: (a.invokeHint || a.bin);
return '<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;background:rgba(255,255,255,0.025);border-radius:8px;border:1px solid '+borderColor+';">'
return '<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;background:'+(isActive?'rgba(0,200,255,0.06)':'rgba(255,255,255,0.025)')+';border-radius:8px;border:1px solid '+borderColor+';">'
+ '<input type="checkbox" class="la-sel" data-id="'+a.id+'" '+(connected?'checked':'')+' '+(disabled?'disabled':'')+' style="accent-color:#2fd6ff;width:15px;height:15px;'+(disabled?'opacity:0.3;':'')+'">'
+ '<span style="font-size:18px;">'+(ICONS[a.id]||'🤖')+'</span>'
+ '<div style="flex:1;min-width:0;">'
+ '<div style="font-size:13.5px;font-weight:700;color:#dde6ea;">'+a.label+' <span style="font-size:10.5px;color:#6b7a85;font-weight:500;">'+a.vendor+(a.version?' · v'+a.version:'')+'</span></div>'
+ '<div style="font-size:13.5px;font-weight:700;color:#dde6ea;">'+a.label+' <span style="font-size:10.5px;color:#6b7a85;font-weight:500;">'+a.vendor+(a.version?' · v'+a.version:'')+'</span>'+(isActive?' <span style="font-size:9.5px;color:#2fd6ff;font-weight:700;">★ ACTIVE BACKBONE</span>':'')+'</div>'
+ '<div style="font-size:10.5px;color:'+(stale?'#ff8a8a':'#67767f')+';font-family:\'JetBrains Mono\',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'+sub+'</div>'
+ '</div>'
+ pill(status, color)
+ (connected ? '<button onclick="setActiveLocalAgent(\''+a.id+'\')" title="Drive this agent as the LLM backbone for missions and analysis" style="padding:6px 11px;font-size:11px;font-weight:700;background:'+(isActive?'linear-gradient(135deg,#2fd6ff,#5b8cff)':'rgba(255,255,255,0.06)')+';border:1px solid '+(isActive?'transparent':'rgba(0,200,255,0.3)')+';border-radius:6px;color:'+(isActive?'#001624':'#bcd')+';cursor:pointer;white-space:nowrap;">'+(isActive?'★ Active':'☆ Use')+'</button>' : '')
+ (a.ready ? '<button onclick="pingOneLocalAgent(\''+a.id+'\')" title="send a real one-shot to verify it actually responds (spends a little quota)" style="padding:6px 11px;font-size:11px;font-weight:600;background:rgba(255,255,255,0.06);border:1px solid rgba(0,200,255,0.3);border-radius:6px;color:#bcd;cursor:pointer;white-space:nowrap;">Test</button>' : '')
+ (a.ready ? '<button onclick="connectOneLocalAgent(\''+a.id+'\')" style="padding:6px 13px;font-size:11px;font-weight:700;background:'+(connected?'rgba(var(--brand-rgb),0.12)':'linear-gradient(135deg,#2fd6ff,#5b8cff)')+';border:'+(connected?'1px solid rgba(var(--brand-rgb),0.4)':'none')+';border-radius:6px;color:'+(connected?'var(--brand)':'#001624')+';cursor:pointer;white-space:nowrap;">'+(connected?'✓ Connected':'Connect')+'</button>' : '')
+ '</div>';
@@ -26311,8 +26397,28 @@ Be specific and actionable. Format as structured JSON.`;
// fresh successful ping proves the local model is currently usable.
window.__t3mpLiveAgents = live; window.__t3mpConnectedAgents = connectedIds.length;
if (live > 0) { const nb = document.getElementById('noBackendNudge'); if (nb) nb.style.display = 'none'; }
renderClaudeModelPicker();
}
// Populate + show the Claude Code model dropdown, but only when Claude Code is actually connected.
function renderClaudeModelPicker(){
const wrap = document.getElementById('claudeModelPicker');
const sel = document.getElementById('claudeModelSelect');
if (!wrap || !sel) return;
const claudeConnected = connectedIds.indexOf('claude') !== -1;
wrap.style.display = claudeConnected ? 'flex' : 'none';
if (!claudeConnected) return;
const cur = getClaudeModel();
sel.innerHTML = CLAUDE_MODELS.map(function(m){
return '<option value="'+m.id+'"'+(m.id === cur ? ' selected' : '')+'>'+m.label+'</option>';
}).join('');
}
window.setClaudeModel = function(m){
setClaudeModelStored(m);
const label = (CLAUDE_MODELS.find(function(x){ return x.id === m; }) || {}).label || (m || 'default');
if (window.toast) toast('Claude model → ' + label, 'success');
};
// ── remember connected agents + silently re-establish them after a refresh ──
function persistConnected(ids){ try { localStorage.setItem('t3mp3st_connected_agents', JSON.stringify(ids || [])); } catch(e) {} }
// null = user never made a choice yet; [] = user explicitly disconnected everything (must stick).
@@ -26361,6 +26467,9 @@ Be specific and actionable. Format as structured JSON.`;
(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
// No backbone pinned yet? Adopt a freshly-connected agent as the active one so a single
// "Connect Claude Code" is enough to route missions through it (no extra click needed).
if (!getActiveAgent() && connectedIds.length) { setActiveAgentStored(connectedIds[0]); render(lastDetected); }
const stale = (data.results||[]).filter(function(r){return r.ping && !r.ping.ok;}).length;
const ok = (data.results||[]).filter(function(r){return r.status==='active';}).length;
if (window.toast) toast(ok+' agent(s) connected'+(stale?' · '+stale+' failed live-check (re-login)':''), stale?'warning':'success');
@@ -26384,6 +26493,17 @@ Be specific and actionable. Format as structured JSON.`;
};
window.connectOneLocalAgent = function(id) { connect([id]); };
// Pin (or unpin) which connected agent drives missions + analysis as the LLM backbone.
// Clicking the active one again clears the pin (back to the auto codex→hermes→claude order).
window.setActiveLocalAgent = function(id) {
const next = (getActiveAgent() === id) ? '' : id;
setActiveAgentStored(next);
render(lastDetected);
const label = ((lastDetected || []).find(function(a){ return a.id === id; }) || {}).label || id;
if (window.toast) toast(next ? ('Backbone → ' + label + ' (local)') : 'Backbone pin cleared — auto-selecting', next ? 'success' : 'info');
if (typeof updatePreflightChecklist === 'function') { try { updatePreflightChecklist(); } catch(e) {} }
};
// ── backend gate + Settings routing (agent auth now lives in Settings; the War Room only nudges) ──
function hasBackend(){
// T3MP3ST_API is a top-level const, NOT a window property — reference the bare binding
@@ -26411,10 +26531,37 @@ Be specific and actionable. Format as structured JSON.`;
var srv = window.__t3mpServerAgents.slice();
pool = srv.filter(function(a){ return a.lastPing && a.lastPing.ok; }).map(function(a){ return a.id; });
}
// Explicit operator pin wins over the default order only while it is live. A remembered
// connection is not sufficient: routing a stale pin ahead of a working keyed provider makes
// General/Admiral dispatch fail even though a healthy fallback is available.
var pinned = getActiveAgent();
if (pinned && pool.indexOf(pinned) >= 0) return pinned;
pool.sort(function(a,b){ return order.indexOf(a) - order.indexOf(b); });
return pool[0] || null;
}
window.t3mpPreferredAgent = preferredAgent;
// The EXPLICIT operator pin (empty string = none). Callers use this to let a pinned local agent
// outrank stored API keys, so "use my local Claude" beats a leftover OpenRouter key.
window.t3mpActiveAgentPin = getActiveAgent;
// The chosen underlying model for a local agent (only Claude Code exposes a picker) — '' / undefined
// means the CLI's own default. Callers thread it through so missions/analysis run the picked model.
window.t3mpAgentModel = agentModelFor;
// CENTRALIZED backend agent resolution — the single source of truth every server-dispatch surface
// (missions, General, Admiral) uses, so pin precedence and model passthrough stay consistent:
// • an EXPLICIT pin outranks a stored API key (the pin is a deliberate "use my local Claude");
// • with no key and no pin, the auto-preferred connected agent is used;
// • the returned `model` carries the picked underlying model as "agentId::model" (LocalAgentAdapter
// parses it) so the selected Claude model is never dropped.
// Returns { id, model } or null when no local agent should drive this dispatch.
window.t3mpDispatchAgent = function(apiKey){
const key = (apiKey || '').trim();
const pin = getActiveAgent();
const pref = preferredAgent();
const agent = (pin && pref === pin) ? pin : (!key ? pref : null);
if (!agent) return null;
const am = agentModelFor(agent);
return { id: agent, model: am ? (agent + '::' + am) : agent };
};
window.nudgeToSettings = function(reason){
const nb = document.getElementById('noBackendNudge');
if (nb) { const m = document.getElementById('noBackendNudgeMsg'); if (m && reason) m.textContent = reason; nb.style.display = 'flex'; nb.scrollIntoView({block:'nearest'}); }
@@ -26616,9 +26763,11 @@ Be specific and actionable. Format as structured JSON.`;
};
}
const apiKey = (typeof getApiKey === 'function') ? (getApiKey() || '') : '';
const agent = (!apiKey && typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null;
// Centralized agent resolution: an explicit pin (e.g. Claude Code) outranks a stored key, and the
// picked Claude model rides along as "agentId::model" — identical precedence to the mission path.
const dispatchAgent = (typeof window.t3mpDispatchAgent === 'function') ? window.t3mpDispatchAgent(apiKey) : null;
let provider, model;
if (!apiKey && agent) { provider = 'local-agent'; model = agent; }
if (dispatchAgent) { provider = 'local-agent'; model = dispatchAgent.model; }
else if (apiKey && apiKey.startsWith('sk-ant-')) { provider = 'anthropic'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4').replace('anthropic/', ''); }
else if (apiKey && apiKey.startsWith('sk-') && !apiKey.startsWith('sk-or-')) { provider = 'openai'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4'); }
else if (apiKey) { provider = 'openrouter'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4'); }