Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
237 lines
7.9 KiB
TypeScript
237 lines
7.9 KiB
TypeScript
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
|
|
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
|
|
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
|
|
|
|
interface NetcattyBridge {
|
|
aiDiscoverAgents(options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }): Promise<DiscoveredAgent[]>;
|
|
}
|
|
|
|
function getBridge(): NetcattyBridge | undefined {
|
|
return (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
|
}
|
|
|
|
const AGENT_DISCOVERY_CACHE_TTL_MS = 60_000;
|
|
let agentDiscoveryCache: {
|
|
agents: DiscoveredAgent[];
|
|
apiKeyPresent: boolean;
|
|
updatedAt: number;
|
|
} | null = null;
|
|
const agentDiscoveryPromises = new Map<string, Promise<DiscoveredAgent[]>>();
|
|
let agentDiscoveryWriteGeneration = 0;
|
|
|
|
export function useAgentDiscovery(
|
|
externalAgents: ExternalAgentConfig[],
|
|
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
|
|
options?: {
|
|
enabled?: boolean;
|
|
schedule?: (task: () => void) => () => void;
|
|
},
|
|
) {
|
|
const enabled = options?.enabled ?? true;
|
|
const schedule = options?.schedule;
|
|
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>(
|
|
() => agentDiscoveryCache?.agents ?? [],
|
|
);
|
|
const [isDiscovering, setIsDiscovering] = useState(false);
|
|
const discoverSeqRef = useRef(0);
|
|
const mountedRef = useRef(true);
|
|
const enabledRef = useRef(enabled);
|
|
|
|
enabledRef.current = enabled;
|
|
|
|
useEffect(() => () => {
|
|
mountedRef.current = false;
|
|
discoverSeqRef.current += 1;
|
|
}, []);
|
|
|
|
const cursorApiKeyPresent = externalAgents.some(
|
|
(agent) => agent.id === "discovered_cursor" && Boolean(agent.apiKey),
|
|
);
|
|
|
|
const discover = useCallback(async (discoverOptions?: { refreshShellEnv?: boolean }) => {
|
|
if (!enabledRef.current) return;
|
|
const bridge = getBridge();
|
|
if (!bridge) return;
|
|
|
|
const forceRefresh = discoverOptions?.refreshShellEnv === true;
|
|
const cacheFresh =
|
|
agentDiscoveryCache
|
|
&& agentDiscoveryCache.apiKeyPresent === cursorApiKeyPresent
|
|
&& Date.now() - agentDiscoveryCache.updatedAt < AGENT_DISCOVERY_CACHE_TTL_MS;
|
|
|
|
if (!forceRefresh && cacheFresh) {
|
|
startTransition(() => setDiscoveredAgents(agentDiscoveryCache?.agents ?? []));
|
|
return;
|
|
}
|
|
|
|
setIsDiscovering(true);
|
|
const discoverSeq = ++discoverSeqRef.current;
|
|
const writeGeneration = ++agentDiscoveryWriteGeneration;
|
|
const promiseKey = JSON.stringify({
|
|
apiKeyPresent: cursorApiKeyPresent,
|
|
refreshShellEnv: forceRefresh,
|
|
});
|
|
try {
|
|
let discoveryPromise = agentDiscoveryPromises.get(promiseKey) ?? null;
|
|
if (!discoveryPromise) {
|
|
const sharedPromise = bridge.aiDiscoverAgents({
|
|
...discoverOptions,
|
|
apiKeyPresent: cursorApiKeyPresent,
|
|
}).finally(() => {
|
|
if (agentDiscoveryPromises.get(promiseKey) === sharedPromise) {
|
|
agentDiscoveryPromises.delete(promiseKey);
|
|
}
|
|
});
|
|
agentDiscoveryPromises.set(promiseKey, sharedPromise);
|
|
discoveryPromise = sharedPromise;
|
|
}
|
|
const agents = await discoveryPromise;
|
|
if (
|
|
!mountedRef.current
|
|
|| !enabledRef.current
|
|
|| discoverSeq !== discoverSeqRef.current
|
|
|| writeGeneration !== agentDiscoveryWriteGeneration
|
|
) return;
|
|
agentDiscoveryCache = {
|
|
agents,
|
|
apiKeyPresent: cursorApiKeyPresent,
|
|
updatedAt: Date.now(),
|
|
};
|
|
startTransition(() => setDiscoveredAgents(agents));
|
|
} catch (err) {
|
|
console.error('Agent discovery failed:', err);
|
|
} finally {
|
|
if (mountedRef.current && discoverSeq === discoverSeqRef.current) {
|
|
setIsDiscovering(false);
|
|
}
|
|
}
|
|
}, [cursorApiKeyPresent]);
|
|
|
|
useEffect(() => {
|
|
discoverSeqRef.current += 1;
|
|
if (!enabled) {
|
|
setIsDiscovering(false);
|
|
}
|
|
}, [cursorApiKeyPresent, enabled]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
let cancelled = false;
|
|
const runDiscover = () => {
|
|
if (!cancelled) void discover();
|
|
};
|
|
|
|
if (schedule) {
|
|
const cancel = schedule(runDiscover);
|
|
return () => {
|
|
cancelled = true;
|
|
cancel();
|
|
};
|
|
}
|
|
|
|
if (typeof requestIdleCallback === 'function') {
|
|
const idleId = requestIdleCallback(runDiscover);
|
|
return () => {
|
|
cancelled = true;
|
|
cancelIdleCallback(idleId);
|
|
};
|
|
}
|
|
|
|
const timeoutId = setTimeout(runDiscover, 0);
|
|
return () => {
|
|
cancelled = true;
|
|
clearTimeout(timeoutId);
|
|
};
|
|
}, [discover, enabled, schedule]);
|
|
|
|
// Auto-update args for already-configured discovered agents when
|
|
// the canonical args from discovery change (e.g. after an app update).
|
|
useEffect(() => {
|
|
if (!setExternalAgents || discoveredAgents.length === 0) return;
|
|
if (!enabled) return;
|
|
|
|
setExternalAgents((prev) => {
|
|
let changed = false;
|
|
const next = prev.map((ea) => {
|
|
// Only update agents that were auto-discovered (id starts with "discovered_")
|
|
if (!ea.id.startsWith('discovered_')) return ea;
|
|
|
|
const match = discoveredAgents.find(
|
|
(da) => ea.command === da.path || ea.command === da.command,
|
|
);
|
|
if (!match) return ea;
|
|
|
|
// Check if args, SDK backend, or managed SDK path env differ.
|
|
const currentArgs = JSON.stringify(ea.args || []);
|
|
const newArgs = JSON.stringify(match.args);
|
|
const backend = match.sdkBackend ?? match.command;
|
|
const backendChanged = getExternalAgentSdkBackend(ea) !== backend
|
|
|| Boolean(ea.acpCommand)
|
|
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify([]);
|
|
const matchPath = match.binPath || match.path;
|
|
const env = match.command === 'claude'
|
|
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: matchPath }
|
|
: match.command === 'opencode'
|
|
? { ...(ea.env ?? {}), OPENCODE_BIN: matchPath }
|
|
: ea.env;
|
|
const envChanged =
|
|
(match.command === 'claude' && ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath)
|
|
|| (match.command === 'opencode' && ea.env?.OPENCODE_BIN !== matchPath);
|
|
const versionChanged = Boolean(match.version) && ea.cliVersion !== match.version;
|
|
if (currentArgs !== newArgs || backendChanged || envChanged || versionChanged) {
|
|
changed = true;
|
|
const { acpCommand: _legacyCommand, acpArgs: _legacyArgs, ...rest } = ea;
|
|
return {
|
|
...rest,
|
|
args: match.args,
|
|
sdkBackend: backend,
|
|
...(match.version ? { cliVersion: match.version } : {}),
|
|
...(env ? { env } : {}),
|
|
};
|
|
}
|
|
return ea;
|
|
});
|
|
return changed ? next : prev;
|
|
});
|
|
}, [discoveredAgents, enabled, setExternalAgents]);
|
|
|
|
// Filter out agents that are already configured as external agents
|
|
const unconfiguredAgents = discoveredAgents.filter(
|
|
(da) => !externalAgents.some(
|
|
(ea) => ea.command === da.command || ea.command === da.path,
|
|
),
|
|
);
|
|
|
|
// Build ExternalAgentConfig from a discovered agent
|
|
const enableAgent = useCallback(
|
|
(agent: DiscoveredAgent): ExternalAgentConfig => {
|
|
const backend = agent.sdkBackend ?? agent.command;
|
|
return {
|
|
id: `discovered_${agent.command}`,
|
|
name: agent.name,
|
|
command: agent.binPath || agent.path || agent.command,
|
|
args: agent.args,
|
|
icon: agent.icon,
|
|
enabled: true,
|
|
sdkBackend: backend,
|
|
...(agent.version ? { cliVersion: agent.version } : {}),
|
|
...(agent.command === 'claude'
|
|
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
|
|
: agent.command === 'opencode'
|
|
? { env: { OPENCODE_BIN: agent.binPath || agent.path || '' } }
|
|
: {}),
|
|
};
|
|
},
|
|
[],
|
|
);
|
|
|
|
return {
|
|
discoveredAgents,
|
|
unconfiguredAgents,
|
|
isDiscovering,
|
|
rediscover: () => discover({ refreshShellEnv: true }),
|
|
enableAgent,
|
|
};
|
|
}
|