Files
Netcatty/components/AIChatSidePanelHelpers.ts
T
GOUKI9999 f1425c8d5a feat(ai): add MiMo Code (mimo) agent support (#3500)
* feat(ai): add MiMo Code (mimo) agent support scaffolding

Registry/wiring only: discovery entry, managed-agent metadata, model presets, icon, i18n and Settings UI for Xiaomi's MiMo Code CLI (an OpenCode fork). The SDK driver is intentionally not wired yet - it depends on the pending @mimo-ai/sdk parity probe, so DRIVER_REGISTRY keeps a TODO and run-turn/list-models reject the mimo backend until then.

* feat(ai): add MiMo Code (mimo) agent support

Adds Xiaomi's MiMo Code CLI as a managed agent backend, mirroring the existing
OpenCode integration (discovery, managed-agent metadata, model presets, icon,
i18n, Settings UI) and adding the SDK driver that was left as a TODO in the
scaffolding commit.

Driver notes:
- New electron/bridges/aiBridge/sdk/mimoDriver.cjs reuses the pure translation
  helpers exported by opencodeDriver.cjs (config/prompt building, event
  translation, model mapping) because MiMo Code is an OpenCode fork and speaks
  the same wire protocol.
- It does NOT use @mimo-ai/sdk's createOpencode(). On 0.1.15 that helper spawns
  a binary named "opencode", waits for an "opencode server listening" banner,
  and passes config via OPENCODE_CONFIG_CONTENT; the mimo CLI prints
  "mimocode server listening on <url>", reads MIMOCODE_CONFIG_CONTENT, and ships
  as "mimo"/"mimo.exe". So createOpencode() can only ever time out. The driver
  runs "mimo serve" itself, parses the readiness URL, and attaches the SDK's
  createOpencodeClient({ baseUrl }).
- Binary resolution honours binPath, MIMOCODE_BIN and MIMOCODE_BIN_PATH, then
  falls back to PATH via resolveCliFromPath (the npm shim is mimo.cmd on
  Windows, which Node cannot spawn directly).
- @mimo-ai/sdk ^0.1.15 added as a dependency.

Verified on Windows against mimo 0.1.15 + @mimo-ai/sdk 0.1.15:
- "mimo serve --port" boots and reports its URL; driver parses it.
- SDK symbols used all exist: createOpencodeClient, client.config.providers(),
  client.global.event() (SSE stream), client.session.create/abort/promptAsync.
- Live provider catalog reads 2 providers (xiaomi, mimo); MIMO_MODEL_PRESETS
  updated to the real lower-case provider-prefixed ids and its TODO removed.
- Server process is killed on close (taskkill tree kill), no orphan processes.

Not yet verified at runtime:
- A full end-to-end turn (no prompt was sent, so no tokens were spent): text /
  reasoning / tool-part streaming and abort handling are untested against a
  live model.
- MIMOCODE_CONFIG_CONTENT accepting the OpenCode config schema (permission,
  mcp, model, share, autoupdate) end-to-end; only that the env var exists in
  the binary has been confirmed.
- Whether the loopback server needs MIMOCODE_SERVER_PASSWORD when the API
  client attaches.
- The placeholder public/ai/agents/mimo.svg still needs an official asset.
- Multi-turn resume (resumeSessionId) and MCP injection against the live CLI.

* feat(ai): inject MIMOCODE_BIN for discovered MiMo Code agents

Mirror the OpenCode OPENCODE_BIN handling so a discovered or re-detected
mimo binary path reaches the driver, and cover it with the same assertion
shape as the opencode managed-agent env test.

* fix(ai): use MiMo branding in the mimo driver

* test(ai): cover the mimo driver

Add a unit test file for the MiMo Code driver, matching the existing
opencode driver test in shape, runner (node:test via node --test) and
`.test.cjs` extension. It is the last driver in the registry without
one.

Coverage:
- spawn/launch: `mimo serve --hostname/--port` argv, binary resolution
  (explicit binPath / MIMOCODE_BIN / MIMOCODE_BIN_PATH), pass-through
  env and MIMOCODE_CONFIG_CONTENT; `child_process.spawn` is stubbed, so
  no real `mimo` process is ever started.
- readiness: parsing of `mimocode server listening on <url>` from
  stdout (including split chunks) and stderr, the OpenCode spelling,
  and the MIMO_SERVE_TIMEOUT_MS timeout path (asserts the rejection and
  that the child is killed).
- abort/cleanup: stopMimoProcess kill/idempotency/swallow, the Windows
  taskkill tree kill, server.close() teardown, and abort mid-turn with
  a throwing close().
- branding: OpenCode -> MiMo Code for the busy/idle/status, failed and
  tool-failure fallback strings, with tool output and non-brand
  technical errors left verbatim.
- happy path: session create -> streamed event translation -> done,
  plus listMimoModels catalog mapping and the empty/aborted paths.

* fix(ai): filter MiMo events by session and stop the server behind the npm shim

Address review on #3500:

- The global SSE stream carries every session on the server, and
  runMimoTurn translated all of them, so another session's text could leak
  into the reply and its session.idle could end the turn early. Skip events
  whose session id is not ours, reusing the OpenCode driver's
  getOpenCodeSessionIdFromEvent (now exported). Covered by a test with
  interleaved sessions.
- On macOS/Linux the official @mimo-ai/cli `mimo` is a Node shim that runs
  the native binary through spawnSync, so killing the shim left the server
  listening. `mimo serve` is now spawned detached on POSIX and
  stopMimoProcess signals its whole process group (SIGTERM, then SIGKILL
  after a grace period). Windows keeps taskkill /T.

Verified against the real @mimo-ai/cli@0.1.15 npm shim: on Linux (WSL
Ubuntu 22.04) the listener is the native .mimocode child; before this change
it was still listening 3s after close(), now the port is released within 1s
and no mimo process remains. On Windows the .cmd shim path also releases the
port within 1s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: harden MiMo CLI integration before merge

* fix: wait for MiMo events and handle setup cancellation

* fix: finish MiMo stream handling and scope skill access

* fix: preflight MiMo skill paths before one server startup

* fix: scope MiMo skill reads to trusted roots

* fix: allow MiMo skills from ancestor project roots

* fix: honor MiMo observer and confirm command permissions

* fix: clear MiMo approval when its service exits

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-29 14:51:22 +08:00

314 lines
10 KiB
TypeScript

import {
resolveAgentModelSelection,
type AgentModelPreset,
type ExternalAgentConfig,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
import {
canonicalizeEffortEncodedModelId,
modelPresetMatchesId,
modelPresetsContainId,
} from '../infrastructure/ai/composerPicker';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex } from '@noble/hashes/utils.js';
export {
canonicalizeEffortEncodedModelId,
modelPresetMatchesId,
modelPresetsContainId,
};
export type SdkRuntimeModelCatalog = {
currentModelId: string | null;
models: AgentModelPreset[];
};
export type SdkRuntimeModelCacheEntry = SdkRuntimeModelCatalog & {
updatedAt: number;
};
type SdkRuntimeModelCacheOptions = {
ttlMs?: number;
maxEntries?: number;
now?: () => number;
};
type SdkRuntimeModelRefreshOptions = {
force?: boolean;
};
const SDK_RUNTIME_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
const SDK_RUNTIME_MODEL_CACHE_MAX_ENTRIES = 64;
// Keep in sync with main-process SDK_MODEL_CACHE_ENV_KEYS: profile-affecting
// env must bust the renderer cache so we re-query after OpenCode config switches.
const MODEL_CACHE_ENV_HINTS = [
'HOME',
'USERPROFILE',
'XDG_CONFIG_HOME',
'CODEX_HOME',
'CLAUDE_CONFIG_DIR',
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'OPENCODE_BIN',
'OPENCODE_CONFIG',
'OPENCODE_CONFIG_DIR',
'OPENCODE_CONFIG_CONTENT',
'MIMOCODE_HOME',
'MIMOCODE_BIN',
'MIMOCODE_BIN_PATH',
'MIMOCODE_CONFIG',
'MIMOCODE_CONFIG_DIR',
'MIMOCODE_MIMO_ONLY',
'XDG_DATA_HOME',
'XDG_CACHE_HOME',
'CLAUDE_CODE_EXECUTABLE',
'CODEBUDDY_CODE_PATH',
'CURSOR_API_KEY',
'NETCATTY_CURSOR_AUTH_MODE',
'NETCATTY_CURSOR_CLI_BIN',
] as const;
function cloneCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
return {
currentModelId: catalog.currentModelId ?? null,
models: [...catalog.models],
};
}
function normalizeSdkRuntimeModelCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
return {
currentModelId: catalog.currentModelId ?? null,
models: Array.isArray(catalog.models)
? catalog.models.filter((model): model is AgentModelPreset => Boolean(model?.id))
: [],
};
}
/**
* Inject Cursor auth-mode env for list-models IPC.
* Mirrors run-turn `buildAgentEnvWithStoredApiKey` (without decrypting API keys):
* persisted `agent.env` strips NETCATTY_CURSOR_* via sanitization, so list-models
* must re-inject from `cursorAuthMode` / `command` or main defaults to api-key.
*/
export function buildCursorListModelsAgentEnv(agent: {
env?: Record<string, string>;
cursorAuthMode?: 'cli-login' | 'api-key';
command?: string;
}): Record<string, string> | undefined {
const env = { ...(agent.env ?? {}) };
const authMode = agent.cursorAuthMode === 'cli-login' ? 'cli-login' : 'api-key';
env.NETCATTY_CURSOR_AUTH_MODE = authMode;
const cliBin = String(agent.command || '').trim();
if (authMode === 'cli-login' && cliBin && cliBin !== 'cursor') {
env.NETCATTY_CURSOR_CLI_BIN = cliBin;
}
return Object.keys(env).length > 0 ? env : undefined;
}
export function buildSdkRuntimeModelCacheKey(agent: {
id: string;
command?: string;
sdkBackend?: string;
acpCommand?: string;
env?: Record<string, string>;
codexRuntime?: 'sdk' | 'app-server';
grokRuntime?: 'acp' | 'streaming-json';
cursorAuthMode?: 'cli-login' | 'api-key';
}): string {
const sdkBackend = agent.sdkBackend || agent.acpCommand || '';
const envHints = MODEL_CACHE_ENV_HINTS.map((key) => `${key}=${agent.env?.[key] ?? ''}`);
// cursorAuthMode is the source of truth when NETCATTY_CURSOR_AUTH_MODE was
// stripped from persisted env; include it so toggling auth mode busts cache.
const cursorAuth = sdkBackend === 'cursor'
? (agent.cursorAuthMode === 'cli-login' ? 'cli-login' : 'api-key')
: '';
const grokRuntime = sdkBackend === 'grok'
? (agent.grokRuntime === 'streaming-json' ? 'streaming-json' : 'acp')
: '';
// Keep authentication values out of the in-memory cache key itself.
const envHash = bytesToHex(sha256(new TextEncoder().encode(envHints.join('\u0000'))));
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, envHash].join('\u0000');
}
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
const ttlMs = options.ttlMs ?? SDK_RUNTIME_MODEL_CACHE_TTL_MS;
const maxEntries = Math.max(1, options.maxEntries ?? SDK_RUNTIME_MODEL_CACHE_MAX_ENTRIES);
const now = options.now ?? (() => Date.now());
const entries = new Map<string, SdkRuntimeModelCacheEntry>();
const inFlight = new Map<string, Promise<SdkRuntimeModelCatalog>>();
const pruneEntries = () => {
const currentTime = now();
for (const [key, entry] of entries) {
if (currentTime - entry.updatedAt >= ttlMs) {
entries.delete(key);
}
}
while (entries.size > maxEntries) {
const oldestKey = entries.keys().next().value;
if (oldestKey === undefined) break;
entries.delete(oldestKey);
}
};
const touchEntry = (key: string, entry: SdkRuntimeModelCacheEntry) => {
entries.delete(key);
entries.set(key, entry);
};
return {
read(key: string): SdkRuntimeModelCacheEntry | null {
pruneEntries();
const entry = entries.get(key);
if (entry) touchEntry(key, entry);
return entry ? { ...cloneCatalog(entry), updatedAt: entry.updatedAt } : null;
},
size(): number {
pruneEntries();
return entries.size;
},
refresh(
key: string,
load: () => Promise<SdkRuntimeModelCatalog>,
refreshOptions: SdkRuntimeModelRefreshOptions = {},
): Promise<SdkRuntimeModelCatalog> {
pruneEntries();
const cached = entries.get(key);
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
touchEntry(key, cached);
return Promise.resolve(cloneCatalog(cached));
}
const existing = inFlight.get(key);
if (existing) return existing;
const promise = Promise.resolve(load())
.then((catalog) => {
const normalized = normalizeSdkRuntimeModelCatalog(catalog);
if (normalized.models.length === 0 && !normalized.currentModelId) {
return cached ? cloneCatalog(cached) : cloneCatalog(normalized);
}
touchEntry(key, { ...cloneCatalog(normalized), updatedAt: now() });
pruneEntries();
return cloneCatalog(normalized);
})
.finally(() => {
if (inFlight.get(key) === promise) {
inFlight.delete(key);
}
});
inFlight.set(key, promise);
return promise;
},
};
}
export const sdkRuntimeModelCache = createSdkRuntimeModelCache();
export function mergeFallbackThinkingLevels(
runtime: AgentModelPreset[],
fallbacks: AgentModelPreset[],
): AgentModelPreset[] {
if (runtime.length === 0 || fallbacks.length === 0) return runtime;
const byId = new Map(fallbacks.map((preset) => [preset.id, preset]));
let changed = false;
const next = runtime.map((preset) => {
if (preset.thinkingLevels !== undefined) return preset;
const fallback = byId.get(preset.id);
if (!fallback?.thinkingLevels?.length) return preset;
changed = true;
return {
...preset,
thinkingLevels: [...fallback.thinkingLevels],
...(fallback.defaultThinkingLevel
? { defaultThinkingLevel: fallback.defaultThinkingLevel }
: {}),
...(fallback.encodeDefaultThinking === false
? { encodeDefaultThinking: false }
: {}),
};
});
return changed ? next : runtime;
}
export function agentModelPresetsShallowEqual(
left: AgentModelPreset[] | undefined,
right: AgentModelPreset[] | undefined,
): boolean {
if (left === right) return true;
if (!left || !right || left.length !== right.length) return false;
return left.every((preset, index) => {
const other = right[index];
return (
preset.id === other.id
&& preset.name === other.name
&& preset.defaultThinkingLevel === other.defaultThinkingLevel
&& (preset.thinkingLevels ?? []).join('\0') === (other.thinkingLevels ?? []).join('\0')
);
});
}
export function normalizeStoredAgentModelSelection(
storedModelId: string | null | undefined,
presets: AgentModelPreset[],
): string | undefined {
if (!storedModelId) return undefined;
const canonical = canonicalizeEffortEncodedModelId(storedModelId);
const preset = presets.find((candidate) => modelPresetMatchesId(candidate, canonical));
if (!preset) return undefined;
return canonical === preset.id
? resolveAgentModelSelection(preset)
: canonical;
}
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
// Codex on the default `sdk` runtime also serves a live catalog: its driver
// falls back to the App Server runtime's model/list in main (#3496), so
// quick sends must await it instead of running with build-time presets.
return sdkBackend === 'codex'
|| sdkBackend === 'claude'
|| sdkBackend === 'copilot'
|| sdkBackend === 'cursor'
|| sdkBackend === 'codebuddy'
|| sdkBackend === 'opencode'
|| sdkBackend === 'grok'
|| sdkBackend === 'mimo';
}
export function shouldAdoptSdkCurrentModel(
currentModelId: string | null | undefined,
storedModelId: string | null | undefined,
runtimePresets: AgentModelPreset[],
): boolean {
if (!currentModelId) return false;
return !storedModelId
|| runtimePresets.length === 0
|| !modelPresetsContainId(runtimePresets, storedModelId);
}
export function normalizeSdkRuntimeModelPresets(
models: AgentModelPreset[],
currentModelId: string | null | undefined,
): AgentModelPreset[] {
if (models.length > 0) return models;
if (!currentModelId) return [];
return [{ id: currentModelId, name: currentModelId }];
}
export function shouldUseStoredAgentModel(
storedModelId: string | null | undefined,
presets: AgentModelPreset[],
agent?: ExternalAgentConfig,
): boolean {
if (!storedModelId) return false;
return modelPresetsContainId(presets, storedModelId)
|| (presets.length === 0 && shouldLoadSdkRuntimeModels(agent));
}
export function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}