[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
236
application/state/useAgentDiscovery.ts
Normal file
236
application/state/useAgentDiscovery.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user