[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,540 @@
/**
* SDK Agent Adapter
*
* Bridges managed external agents through IPC. The main process runs the
* official SDK drivers and forwards stream events to the renderer.
*/
import type {
AgentActivity,
AgentUsage,
AIPermissionMode,
AIToolIntegrationMode,
ExternalAgentConfig,
} from './types';
import { getExternalAgentSdkBackend, getManualAgentCommand } from './managedAgents';
import { encodeSdkSessionIdentity } from './harness/sdkSessionIdentity';
import { mapSdkStreamEventToAgentEvents } from './harness/agentEventAdapter';
import { globalTraceStore } from './harness/traceStore';
import { decryptField } from '../persistence/secureFieldAdapter';
export interface DefaultTargetSessionHint {
sessionId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
source: 'scope-target' | 'only-connected-in-scope';
}
export interface SdkAgentCallbacks {
onSessionId?: (sessionId: string) => void;
onTextDelta: (text: string) => void;
onThinkingDelta: (text: string) => void;
onThinkingDone: () => void;
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => void;
onToolResult: (toolCallId: string, result: string, toolName?: string) => void;
onFileChange?: (activity: Extract<AgentActivity, { type: 'file_change' }>) => void;
onWebSearch?: (activity: Extract<AgentActivity, { type: 'web_search' }>) => void;
onPlanUpdate?: (activity: Extract<AgentActivity, { type: 'plan_update' }>) => void;
onWarning?: (activity: Extract<AgentActivity, { type: 'warning' }>) => void;
onUsage?: (usage: AgentUsage) => void;
onStatus?: (message: string) => void;
onHook?: (hookEvent: string, payload: Record<string, unknown>) => void;
onElicitationCreate?: (elicitationId: string, request: Record<string, unknown>) => void;
onElicitationComplete?: (notification: Record<string, unknown>) => void;
onError: (error: string) => void;
onDone: () => void;
}
interface SdkAgentBridge {
aiSdkAgentStream(
requestId: string,
chatSessionId: string,
sdkBackend: string,
prompt: string,
cwd?: string,
providerId?: string,
model?: string,
existingSessionId?: string,
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
images?: FileAttachment[],
toolIntegrationMode?: AIToolIntegrationMode,
defaultTargetSession?: DefaultTargetSessionHint,
userSkillsContext?: string,
agentEnv?: Record<string, string>,
agentCommand?: string,
codexRuntime?: 'sdk' | 'app-server',
permissionMode?: AIPermissionMode,
codebuddyOptions?: ExternalAgentConfig['codebuddyOptions'],
): Promise<{ ok: boolean; error?: unknown }>;
aiSdkAgentSteer?(
requestId: string,
chatSessionId: string,
prompt: string,
images: FileAttachment[] | undefined,
clientUserMessageId: string,
): Promise<SdkAgentSteerResult>;
aiSdkAgentCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
onAiSdkAgentEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
onAiSdkAgentDone(requestId: string, cb: () => void): () => void;
onAiSdkAgentError(requestId: string, cb: (error: unknown) => void): () => void;
}
export type SdkAgentSteerResult =
| { status: 'accepted' }
| {
status: 'not-steerable' | 'busy' | 'inactive' | 'unsupported' | 'cancelled' | 'failed';
message?: string;
turnKind?: 'review' | 'compact';
};
interface StreamEvent {
type: string;
[key: string]: unknown;
}
export interface FileAttachment {
base64Data: string;
mediaType: string;
filename?: string;
filePath?: string;
}
export async function steerSdkAgentTurn(
bridge: unknown,
requestId: string,
chatSessionId: string,
prompt: string,
images: FileAttachment[] | undefined,
clientUserMessageId: string,
): Promise<SdkAgentSteerResult> {
const sdkBridge = bridge as SdkAgentBridge | null;
if (!sdkBridge?.aiSdkAgentSteer) return { status: 'unsupported' };
try {
return await sdkBridge.aiSdkAgentSteer(
requestId,
chatSessionId,
prompt,
images,
clientUserMessageId,
);
} catch (error) {
return {
status: 'failed',
message: error instanceof Error ? error.message : String(error),
};
}
}
async function buildAgentEnvWithStoredApiKey(
sdkBackend: string,
config: ExternalAgentConfig,
): Promise<Record<string, string> | undefined> {
const env = { ...(config.env ?? {}) };
if (sdkBackend === 'cursor') {
const authMode = config.cursorAuthMode === 'cli-login' ? 'cli-login' : 'api-key';
env.NETCATTY_CURSOR_AUTH_MODE = authMode;
const cliBin = String(config.command || '').trim();
if (authMode === 'cli-login') {
if (cliBin && cliBin !== 'cursor') {
env.NETCATTY_CURSOR_CLI_BIN = cliBin;
}
return Object.keys(env).length > 0 ? env : undefined;
}
if (config.apiKey) {
const decrypted = await decryptField(config.apiKey).catch(() => config.apiKey);
const apiKey = String(decrypted || '').trim();
if (apiKey) {
env.CURSOR_API_KEY = apiKey;
}
}
}
return Object.keys(env).length > 0 ? env : undefined;
}
function safeJsonStringify(value: unknown): string | null {
const seen = new WeakSet<object>();
try {
return JSON.stringify(value, (_key, nestedValue: unknown) => {
if (typeof nestedValue !== 'object' || nestedValue === null) {
return nestedValue;
}
if (seen.has(nestedValue)) {
return '[Circular]';
}
seen.add(nestedValue);
return nestedValue;
});
} catch {
return null;
}
}
function formatSdkAgentErrorValue(error: unknown, seen = new WeakSet<object>()): string {
if (error == null) return '';
if (typeof error === 'string') return error;
if (typeof error === 'number' || typeof error === 'boolean') return String(error);
if (error instanceof Error) return error.message || error.name || '';
if (typeof error !== 'object') return String(error);
if (seen.has(error)) return '[Circular error]';
seen.add(error);
const record = error as Record<string, unknown>;
const data = record.data as Record<string, unknown> | undefined;
const nestedError = record.error as Record<string, unknown> | undefined;
const candidates: unknown[] = [
data?.message,
data?.error,
record.errorText,
record.message,
record.error,
record.cause,
nestedError?.message,
record.data,
];
for (const candidate of candidates) {
const message = formatSdkAgentErrorValue(candidate, seen).trim();
if (message && message !== '{}') {
return message;
}
}
return safeJsonStringify(error) || String(error);
}
export function formatSdkAgentErrorForDisplay(error: unknown): string {
return formatSdkAgentErrorValue(error).trim() || 'Unknown error';
}
export async function runSdkAgentTurn(
bridge: Record<string, (...args: unknown[]) => unknown>,
requestId: string,
chatSessionId: string,
config: ExternalAgentConfig,
prompt: string,
callbacks: SdkAgentCallbacks,
signal?: AbortSignal,
providerId?: string,
model?: string,
existingSessionId?: string,
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
images?: FileAttachment[],
toolIntegrationMode?: AIToolIntegrationMode,
defaultTargetSession?: DefaultTargetSessionHint,
userSkillsContext?: string,
permissionMode?: AIPermissionMode,
harnessOptions?: {
traceSink?: (event: import('./harness/types').AgentEvent) => void;
skipHarnessTrace?: boolean;
},
): Promise<void> {
const sdkBridge = bridge as unknown as SdkAgentBridge;
const sdkBackend = getExternalAgentSdkBackend(config);
if (!sdkBackend) {
callbacks.onError('Agent has no SDK backend configured');
return;
}
const cleanupFns: (() => void)[] = [];
let settled = false;
let resolveDone: () => void = () => {};
const donePromise = new Promise<void>((resolve) => {
resolveDone = resolve;
});
const settle = (fn?: () => void) => {
if (settled) return false;
settled = true;
fn?.();
resolveDone();
return true;
};
const baseAgentEnv = await buildAgentEnvWithStoredApiKey(sdkBackend, config);
// Grok dual runtime: prefer Settings grokRuntime; env override still works in main.
const agentEnv = sdkBackend === 'grok'
? {
...baseAgentEnv,
NETCATTY_GROK_RUNTIME: config.grokRuntime === 'streaming-json' ? 'streaming-json' : 'acp',
}
: baseAgentEnv;
const agentCommand = getManualAgentCommand(config);
// Set up event listeners before starting stream
if (!harnessOptions?.skipHarnessTrace) {
globalTraceStore.append({
id: `turn-start-${requestId}`,
type: 'turn_start',
sessionId: chatSessionId,
chatSessionId,
backend: 'external-sdk',
timestamp: Date.now(),
backendLabel: sdkBackend,
});
}
const appendHarnessEvent = (event: import('./harness/types').AgentEvent) => {
if (harnessOptions?.traceSink) {
harnessOptions.traceSink(event);
return;
}
if (!harnessOptions?.skipHarnessTrace) {
globalTraceStore.append(event);
}
};
const unsubEvent = sdkBridge.onAiSdkAgentEvent(requestId, (event: StreamEvent) => {
for (const agentEvent of mapSdkStreamEventToAgentEvents(event, {
sessionId: chatSessionId,
chatSessionId,
turnId: requestId,
})) {
appendHarnessEvent(agentEvent);
}
const streamFailed = handleStreamEvent(event, callbacks);
if (streamFailed) {
settle();
}
});
cleanupFns.push(unsubEvent);
const unsubDone = sdkBridge.onAiSdkAgentDone(requestId, () => {
settle(() => {
callbacks.onDone();
});
});
cleanupFns.push(unsubDone);
const unsubError = sdkBridge.onAiSdkAgentError(requestId, (error: unknown) => {
settle(() => {
callbacks.onError(formatSdkAgentErrorForDisplay(error));
});
});
cleanupFns.push(unsubError);
// Handle abort
if (signal) {
if (signal.aborted) {
cleanup(cleanupFns);
return;
}
const onAbort = () => {
if (!settle()) {
return;
}
sdkBridge.aiSdkAgentCancel(requestId, chatSessionId).catch(() => {});
};
signal.addEventListener('abort', onAbort, { once: true });
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
}
// Start the SDK stream in the main process
void sdkBridge.aiSdkAgentStream(
requestId,
chatSessionId,
sdkBackend,
prompt,
undefined, // cwd
providerId,
model,
existingSessionId,
historyMessages,
images?.length ? images : undefined,
toolIntegrationMode,
defaultTargetSession,
userSkillsContext,
agentEnv,
agentCommand,
sdkBackend === 'codex' ? (config.codexRuntime ?? 'sdk') : undefined,
permissionMode,
sdkBackend === 'codebuddy' ? config.codebuddyOptions : undefined,
).then((result) => {
if (result?.ok === false) {
settle(() => {
callbacks.onError(
result.error == null
? 'Failed to start SDK agent stream'
: formatSdkAgentErrorForDisplay(result.error),
);
});
}
}).catch((err: unknown) => {
settle(() => {
callbacks.onError(formatSdkAgentErrorForDisplay(err));
});
}).finally(() => {
if (settled) {
cleanup(cleanupFns);
}
});
// Wait for done or error
await donePromise;
cleanup(cleanupFns);
}
function cleanup(fns: (() => void)[]) {
for (const fn of fns) {
try { fn(); } catch { /* */ }
}
}
/**
* Handle a single stream event from the AI SDK stream.
* Events come from `streamText().stream` in the main process.
*/
function handleStreamEvent(event: StreamEvent, callbacks: SdkAgentCallbacks): boolean {
switch (event.type) {
case 'text-delta': {
const text = (event.textDelta as string) || (event.delta as string) || '';
if (text) callbacks.onTextDelta(text);
return false;
}
case 'reasoning-start': {
// Reasoning block started — nothing to render yet
return false;
}
case 'reasoning-delta': {
const text = (event.delta as string) || '';
if (text) callbacks.onThinkingDelta(text);
return false;
}
case 'reasoning-end': {
callbacks.onThinkingDone();
return false;
}
case 'tool-call': {
const toolName = (event.toolName as string) || 'unknown';
// The Electron bridge emits tool args as `args` (see sdk/emit.cjs
// toolCall), while direct AI SDK paths use `input`. Read both so
// either source works.
const input =
(event.input as Record<string, unknown>) ||
(event.args as Record<string, unknown>) ||
{};
const toolCallId = (event.toolCallId as string) || undefined;
callbacks.onToolCall(toolName, input, toolCallId);
return false;
}
case 'tool-result': {
const toolCallId = (event.toolCallId as string) || '';
const toolName = (event.toolName as string) || undefined;
const output = event.output ?? event.result;
const result = typeof output === 'string'
? output
: JSON.stringify(output);
callbacks.onToolResult(toolCallId, result, toolName);
return false;
}
case 'file-change': {
callbacks.onFileChange?.({
id: (event.itemId as string) || '',
type: 'file_change',
status: event.status === 'failed' ? 'failed' : 'completed',
changes: Array.isArray(event.changes)
? event.changes as Extract<AgentActivity, { type: 'file_change' }>['changes']
: [],
});
return false;
}
case 'web-search': {
callbacks.onWebSearch?.({
id: (event.itemId as string) || '',
type: 'web_search',
status: event.status === 'completed' ? 'completed' : 'running',
query: (event.query as string) || '',
});
return false;
}
case 'plan-update': {
callbacks.onPlanUpdate?.({
id: (event.itemId as string) || '',
type: 'plan_update',
status: event.status === 'completed' ? 'completed' : 'running',
items: Array.isArray(event.items)
? event.items as Extract<AgentActivity, { type: 'plan_update' }>['items']
: [],
});
return false;
}
case 'warning': {
const message = (event.message as string) || '';
if (message) {
callbacks.onWarning?.({
id: (event.itemId as string) || '',
type: 'warning',
status: 'completed',
message,
});
}
return false;
}
case 'usage': {
const inputTokens = Number(event.inputTokens) || 0;
const outputTokens = Number(event.outputTokens) || 0;
callbacks.onUsage?.({
inputTokens,
cachedInputTokens: Number(event.cachedInputTokens) || 0,
outputTokens,
reasoningTokens: Number(event.reasoningTokens) || 0,
totalTokens: Number(event.totalTokens) || inputTokens + outputTokens,
estimated: false,
});
return false;
}
case 'status': {
const msg = (event.message as string) || '';
if (msg) callbacks.onStatus?.(msg);
return false;
}
case 'session-id': {
const sessionId = (event.sessionId as string) || '';
if (sessionId) {
const runtimeRaw = String(event.runtime || 'sdk');
callbacks.onSessionId?.(encodeSdkSessionIdentity(
sessionId,
event.sdkBackend as string | undefined,
event.binPath as string | undefined,
// Preserve Codex + Grok dual-runtime tokens (do not collapse Grok to "sdk").
runtimeRaw,
event.authMode as string | undefined,
event.cliMode as string | undefined,
));
}
return false;
}
case 'hook': {
const hookEvent = (event.hookEvent as string) || '';
if (hookEvent) {
callbacks.onHook?.(hookEvent, event as Record<string, unknown>);
}
return false;
}
case 'elicitation-create': {
const elicitationId = (event.elicitationId as string) || '';
const request = (event.request as Record<string, unknown>) || {};
if (elicitationId) {
callbacks.onElicitationCreate?.(elicitationId, request);
}
return false;
}
case 'elicitation-complete': {
const notification = (event.notification as Record<string, unknown>) || {};
callbacks.onElicitationComplete?.(notification);
return false;
}
case 'error': {
callbacks.onError(formatSdkAgentErrorForDisplay(event.error));
return true;
}
// step-start, step-finish, etc. — ignore silently
default:
return false;
}
}