diff --git a/README.md b/README.md
index 617e507..c7ef9eb 100755
--- a/README.md
+++ b/README.md
@@ -77,6 +77,10 @@ export OPENROUTER_API_KEY=... # or VENICE_API_KEY / ANTHROPIC_API_KEY / OPEN
export XAI_API_KEY=... # Grok Build (grok-build-0.1) — xAI's coding model, native tool-calling
```
+Slow local agents can be given more room with `T3MP3ST_LOCAL_AGENT_TIMEOUT_MS`
+for each CLI call, `T3MP3ST_TASK_TIMEOUT_MS` for mission tasks, and
+`T3MP3ST_GENERAL_TIMEOUT_MS` for planning requests. Values are milliseconds.
+
Or run it **fully offline** on your own model — no key, no cloud. Defaults to Ollama; point it at any OpenAI-compatible server (LM Studio, vLLM, llama.cpp):
```bash
diff --git a/docs/index.html b/docs/index.html
index 895a5cc..8a06e30 100755
--- a/docs/index.html
+++ b/docs/index.html
@@ -6223,6 +6223,11 @@ tempest.operators.spawn('scan
return;
}
+ if (status.paused && status.stallReason) {
+ resolve(status);
+ return;
+ }
+
// Update kill chain from mission phase
if (status.mission?.currentPhase) {
const phaseMap = {
@@ -17921,7 +17926,7 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
// ── Local agents: fetch ONCE before the backbone label so both reflect the server's
// actual agent state from the first poll (and t3mpPreferredAgent can route to a live one) ──
let __agents = [];
- try { const s = await (await fetch(base + '/api/agents/local/status')).json(); __agents = s.connected || []; } catch (_) {}
+ try { const s = await (await fetch(base + '/api/agents/local/status?check=1')).json(); __agents = s.connected || []; } catch (_) {}
window.__t3mpServerAgents = __agents;
window.__t3mpConnectedAgents = __agents.length;
window.__t3mpLiveAgents = __agents.filter(a => a.lastPing && a.lastPing.ok).length;
@@ -17931,10 +17936,12 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
const key = (typeof getApiKey === 'function') ? (getApiKey() || '') : '';
let txt, color;
const serverLLM = !!(health && health.llm && health.llm.connected);
+ const staleAgents = __agents.filter(a => !a.lastPing || !a.lastPing.ok).length;
const hasBackend = !!(agent || key || serverLLM);
if (!key && agent) { txt = '⚡ ' + agent.toUpperCase() + ' · local, no key'; color = '#00ff88'; }
else if (key) { txt = (key.startsWith('sk-ant-') ? 'Anthropic' : key.startsWith('sk-or-') ? 'OpenRouter' : key.startsWith('sk-') ? 'OpenAI' : 'OpenRouter') + ' · your key'; color = '#0088ff'; }
else if (serverLLM) { txt = (health.llm.provider || 'server') + ' · server key'; color = '#0088ff'; }
+ else if (staleAgents) { txt = 'local agent stale — model unreachable'; color = '#ffaa00'; }
else { txt = 'none — connect agent / add key'; color = '#ff6b6b'; }
$('sysLLM').innerHTML = ''+txt+'';
// Keyless hero callout: when NO backend is present (no agent, no key, no server LLM),
@@ -17959,7 +17966,11 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
// ── Local agents cell (reuse the state fetched above; keeps the stale-count latch fresh) ──
if ($('sysAgents')) {
$('sysAgents').innerHTML = __agents.length
- ? __agents.map(a => { const live = a.lastPing && a.lastPing.ok; return '●'+a.id+''; }).join(' ')
+ ? __agents.map(a => {
+ const live = a.lastPing && a.lastPing.ok;
+ const err = a.lastPing && a.lastPing.error ? ' title="'+String(a.lastPing.error).replace(/"/g,'"')+'"' : '';
+ return '●'+a.id+''+(live?' live':' stale')+'';
+ }).join(' ')
: 'none — click to connect';
}
// ── Drive the SitRep pipeline from /api/mission/status (sysPhases/sysFindings status cells were consolidated out) ──
@@ -18246,6 +18257,14 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
// Poll for mission completion (SSE provides real-time updates, poll for completion detection)
const finalStatus = await BackendDispatch.pollUntilComplete((status) => {
+ if (status.paused && status.stallReason) {
+ const reason = status.stallReason;
+ document.getElementById('cmdMissionStatus').textContent = `Stalled — ${reason}`;
+ document.getElementById('cmdMissionStatus').style.color = '#ff6b6b';
+ addMissionLog('error', reason);
+ addIntel('ERROR', reason, 'danger');
+ return;
+ }
// Periodic status update
if (status.operators?.summary) {
const ops = status.operators.summary;
@@ -18263,6 +18282,9 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
}, 3000);
// Mission complete
+ if (finalStatus.paused && finalStatus.stallReason) {
+ throw new Error(finalStatus.stallReason);
+ }
const findingCount = finalStatus.findings?.length || 0;
completeMission(null);
addIntel('COMMAND', `Backend mission complete. ${findingCount} findings.`, 'success');
@@ -18286,7 +18308,7 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
setFidelity('off');
const why = isScopeBlock
? 'the scope gate refused this target (no valid approval/receipt for it)'
- : 'the live-tools backend is unavailable';
+ : `the live-tools backend failed: ${msg}`;
addIntel('REFUSED', `Refusing to simulate against external target ${target} — ${why}. Authorize the target (mint a receipt) or point at a local/practice target.`, 'danger');
addMissionLog('error', `Refused: will not run an LLM-only simulation against external target ${target} (${why})`);
toast(`Won't fake a run against ${target} — ${why}`, 'error');
@@ -24747,10 +24769,10 @@ Be specific and actionable. Format as structured JSON.`;
else if (!a.authed) { status='Not authed'; color='amber'; }
else if (stale) { status='⚠ Auth stale'; color='red'; }
else if (live) { status='● Live · '+ps.latencyMs+'ms'; color='green'; }
- else if (connected) { status='● Active'; color='green'; }
+ 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)' : (connected||live) ? 'rgba(0,255,136,0.35)' : 'rgba(0,200,255,0.14)';
+ 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 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);
@@ -24786,10 +24808,10 @@ Be specific and actionable. Format as structured JSON.`;
const live = Object.keys(pingStatus).filter(function(k){return pingStatus[k].ok;}).length;
const sum = document.getElementById('localAgentsSummary');
if (sum) sum.innerHTML = connectedIds.length+' active'+(live?' · '+live+' live':'')+' · '+ready+' ready · '+installed+'/'+agents.length+' installed';
- // expose for the War Room backend-gate, and dismiss the nudge once ANY agent is connected
- // (a plain connect counts — not only a successful live-ping)
+ // expose live/connected counts separately. A plain connect is just a registration; only a
+ // fresh successful ping proves the local model is currently usable.
window.__t3mpLiveAgents = live; window.__t3mpConnectedAgents = connectedIds.length;
- if (connectedIds.length > 0 || live > 0) { const nb = document.getElementById('noBackendNudge'); if (nb) nb.style.display = 'none'; }
+ if (live > 0) { const nb = document.getElementById('noBackendNudge'); if (nb) nb.style.display = 'none'; }
}
// ── remember connected agents + silently re-establish them after a refresh ──
@@ -24866,8 +24888,9 @@ Be specific and actionable. Format as structured JSON.`;
// _isStandalone (no API server) is NOT a backend — client-side mode still needs an API key.
// Counting it here let keyless standalone deploys bypass the "connect an agent / add a key"
// gate and silently fall through to a failing OpenRouter pipeline. A backend = a real LLM:
- // the server's own LLM (llmAvailable) or a connected local agent.
- return !!(api.llmAvailable || window.__t3mpLiveAgents > 0 || window.__t3mpConnectedAgents > 0);
+ // the server's own LLM (llmAvailable) or a live local agent. A connected-but-stale
+ // agent is not enough, because the underlying local model may have gone down.
+ return !!(api.llmAvailable || window.__t3mpLiveAgents > 0);
}
window.t3mpHasBackend = hasBackend;
// Pick the best connected agent to serve as the mission LLM backend: prefer a live-pinged one, and
@@ -24876,15 +24899,14 @@ Be specific and actionable. Format as structured JSON.`;
var order = ['codex','hermes','claude'];
var connected = (connectedIds || []).slice();
var live = connected.filter(function(id){ return pingStatus[id] && pingStatus[id].ok; });
- var pool = live.length ? live : connected;
+ var pool = live;
// Fall back to the SERVER's reported agents (/api/agents/local/status, stashed by
// refreshSystemStatus). Without this, a War Room loaded without first visiting Settings has an
// empty client connectedIds and would route missions to a key-provider even though the server
// already has a live agent — the exact AI-backbone/mission-routing mismatch we want to avoid.
if (!pool.length && Array.isArray(window.__t3mpServerAgents)) {
var srv = window.__t3mpServerAgents.slice();
- var srvLive = srv.filter(function(a){ return a.lastPing && a.lastPing.ok; });
- pool = (srvLive.length ? srvLive : srv).map(function(a){ return a.id; });
+ pool = srv.filter(function(a){ return a.lastPing && a.lastPing.ok; }).map(function(a){ return a.id; });
}
pool.sort(function(a,b){ return order.indexOf(a) - order.indexOf(b); });
return pool[0] || null;
diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts
index 5865e66..1fc8835 100755
--- a/src/__tests__/index.test.ts
+++ b/src/__tests__/index.test.ts
@@ -419,7 +419,7 @@ describe('Wedged-dispatch timeout backstop', () => {
expect(stopped).toBe(true);
});
- it('a wedged mission reaches phase advancement once the per-dispatch backstop fires', async () => {
+ it('a wedged mission stalls instead of advancing after required recon dispatches time out', async () => {
// Tiny backstop so the test is fast and deterministic.
const prev = process.env.T3MP3ST_TASK_TIMEOUT_MS;
process.env.T3MP3ST_TASK_TIMEOUT_MS = '30';
@@ -442,14 +442,18 @@ describe('Wedged-dispatch timeout backstop', () => {
command.start();
// Poll (real timers): the 1s tick loop must dispatch, wedge, then reap the
- // dispatch via the backstop and advance past RECON.
+ // dispatch via the backstop. Failed required recon work should stall instead
+ // of advancing the mission with no successful backend/model work.
const deadline = Date.now() + 8000;
- while (Date.now() < deadline && !phaseAdvanced) {
+ while (Date.now() < deadline && !command.getStatus().paused) {
await new Promise(r => setTimeout(r, 100));
}
+ const status = command.getStatus();
command.stop();
- expect(phaseAdvanced).toBe(true);
+ expect(phaseAdvanced).toBe(false);
+ expect(status.paused).toBe(true);
+ expect(status.stallReason).toContain('stalled in reconnaissance');
// The wedged operator was reset back to idle (or re-tasked in a later phase),
// never left stuck in 'executing' with no current task.
const stuck = command.cell.getAllOperators().some(
diff --git a/src/__tests__/local-agent-tool-calling.test.ts b/src/__tests__/local-agent-tool-calling.test.ts
index a5791d2..45a3fb4 100644
--- a/src/__tests__/local-agent-tool-calling.test.ts
+++ b/src/__tests__/local-agent-tool-calling.test.ts
@@ -71,6 +71,15 @@ describe('parseTextToolCalls — happy path + drift tolerance', () => {
});
describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => {
+ it('describes the current ACTION CONTRACT to the local agent', async () => {
+ cli.mockResolvedValueOnce('Final debrief.');
+ await localBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
+ const prompt = String(cli.mock.calls.at(-1)?.[1] || '');
+ expect(prompt).toContain('ACTION CONTRACT');
+ expect(prompt).toContain('nmap_scan');
+ expect(prompt).toContain('port scan a host');
+ });
+
it('returns toolCalls + finishReason=tool_calls when the agent requests a tool', async () => {
cli.mockResolvedValueOnce('```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```');
const res = await localBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
@@ -83,6 +92,19 @@ describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => {
expect(res.toolCalls).toBeUndefined();
expect(res.finishReason).toBe('stop');
});
+
+ it('does not force the old short timeout when no local-agent timeout is configured', async () => {
+ cli.mockResolvedValueOnce('done');
+ await localBackbone().chat([{ role: 'user', content: 'hello' }]);
+ expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ timeoutMs: undefined });
+ });
+
+ it('passes an explicit local-agent timeout through to the CLI runner', async () => {
+ cli.mockResolvedValueOnce('done');
+ await new LLMBackbone({ provider: 'local-agent', model: 'codex', timeout: 900000 } as never)
+ .chat([{ role: 'user', content: 'hello' }]);
+ expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ timeoutMs: 900000 });
+ });
});
describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the fix)', () => {
diff --git a/src/agent/index.ts b/src/agent/index.ts
index 71fb532..092b55a 100755
--- a/src/agent/index.ts
+++ b/src/agent/index.ts
@@ -65,6 +65,8 @@ export interface AgentResult {
durationMs: number;
/** Whether the agent hit the iteration limit */
hitLimit: boolean;
+ /** Error from the forced final-summary call after hitting limits, if any */
+ finalSummaryError?: string;
}
export interface AgentEvents {
@@ -267,6 +269,7 @@ export class AgentLoop extends EventEmitter {
});
let summary = 'Agent reached iteration limit without producing a final summary.';
+ let finalSummaryError: string | undefined;
try {
const finalResponse = await this.llm.chat(messages, { maxTokens: 2048 });
summary = finalResponse.content;
@@ -275,8 +278,8 @@ export class AgentLoop extends EventEmitter {
for (const f of this.parseFinalFindings(summary)) {
if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f);
}
- } catch {
- // Use what we have
+ } catch (error) {
+ finalSummaryError = error instanceof Error ? error.message : String(error);
}
const result: AgentResult = {
@@ -288,6 +291,7 @@ export class AgentLoop extends EventEmitter {
tokensUsed,
durationMs: Date.now() - startTime,
hitLimit,
+ finalSummaryError,
};
this.emit('agent:complete', result);
diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts
index d2c86ae..f6de5ec 100644
--- a/src/agent/local-agents.ts
+++ b/src/agent/local-agents.ts
@@ -236,6 +236,18 @@ export interface AgentRunResult {
error?: string;
}
+function agentFailureOutput(output: string): string | null {
+ const text = output.trim();
+ if (/^API call failed\b/i.test(text)) return text.slice(0, 300);
+ if (/connection error/i.test(text) && /failed/i.test(text)) return text.slice(0, 300);
+ return null;
+}
+
+function envTimeoutMs(name: string, fallback: number): number {
+ const parsed = Number(process.env[name]);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+}
+
/**
* Drive a connected agent with a one-shot headless prompt (real round-trip — SPENDS the agent's quota).
* Used by /ping (liveness proof) and /dispatch (actually using the operator).
@@ -249,7 +261,7 @@ export function runLocalAgent(
const t0 = Date.now();
if (!spec) return Promise.resolve({ ok: false, latencyMs: 0, output: '', error: `unknown agent: ${id}` });
const args = spec.oneShot(prompt, opts.model);
- const timeoutMs = opts.timeoutMs ?? 120000;
+ const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000);
const maxChars = opts.maxChars ?? 4000;
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
const env = childEnv();
@@ -270,7 +282,9 @@ export function runLocalAgent(
child.on('close', (code) => {
const latencyMs = Date.now() - t0;
const output = out.trim().slice(0, maxChars);
- if (code === 0) finish({ ok: true, latencyMs, output });
+ const semanticError = agentFailureOutput(output);
+ if (code === 0 && !semanticError) finish({ ok: true, latencyMs, output });
+ else if (semanticError) finish({ ok: false, latencyMs, output, error: semanticError });
else finish({ ok: false, latencyMs, output, error: (errOut.trim() || `exited with code ${code}`).slice(0, 300) });
});
});
@@ -297,7 +311,7 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
const env = childEnv();
const model = opts.model && opts.model !== 'codex-default' && opts.model !== id ? opts.model : undefined;
- const timeoutMs = opts.timeoutMs ?? 240000;
+ const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000);
let args: string[];
let viaStdin = true;
@@ -329,7 +343,9 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
let content = out.trim();
if (outFile) { try { content = (readFileSync(outFile, 'utf8').trim() || content); } catch { /* fall back to stdout */ } }
finish(() => {
- if (code === 0 && content) resolve(content);
+ const semanticError = agentFailureOutput(content);
+ if (code === 0 && content && !semanticError) resolve(content);
+ else if (semanticError) reject(new Error(semanticError));
else reject(new Error((errOut.trim() || content || `exited with code ${code}`).slice(0, 800)));
});
});
diff --git a/src/index.ts b/src/index.ts
index ee8bf98..e975767 100755
--- a/src/index.ts
+++ b/src/index.ts
@@ -208,6 +208,7 @@ import type {
TempestConfig,
LLMConfig,
RuntimeHooks,
+ LLMProvider,
OperatorArchetype,
CommandEvents,
Finding,
@@ -268,6 +269,9 @@ import {
// TEMPEST COMMAND
// =============================================================================
+const DEFAULT_AGENT_MAX_ITERATIONS = 15;
+const LOCAL_AGENT_MAX_ITERATIONS = Number(process.env.T3MP3ST_LOCAL_AGENT_MAX_ITERATIONS || 30);
+
/**
* TEMPEST Command - Main orchestration controller
*/
@@ -322,6 +326,7 @@ export class TempestCommand extends EventEmitter {
private tickInterval: NodeJS.Timeout | null = null;
private tickCount: number = 0;
private hooks: RuntimeHooks;
+ private readonly taskTimeoutMs: number;
/**
* White-box source context (security-prioritized code excerpt), set by the
@@ -356,6 +361,7 @@ export class TempestCommand extends EventEmitter {
super();
this.name = config.name;
this.hooks = config.hooks || {};
+ this.taskTimeoutMs = TempestCommand.resolveTaskTimeoutMs(config.llm.provider);
// Initialize LLM backbone
this.llm = new LLMBackbone(config.llm);
@@ -786,6 +792,7 @@ export class TempestCommand extends EventEmitter {
public resume(): void {
if (!this.running || !this.paused) return;
this.paused = false;
+ this.stallReason = null;
this.emit('command:resumed');
}
@@ -814,29 +821,32 @@ export class TempestCommand extends EventEmitter {
* GENEROUS per-dispatch wall-clock backstop (ms). If a single task dispatch stays
* in-flight longer than this, the tick loop force-resolves it as a timeout so
* pendingOrActive can reach 0 and the mission can advance/complete even when an
- * operator promise wedges. Deliberately large (default 5 min) so it NEVER kills a
- * legitimately-slow-but-working LLM task (e.g. opus planning ~165s); it only fires
- * on truly-hung dispatches. Override via T3MP3ST_TASK_TIMEOUT_MS.
+ * operator promise wedges. Deliberately large (default 5 min for API models,
+ * 30 min for local-agent backends) so it does not kill legitimately slow local
+ * CLI work; it only fires on truly-hung dispatches. Override via
+ * T3MP3ST_TASK_TIMEOUT_MS.
*/
- private readonly taskTimeoutMs: number = TempestCommand.resolveTaskTimeoutMs();
-
/**
- * Resolve the dispatch timeout from the environment, falling back to the 5-minute
- * default. Guards against a non-numeric / non-positive override.
+ * Resolve the dispatch timeout from the environment, falling back to the
+ * provider-specific default. Guards against a non-numeric / non-positive override.
*/
- private static resolveTaskTimeoutMs(): number {
+ private static resolveTaskTimeoutMs(provider?: LLMProvider): number {
const DEFAULT_TASK_TIMEOUT_MS = 300000; // 5 minutes — generous backstop, not a deadline
+ const LOCAL_AGENT_TASK_TIMEOUT_MS = 1800000; // local CLI agents can need multiple slow turns
const raw = process.env.T3MP3ST_TASK_TIMEOUT_MS;
if (raw != null && raw.trim() !== '') {
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
- return DEFAULT_TASK_TIMEOUT_MS;
+ return provider === 'local-agent' ? LOCAL_AGENT_TASK_TIMEOUT_MS : DEFAULT_TASK_TIMEOUT_MS;
}
/** Track whether we've seeded initial tasks for the current mission */
private taskSeeded: boolean = false;
+ /** Human-readable reason a mission was paused because required work failed. */
+ private stallReason: string | null = null;
+
/**
* Main tick loop — seeds tasks, dispatches to operators, advances phases
*/
@@ -889,13 +899,28 @@ export class TempestCommand extends EventEmitter {
);
const inFlight = allMissionTasks.filter(t => this.activeDispatches.has(t.id));
- // If we have tasks, all are done, and nothing is in-flight → advance phase
+ // If we have tasks, all are done, and nothing is in-flight → advance phase.
+ // Failed required tasks are terminal, but they are not successful progress.
+ // Stall instead of walking the phase bar forward with no backend/model work.
if (allMissionTasks.length > 0 && pendingOrActive.length === 0 && inFlight.length === 0) {
+ const failedCurrentPhase = allMissionTasks.filter(
+ t => t.phase === mission.currentPhase && t.status === 'failed'
+ );
+ if (failedCurrentPhase.length > 0) {
+ const firstError = failedCurrentPhase[0].result?.error;
+ this.stallReason = `stalled in ${mission.currentPhase}: ${failedCurrentPhase.length} required task(s) failed` +
+ (firstError ? ` — ${firstError}` : '');
+ this.paused = true;
+ this.emit('command:paused');
+ return;
+ }
+
const phaseIndex = mission.phases.indexOf(mission.currentPhase);
if (phaseIndex === -1) return; // Guard: phase not found (race condition)
if (phaseIndex < mission.phases.length - 1) {
// Advance to next phase and generate tasks
this.mission.advancePhase(mission.id);
+ this.stallReason = null;
const targets = this.targetEnv.getAllTargets();
for (const target of targets) {
this.mission.generateNextPhaseTasks(target.address);
@@ -972,8 +997,12 @@ export class TempestCommand extends EventEmitter {
// double-fire the completion hook.
if (!this.activeDispatches.has(task.id)) return;
this.clearDispatch(task.id);
- taskQueue.complete(task.id, result);
- this.hooks.onTaskCompleted?.(task);
+ if (result.success === false) {
+ taskQueue.fail(task.id, result.error || result.output || 'task returned unsuccessful result');
+ } else {
+ taskQueue.complete(task.id, result);
+ this.hooks.onTaskCompleted?.(task);
+ }
}).catch((_error) => {
if (!this.activeDispatches.has(task.id)) return;
this.clearDispatch(task.id);
@@ -1007,8 +1036,8 @@ export class TempestCommand extends EventEmitter {
* never advances, and completeMission() is never called: the mission HANGS.
*
* On every tick we scan the in-flight dispatches and force-resolve any that have
- * exceeded the GENEROUS wall-clock backstop (taskTimeoutMs, default 5 min), or
- * that exhibit the exact wedge symptom (owning operator is `executing`/`tasked`
+ * exceeded the GENEROUS wall-clock backstop (taskTimeoutMs), or that exhibit
+ * the exact wedge symptom (owning operator is `executing`/`tasked`
* but its currentTask is null — i.e. it has silently dropped the task). For each
* we: (1) mark the task failed/timed-out in the queue, (2) clear its dispatch
* bookkeeping, and (3) reset the owning operator back to idle so it can take new
@@ -1135,8 +1164,11 @@ export class TempestCommand extends EventEmitter {
// Attach the agent loop scoped to this archetype's SPECIALIZED role toolkit (defaultTools =
// the curated per-operator tool allowlist). toolCategories stays as a coarse fallback.
const profile = ARCHETYPE_PROFILES[archetype];
+ const maxIterations = this.llm.getProvider() === 'local-agent'
+ ? LOCAL_AGENT_MAX_ITERATIONS
+ : DEFAULT_AGENT_MAX_ITERATIONS;
const agentLoop = new AgentLoop(this.llm, this.arsenal, {
- maxIterations: 15,
+ maxIterations,
maxTokens: 50000,
toolCategories: profile.toolCategories,
tools: profile.defaultTools,
@@ -1199,6 +1231,7 @@ export class TempestCommand extends EventEmitter {
vault: ReturnType;
opsec: ReturnType;
activeMission: string | null;
+ stallReason: string | null;
} {
const activeMission = this.mission.getActiveMission();
@@ -1212,6 +1245,7 @@ export class TempestCommand extends EventEmitter {
vault: this.vault.getStats(),
opsec: this.opsec.getStats(),
activeMission: activeMission?.id || null,
+ stallReason: this.stallReason,
};
}
diff --git a/src/llm/index.ts b/src/llm/index.ts
index d796e73..0b426f3 100755
--- a/src/llm/index.ts
+++ b/src/llm/index.ts
@@ -1070,7 +1070,8 @@ class LocalAgentAdapter implements LLMProviderAdapter {
async chat(messages: LLMMessage[], options?: ChatOptions): Promise {
const agentId = this.config.model || 'codex';
const prompt = this.formatPrompt(messages, options);
- const content = (await localAgentChat(agentId, prompt, { timeoutMs: this.config.timeout || 240000 })).trim();
+ const timeoutMs = typeof this.config.timeout === 'number' && this.config.timeout > 0 ? this.config.timeout : undefined;
+ const content = (await localAgentChat(agentId, prompt, { timeoutMs })).trim();
// Tool-calling over text: if the Arsenal was offered, parse the agent's tool requests so the
// ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer.
const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined;
diff --git a/src/operators/index.ts b/src/operators/index.ts
index 956d1c1..cdb70f5 100755
--- a/src/operators/index.ts
+++ b/src/operators/index.ts
@@ -461,6 +461,9 @@ export class OperatorAgent extends EventEmitter {
return {
success: result.success,
output: result.summary,
+ error: result.finalSummaryError
+ ? `${result.summary} Final summary failed: ${result.finalSummaryError}`
+ : undefined,
findings: result.findings.map(f => f.title),
nextTasks: undefined,
};
diff --git a/src/server.ts b/src/server.ts
index 1f16a79..01b5fb2 100755
--- a/src/server.ts
+++ b/src/server.ts
@@ -5982,6 +5982,13 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise {
res.json({
active: status.running,
paused: status.paused,
+ stallReason: status.stallReason,
name: status.name,
tickCount: status.tickCount,
mission: mission ? {
@@ -6470,6 +6478,18 @@ function providerNeedsApiKey(provider: string): boolean {
return !['codex', 'mock', 'local', 'local-agent'].includes(provider);
}
+function readPositiveTimeoutEnv(name: string): number | undefined {
+ const raw = process.env[name];
+ if (raw == null || raw.trim() === '') return undefined;
+ const parsed = Number(raw);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
+}
+
+function readGeneralTimeoutEnv(): number | undefined {
+ return readPositiveTimeoutEnv('TEMPEST_GENERAL_TIMEOUT_MS')
+ ?? readPositiveTimeoutEnv('T3MP3ST_GENERAL_TIMEOUT_MS');
+}
+
// SECURITY NOTE: `apiKey` is accepted from the request BODY here (and in the
// mission/general routes that call this). Sending secrets in the body is not
// ideal — an Authorization header is preferred — but the same-origin UI posts
@@ -6493,7 +6513,9 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
model: model || 'codex',
maxTokens: 8192,
temperature: 0.4,
- timeout: Number(process.env.TEMPEST_GENERAL_TIMEOUT_MS) || 300000,
+ timeout: readGeneralTimeoutEnv()
+ ?? readPositiveTimeoutEnv('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS')
+ ?? 600000,
};
}
const baseConfig = config.getLLMConfig(selectedProvider as any, model);
@@ -6507,7 +6529,7 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
apiKey: effectiveKey,
maxTokens: 8192,
temperature: 0.4,
- timeout: Number(process.env.TEMPEST_GENERAL_TIMEOUT_MS) || 300000, // General planning needs room (was a hardcoded 60s); override via env
+ timeout: readGeneralTimeoutEnv() ?? 300000, // General planning needs room (was a hardcoded 60s); override via env
};
}
@@ -7350,8 +7372,51 @@ app.get('/api/bounty/credentials', (_req: Request, res: Response) => {
// Detect agent CLIs that are already installed + logged-in on this machine and enlist them as
// operators — no keys are entered or read (auth is detected by artifact PRESENCE only; see
// src/agent/local-agents.ts). In-memory registry of agents connected this session:
-const connectedLocalAgents = new Map();
type AgentRunSummary = { ok: boolean; latencyMs: number; output: string; error?: string };
+type ConnectedLocalAgent = {
+ id: string;
+ label: string;
+ version?: string;
+ connectedAt: number;
+ lastPing?: AgentRunSummary | null;
+ lastHealthCheckAt?: number;
+};
+const connectedLocalAgents = new Map();
+const LOCAL_AGENT_HEALTH_TTL_MS = 30_000;
+const LOCAL_AGENT_HEALTH_TIMEOUT_MS = 15_000;
+
+async function refreshConnectedLocalAgentHealth(force = false): Promise {
+ const now = Date.now();
+ const due = Array.from(connectedLocalAgents.values()).filter((agent) => (
+ force ||
+ !agent.lastHealthCheckAt ||
+ now - agent.lastHealthCheckAt > LOCAL_AGENT_HEALTH_TTL_MS
+ ));
+
+ await Promise.all(due.map(async (agent) => {
+ const result = await pingLocalAgent(agent.id, undefined, LOCAL_AGENT_HEALTH_TIMEOUT_MS);
+ const current = connectedLocalAgents.get(agent.id);
+ if (!current) return;
+ current.lastPing = result;
+ current.lastHealthCheckAt = Date.now();
+ }));
+}
+
+async function requireLiveLocalAgent(model: string | undefined): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
+ const id = String(model || 'codex').trim();
+ const entry = connectedLocalAgents.get(id);
+ if (!entry) return { ok: false, error: `local agent "${id}" is not connected — connect it in Settings first` };
+
+ await refreshConnectedLocalAgentHealth(true);
+ const refreshed = connectedLocalAgents.get(id);
+ if (refreshed?.lastPing?.ok) return { ok: true, id };
+
+ return {
+ ok: false,
+ error: `local agent "${id}" is connected but not live` +
+ (refreshed?.lastPing?.error ? ` — ${refreshed.lastPing.error}` : ''),
+ };
+}
// GET /api/agents/local/detect — which agents are installed / authed / ready (no tokens spent)
app.get('/api/agents/local/detect', async (_req: Request, res: Response): Promise => {
@@ -7378,7 +7443,14 @@ app.post('/api/agents/local/connect', async (req: Request, res: Response): Promi
if (!d.authed) { results.push({ id, status: 'not-authed', version: d.version }); continue; }
let ping: AgentRunSummary | null = null;
if (doPing) ping = await pingLocalAgent(id);
- connectedLocalAgents.set(id, { id, label: d.label, version: d.version, connectedAt: Date.now(), lastPing: ping });
+ connectedLocalAgents.set(id, {
+ id,
+ label: d.label,
+ version: d.version,
+ connectedAt: Date.now(),
+ lastPing: ping,
+ lastHealthCheckAt: ping ? Date.now() : undefined,
+ });
results.push({ id, status: 'active', label: d.label, version: d.version, authMethod: d.authMethod, ping });
}
syncLocalAgentSelection(connectedLocalAgents, ids, replace);
@@ -7391,7 +7463,10 @@ app.post('/api/agents/local/ping', async (req: Request, res: Response): Promise<
if (!body.id) { res.status(400).json({ error: 'id required' }); return; }
const r = await pingLocalAgent(body.id, body.prompt);
const entry = connectedLocalAgents.get(body.id);
- if (entry) entry.lastPing = r;
+ if (entry) {
+ entry.lastPing = r;
+ entry.lastHealthCheckAt = Date.now();
+ }
res.json({ id: body.id, ...r });
});
@@ -7411,8 +7486,10 @@ app.post('/api/agents/local/disconnect', (req: Request, res: Response): void =>
res.json({ ok: true, connected: Array.from(connectedLocalAgents.keys()) });
});
-// GET /api/agents/local/status — currently connected agents
-app.get('/api/agents/local/status', (_req: Request, res: Response): void => {
+// GET /api/agents/local/status — connected agents, optionally with a bounded live health check.
+app.get('/api/agents/local/status', async (req: Request, res: Response): Promise => {
+ const check = /^(1|true|yes|on)$/i.test(String(req.query.check || ''));
+ if (check) await refreshConnectedLocalAgentHealth(false);
res.json({ connected: Array.from(connectedLocalAgents.values()) });
});
diff --git a/src/target/index.ts b/src/target/index.ts
index 203c4ed..f447c66 100755
--- a/src/target/index.ts
+++ b/src/target/index.ts
@@ -271,6 +271,7 @@ export class TargetEnvironment extends EventEmitter {
export function createTargetFromUrl(url: string, zone: TargetZone = 'external'): Target {
const parsed = new URL(url);
+ const normalizedUrl = parsed.toString();
return {
id: randomUUID(),
@@ -278,12 +279,17 @@ export function createTargetFromUrl(url: string, zone: TargetZone = 'external'):
type: 'web_application',
zone,
status: 'discovered',
- address: url,
+ address: parsed.hostname,
port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80),
protocol: parsed.protocol.replace(':', ''),
services: [],
vulnerabilities: [],
credentials: [],
+ metadata: {
+ url: normalizedUrl,
+ origin: parsed.origin,
+ path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
+ },
discoveredAt: Date.now(),
};
}