diff --git a/README.md b/README.md index bb86cc4c9e5..dd6aa3d24de 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   OpenCode logo OpenCode   + MiMo Code logo MiMo Code   Amp logo Amp   OpenClaude logo OpenClaude   Antigravity logo Antigravity   diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index 50e58612075..1c9ef440ac0 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -11,6 +11,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [ 'grok', 'copilot', 'opencode', + 'mimo-code', 'ante', 'pi', 'omp', @@ -47,6 +48,7 @@ export const MOBILE_TUI_AGENT_LABELS: Record = { grok: 'Grok', copilot: 'GitHub Copilot', opencode: 'OpenCode', + 'mimo-code': 'MiMo Code', ante: 'Ante', pi: 'Pi', omp: 'OMP', @@ -80,6 +82,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> grok: 'x.ai', copilot: 'github.com', opencode: 'opencode.ai', + 'mimo-code': 'mimo.xiaomi.com', ante: 'antigma.ai', omp: 'omp.sh', gemini: 'gemini.google.com', @@ -114,6 +117,7 @@ export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record = { grok: 'grok', copilot: 'copilot', opencode: 'opencode', + 'mimo-code': 'mimo', ante: 'ante', pi: 'pi', omp: 'omp', diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index f052f603cf2..05391b88b89 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -80,7 +80,8 @@ export function agentIdentityLabel(agentType: string | null): string { copilot: 'CP', amp: 'AM', aider: 'AI', - opencode: 'OC' + opencode: 'OC', + 'mimo-code': 'MC' } return known[normalized] ?? normalized.slice(0, 2).toUpperCase() } diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 75afb290310..c0ac8d3a7ab 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -48,6 +48,7 @@ import { createPtySubprocess } from './pty-subprocess' const ORCA_SHELL_WRAPPER_ENV = [ 'ORCA_ATTRIBUTION_SHIM_DIR', 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_MIMOCODE_HOME', 'ORCA_PI_CODING_AGENT_DIR', 'ORCA_OMP_CODING_AGENT_DIR', 'ORCA_CODEX_HOME' @@ -906,6 +907,35 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') }) + it('uses shell wrapper when MiMo home must survive shell startup', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { + SHELL: '/bin/zsh', + MIMOCODE_HOME: '/tmp/orca-mimocode-overlay', + ORCA_MIMOCODE_HOME: '/tmp/orca-mimocode-overlay' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[1]).toEqual(['-l']) + expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) + expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + }) + it('uses shell wrapper when typed OMP commands need the status extension', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 22fd03412b6..0e6e817dc33 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -593,6 +593,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellLaunch = env.ORCA_ATTRIBUTION_SHIM_DIR || env.ORCA_OPENCODE_CONFIG_DIR || + env.ORCA_MIMOCODE_HOME || env.ORCA_OMP_STATUS_EXTENSION || env.ORCA_CODEX_HOME || env.ORCA_AGENT_TEAMS_SHIM_DIR diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 16111573417..ebe7474cd24 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -472,6 +472,8 @@ describePosix('daemon shell-ready launch config', () => { const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') const restoreLine = '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' + const mimoRestoreLine = + '[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"' const codexRestoreLine = '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' @@ -479,6 +481,9 @@ describePosix('daemon shell-ready launch config', () => { expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) + expect(zshrc).toContain(mimoRestoreLine) + expect(zlogin).toContain(mimoRestoreLine) + expect(bashRc).toContain(mimoRestoreLine) expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR') expect(zlogin).not.toContain('ORCA_PI_CODING_AGENT_DIR') expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR') diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 05f3c25e7d5..065edb603b8 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -114,6 +114,7 @@ __orca_restore_agent_teams_path # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" @@ -226,6 +227,7 @@ __orca_restore_agent_teams_path() { if [[ ! -o login ]]; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" + [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi @@ -288,6 +290,7 @@ __orca_restore_agent_teams_path() { __orca_restore_agent_teams_path # Why: .zlogin is the final login startup file before the prompt is shown. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 5e0261c601a..0187f2afaef 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -38,6 +38,7 @@ const { spawnMock, openCodeBuildPtyEnvMock, openCodeClearPtyMock, + mimoCodeBuildPtyEnvMock, buildAgentHookEnvMock, clearAgentHookPaneStateMock, registerPaneKeyAliasMock, @@ -67,6 +68,7 @@ const { getPathMock: vi.fn(), spawnMock: vi.fn(), openCodeBuildPtyEnvMock: vi.fn(), + mimoCodeBuildPtyEnvMock: vi.fn(), isPwshAvailableMock: vi.fn(), openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), @@ -124,6 +126,12 @@ vi.mock('../opencode/hook-service', () => ({ } })) +vi.mock('../mimo/hook-service', () => ({ + mimoCodeHookService: { + buildPtyEnv: mimoCodeBuildPtyEnvMock + } +})) + vi.mock('../agent-hooks/server', () => ({ agentHookServer: { buildPtyEnv: buildAgentHookEnvMock, @@ -271,6 +279,7 @@ describe('registerPtyHandlers', () => { getPathMock.mockReset() spawnMock.mockReset() openCodeBuildPtyEnvMock.mockReset() + mimoCodeBuildPtyEnvMock.mockReset() openCodeClearPtyMock.mockReset() buildAgentHookEnvMock.mockReset() clearAgentHookPaneStateMock.mockReset() @@ -304,6 +313,9 @@ describe('registerPtyHandlers', () => { ? '/tmp/orca-opencode-overlay' : '/tmp/orca-opencode-config' })) + mimoCodeBuildPtyEnvMock.mockImplementation((_ptyId: string, existingHome?: string) => ({ + MIMOCODE_HOME: existingHome ? '/tmp/orca-mimocode-overlay' : '/tmp/orca-mimocode-shared' + })) buildAgentHookEnvMock.mockReturnValue({ ORCA_AGENT_HOOK_PORT: '5678', ORCA_AGENT_HOOK_TOKEN: 'agent-token' @@ -758,6 +770,51 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() }) + it('injects MiMo overlay env only when launch command is mimo', async () => { + const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, 'mimo') + + expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1) + expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined() + }) + + it.each(['/usr/local/bin/mimo --prompt hi', '"C:\\Program Files\\MiMo\\mimo.cmd" --prompt hi'])( + 'injects MiMo overlay env for path-qualified launch command %s', + async (launchCommand) => { + const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, launchCommand) + + expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1) + expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + } + ) + + it('does not inject MiMo overlay for non-mimo launches', async () => { + await spawnAndGetEnv() + + expect(mimoCodeBuildPtyEnvMock).not.toHaveBeenCalled() + }) + + it('restores user MiMo home when agent status hooks are disabled in a nested Orca shell', async () => { + const env = await spawnAndGetEnv( + { + MIMOCODE_HOME: '/tmp/parent-orca-mimocode-overlay', + ORCA_MIMOCODE_HOME: '/tmp/parent-orca-mimocode-overlay', + ORCA_MIMOCODE_SOURCE_HOME: '/tmp/user-mimocode-home' + }, + undefined, + undefined, + () => ({ agentStatusHooksEnabled: false }), + 'mimo' + ) + + expect(mimoCodeBuildPtyEnvMock).not.toHaveBeenCalled() + expect(env.MIMOCODE_HOME).toBe('/tmp/user-mimocode-home') + expect(env.ORCA_MIMOCODE_HOME).toBeUndefined() + expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined() + }) + posixOnlyIt( 'reproduces issue #1534: GUI-launched Orca mirrors zshrc-only OpenCode config', async () => { @@ -1903,6 +1960,9 @@ describe('registerPtyHandlers', () => { expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(env.MIMOCODE_HOME).toBeUndefined() + expect(env.ORCA_MIMOCODE_HOME).toBeUndefined() + expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined() expect(env.PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 1d02b3deb4c..b391e01ca40 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -24,6 +24,11 @@ import { resolveLocalWindowsTerminalRuntimeOptions } from '../../shared/local-windows-terminal-runtime' import { openCodeHookService } from '../opencode/hook-service' +import { mimoCodeHookService } from '../mimo/hook-service' +import { + getCommandTokenPathBasename, + getFirstCommandToken +} from '../../shared/command-token-scanner' import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' @@ -611,6 +616,26 @@ function restoreOrStripOverlayEnv( delete baseEnv[keys.source] } +function isMimoLaunchCommand(launchCommand: string | undefined): boolean { + const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) + .toLowerCase() + .replace(/\.(?:cmd|exe|sh)$/, '') + return binary === 'mimo' +} + +function resolveMimocodeSourceHome(baseEnv: Record): string | undefined { + const sourceHome = baseEnv.ORCA_MIMOCODE_SOURCE_HOME ?? process.env.ORCA_MIMOCODE_SOURCE_HOME + if (sourceHome) { + return sourceHome + } + const configHome = baseEnv.MIMOCODE_HOME ?? process.env.MIMOCODE_HOME + const orcaHome = baseEnv.ORCA_MIMOCODE_HOME ?? process.env.ORCA_MIMOCODE_HOME + if (configHome && orcaHome && configHome === orcaHome) { + return undefined + } + return configHome +} + function resolveOpenCodeSourceConfigDir(baseEnv: Record): string | undefined { const sourceDir = baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR @@ -697,12 +722,29 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR } } + if (isMimoLaunchCommand(opts.launchCommand)) { + const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) + Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) + if (baseEnv.MIMOCODE_HOME) { + baseEnv.ORCA_MIMOCODE_HOME = baseEnv.MIMOCODE_HOME + if (preexistingMimocodeHome) { + baseEnv.ORCA_MIMOCODE_SOURCE_HOME = preexistingMimocodeHome + } else { + delete baseEnv.ORCA_MIMOCODE_SOURCE_HOME + } + } + } } else { restoreOrStripOverlayEnv(baseEnv, { primary: 'OPENCODE_CONFIG_DIR', overlay: 'ORCA_OPENCODE_CONFIG_DIR', source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' }) + restoreOrStripOverlayEnv(baseEnv, { + primary: 'MIMOCODE_HOME', + overlay: 'ORCA_MIMOCODE_HOME', + source: 'ORCA_MIMOCODE_SOURCE_HOME' + }) } // Why: Claude/Codex native hooks run inside the shell process, so Orca diff --git a/src/main/mimo/hook-service.test.ts b/src/main/mimo/hook-service.test.ts new file mode 100644 index 00000000000..0439c727339 --- /dev/null +++ b/src/main/mimo/hook-service.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +import { MimoCodeHookService } from './hook-service' + +describe('MimoCodeHookService buildPtyEnv', () => { + let userDataDir: string + let mimocodeHome: string + + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-mimocode-userdata-')) + getPathMock.mockImplementation((name) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected getPath: ${name}`) + }) + + mimocodeHome = mkdtempSync(join(tmpdir(), 'orca-mimocode-home-')) + const configDir = join(mimocodeHome, 'config') + mkdirSync(join(configDir, 'plugins'), { recursive: true }) + writeFileSync(join(configDir, 'mimocode.json'), '{"theme":"dark"}') + writeFileSync(join(configDir, 'plugins', 'user-plugin.js'), 'export default () => {}') + writeFileSync(join(configDir, 'plugins', 'orca-mimocode-status.js'), 'USER PLUGIN') + }) + + afterEach(() => { + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mimocodeHome, { recursive: true, force: true }) + }) + + it('mirrors user config into shared overlay and installs Orca status plugin', () => { + const service = new MimoCodeHookService() + const env = service.buildPtyEnv('pty-1', mimocodeHome) + + const overlayHome = join(userDataDir, 'mimocode-hooks', 'shared') + expect(env.MIMOCODE_HOME).toBe(overlayHome) + expect(readFileSync(join(overlayHome, 'config', 'mimocode.json'), 'utf8')).toBe( + '{"theme":"dark"}' + ) + expect(readFileSync(join(overlayHome, 'config', 'plugins', 'user-plugin.js'), 'utf8')).toBe( + 'export default () => {}' + ) + + const orcaPlugin = join(overlayHome, 'config', 'plugins', 'orca-mimocode-status.js') + expect(existsSync(orcaPlugin)).toBe(true) + expect(readFileSync(orcaPlugin, 'utf8')).toContain('/hook/mimo-code') + + expect( + readFileSync(join(mimocodeHome, 'config', 'plugins', 'orca-mimocode-status.js'), 'utf8') + ).toBe('USER PLUGIN') + }) + + it('reuses the overlay home on a second buildPtyEnv call', () => { + const service = new MimoCodeHookService() + const first = service.buildPtyEnv('pty-1', mimocodeHome) + const second = service.buildPtyEnv('pty-2', mimocodeHome) + + const overlayHome = join(userDataDir, 'mimocode-hooks', 'shared') + expect(first.MIMOCODE_HOME).toBe(overlayHome) + expect(second.MIMOCODE_HOME).toBe(overlayHome) + expect( + readFileSync(join(overlayHome, 'config', 'plugins', 'orca-mimocode-status.js'), 'utf8') + ).toContain('/hook/mimo-code') + }) +}) diff --git a/src/main/mimo/hook-service.ts b/src/main/mimo/hook-service.ts new file mode 100644 index 00000000000..2cc9f542b94 --- /dev/null +++ b/src/main/mimo/hook-service.ts @@ -0,0 +1,80 @@ +import { app } from 'electron' +import { join } from 'path' +import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'fs' +import { homedir } from 'os' +import { getOpenCodeFamilyPluginSource } from '../opencode/hook-service' +import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' + +const ORCA_MIMOCODE_PLUGIN_FILE = 'orca-mimocode-status.js' +const MIMOCODE_HOOKS_DIR = 'mimocode-hooks' +const MIMOCODE_SHARED_HOME = 'shared' + +function defaultMimocodeConfigDir(): string { + return join(homedir(), '.config', 'mimocode') +} + +function resolveSourceConfigDir(existingHome: string | undefined): string | undefined { + if (existingHome) { + const fromHome = join(existingHome, 'config') + if (existsSync(fromHome)) { + return fromHome + } + } + const xdg = defaultMimocodeConfigDir() + return existsSync(xdg) ? xdg : undefined +} + +function mirrorConfigDir(sourceConfigDir: string, targetConfigDir: string): void { + mkdirSync(targetConfigDir, { recursive: true }) + for (const entry of readdirSync(sourceConfigDir, { withFileTypes: true })) { + if (entry.name === 'plugins' && entry.isDirectory()) { + const overlayPlugins = join(targetConfigDir, 'plugins') + mkdirSync(overlayPlugins, { recursive: true }) + for (const pluginEntry of readdirSync(join(sourceConfigDir, 'plugins'), { + withFileTypes: true + })) { + if (pluginEntry.name === ORCA_MIMOCODE_PLUGIN_FILE) { + continue + } + mirrorEntry( + join(sourceConfigDir, 'plugins', pluginEntry.name), + join(overlayPlugins, pluginEntry.name) + ) + } + continue + } + mirrorEntry(join(sourceConfigDir, entry.name), join(targetConfigDir, entry.name)) + } +} + +export class MimoCodeHookService { + clearPty(_ptyId: string): void {} + + buildPtyEnv(_ptyId: string, existingMimocodeHome?: string): Record { + // Why: MiMo currently uses a shared home; per-source subdirs can come + // later if concurrent MiMo panes need isolated runtime state. + const home = join(app.getPath('userData'), MIMOCODE_HOOKS_DIR, MIMOCODE_SHARED_HOME) + try { + for (const sub of ['config', 'data', 'cache', 'state'] as const) { + mkdirSync(join(home, sub), { recursive: true }) + } + const overlayConfig = join(home, 'config') + const sourceConfig = resolveSourceConfigDir(existingMimocodeHome) + if (sourceConfig) { + safeRemoveTree(overlayConfig) + mirrorConfigDir(sourceConfig, overlayConfig) + } + const pluginsDir = join(home, 'config', 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + writeFileSync( + join(pluginsDir, ORCA_MIMOCODE_PLUGIN_FILE), + getOpenCodeFamilyPluginSource('/hook/mimo-code') + ) + } catch { + return existingMimocodeHome ? { MIMOCODE_HOME: existingMimocodeHome } : {} + } + return { MIMOCODE_HOME: home } + } +} + +export const mimoCodeHookService = new MimoCodeHookService() diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 6a734d4a8d1..20bc863b5ce 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -58,7 +58,11 @@ function toSafeDirName(id: string): string { return createHash('sha256').update(id).digest('hex').slice(0, 32) } -function getOpenCodePluginSource(): string { +export function getOpenCodePluginSource(): string { + return getOpenCodeFamilyPluginSource('/hook/opencode') +} + +export function getOpenCodeFamilyPluginSource(hookPathname: string): string { // Why: the plugin runs inside the OpenCode Node process and POSTs to the // unified agent-hooks server shared with Claude/Codex/Gemini. It reads the // same ORCA_PANE_KEY / ORCA_TAB_ID / ORCA_WORKTREE_ID / ORCA_AGENT_HOOK_* @@ -260,7 +264,7 @@ function getOpenCodePluginSource(): string { ' const coords = resolveHookCoords();', ' const paneKey = process.env.ORCA_PANE_KEY;', ' if (!coords.port || !coords.token || !paneKey) return;', - ' const url = `http://127.0.0.1:${coords.port}/hook/opencode`;', + ` const url = \`http://127.0.0.1:\${coords.port}${hookPathname}\`;`, ' const body = JSON.stringify({', ' paneKey,', ' launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || "",', diff --git a/src/main/powershell-osc133-bootstrap.test.ts b/src/main/powershell-osc133-bootstrap.test.ts index 80f9fd0880a..eedfb6159d7 100644 --- a/src/main/powershell-osc133-bootstrap.test.ts +++ b/src/main/powershell-osc133-bootstrap.test.ts @@ -10,6 +10,7 @@ describe('PowerShell OSC 133 bootstrap', () => { expect(script).toContain('[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()') expect(script).toContain('ORCA_OPENCODE_CONFIG_DIR') + expect(script).toContain('ORCA_MIMOCODE_HOME') expect(script).not.toContain('ORCA_PI_CODING_AGENT_DIR') expect(script).not.toContain('ORCA_OMP_CODING_AGENT_DIR') expect(script).toContain('ORCA_OMP_STATUS_EXTENSION') diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index cb4e3177a46..bad6cbba6ea 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -24,6 +24,7 @@ try { # Profiles can re-export user defaults after Orca's spawn env is set. if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR } +if ($env:ORCA_MIMOCODE_HOME) { $env:MIMOCODE_HOME = $env:ORCA_MIMOCODE_HOME } ${getPowerShellOmpShellWrapper()} if ($env:ORCA_CODEX_HOME) { $env:CODEX_HOME = $env:ORCA_CODEX_HOME } diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index c058fa57151..54a7838c87a 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -307,6 +307,23 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() }) + it('uses shell wrapper when MiMo home must survive shell startup', async () => { + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.MIMOCODE_HOME = '/tmp/orca-mimocode-overlay' + env.ORCA_MIMOCODE_HOME = '/tmp/orca-mimocode-overlay' + return env + } + }) + + await provider.spawn({ cols: 80, rows: 24 }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[1]).toEqual(['-l']) + expect(spawnCall[2].env.ZDOTDIR).toMatch(/shell-ready[\\/]zsh/) + expect(spawnCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + }) + it('does not pass a Windows Codex home into WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) provider.configure({ diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index c64a5511315..5b61c20e5f3 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -539,6 +539,7 @@ export class LocalPtyProvider implements IPtyProvider { const needsNoMarkerWrapper = finalEnv.ORCA_ATTRIBUTION_SHIM_DIR || finalEnv.ORCA_OPENCODE_CONFIG_DIR || + finalEnv.ORCA_MIMOCODE_HOME || finalEnv.ORCA_OMP_STATUS_EXTENSION || finalEnv.ORCA_CODEX_HOME || finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 8eed31c3b93..f40db2c5931 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -437,6 +437,8 @@ describePosix('local PTY shell-ready launch config', () => { const bashRc = getBashShellReadyRcfileContent() const restoreLine = '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' + const mimoRestoreLine = + '[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"' const codexRestoreLine = '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' @@ -444,6 +446,9 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) + expect(zshrc).toContain(mimoRestoreLine) + expect(zlogin).toContain(mimoRestoreLine) + expect(bashRc).toContain(mimoRestoreLine) expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR') expect(zlogin).not.toContain('ORCA_PI_CODING_AGENT_DIR') expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR') diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index d8bbf94f69f..6a7c1d1fa73 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -140,6 +140,7 @@ __orca_restore_agent_teams_path # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" @@ -255,6 +256,7 @@ __orca_restore_agent_teams_path() { if [[ ! -o login ]]; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" @@ -318,6 +320,7 @@ __orca_restore_agent_teams_path() { __orca_restore_agent_teams_path # Why: .zlogin is the final login startup file before the prompt is shown. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 04799b7ec6f..483c8a1fe83 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -105,6 +105,16 @@ describe('resolveGroupAddress', () => { expect(result).toEqual(['term_b']) }) + it('matches @mimo by terminal title', () => { + const terminals = [ + makeSummary('term_a', { title: 'mimo' }), + makeSummary('term_b', { title: 'MiMo Code session' }), + makeSummary('term_c', { title: 'OpenCode' }) + ] + const result = resolveGroupAddress('@mimo', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + it('matches @openclaude by terminal title', () => { const terminals = [ makeSummary('term_a', { title: 'OpenClaude' }), diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index baa3110f456..c897ae03866 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -4,7 +4,15 @@ import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' // Resolution is done at send-time: one message record per recipient, same thread_id, // so each recipient gets their own read-tracking (Section 4.5). -const AGENT_NAME_GROUPS = ['claude', 'openclaude', 'codex', 'opencode', 'gemini', 'droid'] as const +const AGENT_NAME_GROUPS = [ + 'claude', + 'openclaude', + 'codex', + 'opencode', + 'mimo', + 'gemini', + 'droid' +] as const export type GroupAddress = | '@all' diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index 9f020b06e91..aec824b3758 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -137,6 +137,24 @@ describe('getRelayShellLaunchConfig', () => { ) }) + it.skipIf(process.platform === 'win32')( + 'wraps zsh when MiMo home must survive shell startup', + () => { + const config = getRelayShellLaunchConfig('/bin/zsh', { + HOME: homeDir, + ORCA_MIMOCODE_HOME: '/tmp/orca-mimocode-overlay' + }) + const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') + const zshrc = readFileSync(join(zshRoot, '.zshrc'), 'utf8') + + expect(config.args).toEqual(['-l']) + expect(config.env.ZDOTDIR).toBe(zshRoot) + expect(zshrc).toContain( + '[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"' + ) + } + ) + it.skipIf(process.platform === 'win32')( 'wraps bash even without overlay env for OSC 133 lifecycle markers', () => { diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index a9a3e221c36..d2ee25e2d41 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -40,7 +40,10 @@ function windowsShellArgs(shellName: string): string[] | null { function hasOverlayRestoreEnv(env: Record): boolean { return Boolean( - env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_REMOTE_CLI_BIN_DIR || env.ORCA_OMP_STATUS_EXTENSION + env.ORCA_OPENCODE_CONFIG_DIR || + env.ORCA_MIMOCODE_HOME || + env.ORCA_REMOTE_CLI_BIN_DIR || + env.ORCA_OMP_STATUS_EXTENSION ) } @@ -100,6 +103,7 @@ ${getZshStartupFileSourceBlock({ if [[ ! -o login ]]; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" + [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} fi @@ -115,6 +119,7 @@ ${getZshStartupFileSourceBlock({ })} # Why: .zlogin is the final zsh login startup file before the prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} ${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')} @@ -131,6 +136,7 @@ elif [[ -f "$HOME/.profile" ]]; then fi # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} # Why: SSH bash sessions need the same command lifecycle markers as local diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index db36d7b51b5..7e004df6698 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -289,7 +289,8 @@ "bf53f09bf8": "Claude Agent Teams", "0708ed89f1": "Claude", "fc80296033": "Devin", - "da41abbdd4": "Ante" + "da41abbdd4": "Ante", + "mimo_code_label": "MiMo Code" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 495c24dfeaf..d01aecb6416 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -289,7 +289,8 @@ "bf53f09bf8": "Claude Agent Teams", "0708ed89f1": "Claude", "fc80296033": "Devin", - "da41abbdd4": "Ante" + "da41abbdd4": "Ante", + "mimo_code_label": "MiMo Code" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 78a9c4dffad..138e10813fd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -289,7 +289,8 @@ "bf53f09bf8": "Claude Agent Teams", "0708ed89f1": "Claude", "fc80296033": "Devin", - "da41abbdd4": "Ante" + "da41abbdd4": "Ante", + "mimo_code_label": "MiMo Code" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 00981aea841..64bf5cd45da 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -289,7 +289,8 @@ "bf53f09bf8": "Claude Agent Teams", "0708ed89f1": "Claude", "fc80296033": "Devin", - "da41abbdd4": "Ante" + "da41abbdd4": "Ante", + "mimo_code_label": "MiMo Code" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 36f73ea9abb..1e46ef2fb90 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -289,7 +289,8 @@ "bf53f09bf8": "Claude Agent Teams", "0708ed89f1": "Claude", "fc80296033": "Devin", - "da41abbdd4": "Ante" + "da41abbdd4": "Ante", + "mimo_code_label": "MiMo Code" }, "skill": { "cli": { diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 5b4d0ca2587..e93f32d03db 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -89,6 +89,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => faviconDomain: 'opencode.ai', homepageUrl: 'https://opencode.ai/docs/cli/' }, + { + id: 'mimo-code', + label: translate('auto.lib.agent.catalog.mimo_code_label', 'MiMo Code'), + cmd: 'mimo', + faviconDomain: 'mimo.xiaomi.com', + homepageUrl: 'https://mimo.xiaomi.com/coder' + }, { id: 'ante', label: translate('auto.lib.agent.catalog.da41abbdd4', 'Ante'), diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 05461437229..39618185154 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -121,6 +121,7 @@ const WELL_KNOWN_LABELS: Record = { amp: 'Amp', copilot: 'GitHub Copilot', opencode: 'OpenCode', + 'mimo-code': 'MiMo Code', cursor: 'Cursor', aider: 'Aider', pi: 'Pi', @@ -162,6 +163,7 @@ const ICONABLE_AGENT_TYPES: Record = { codex: true, autohand: true, opencode: true, + 'mimo-code': true, pi: true, omp: true, gemini: true, diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index 6154e9be242..1a42e362013 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { extractAllOscTitles, extractLastOscTitle, MAX_OSC_TITLE_CHARS } from './agent-detection' +import { + detectAgentStatusFromTitle, + extractAllOscTitles, + extractLastOscTitle, + getAgentLabel, + MAX_OSC_TITLE_CHARS +} from './agent-detection' afterEach(() => { vi.restoreAllMocks() @@ -51,3 +57,23 @@ describe('OSC title extraction', () => { expect(extractAllOscTitles(data)).toEqual([extracted]) }) }) + +describe('MiMo title detection', () => { + it.each([ + ['MiMo Code', 'idle'], + ['mimo ready', 'idle'], + ['mimo working', 'working'], + ['\u280b MiMo Code', 'working'] + ] as const)('classifies %s', (title, expectedStatus) => { + expect(getAgentLabel(title)).toBe('MiMo Code') + expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus) + }) + + it.each(['~/mimo/working', 'mimo-code-fixtures ready'])( + 'does not classify path or hyphen false positive %s', + (title) => { + expect(getAgentLabel(title)).toBeNull() + expect(detectAgentStatusFromTitle(title)).toBeNull() + } + ) +}) diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 27412a53529..4b808935142 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -350,6 +350,9 @@ export function getAgentLabel(title: string): string | null { if (titleHasAgentName(title, 'opencode')) { return 'OpenCode' } + if (titleHasAgentName(title, 'mimo')) { + return 'MiMo Code' + } if (titleHasAgentName(title, 'aider')) { return 'Aider' } diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 55f005facb9..f208450a7a7 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -91,6 +91,7 @@ describe('shared agent-hook-listener', () => { expect(resolveHookSource('/hook/pi')).toBe('pi') expect(resolveHookSource('/hook/omp')).toBe('omp') expect(resolveHookSource('/hook/command-code')).toBe('command-code') + expect(resolveHookSource('/hook/mimo-code')).toBe('mimo-code') expect(resolveHookSource('/hook/unknown')).toBeNull() expect(resolveHookSource('/')).toBeNull() }) @@ -544,6 +545,55 @@ describe('shared agent-hook-listener', () => { expect(stopped?.providerSession).toMatchObject({ key: 'session_id', id: 'session_abc' }) }) + it('normalizes MiMo Code OpenCode-compatible lifecycle events as mimo-code status', () => { + const message = normalizeHookPayload( + state, + 'mimo-code', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'MessagePart', + sessionID: 'mimo-session', + messageID: 'message-1', + role: 'user', + text: 'ship the fix' + } + }, + 'production' + ) + const tool = normalizeHookPayload( + state, + 'mimo-code', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'SessionBusy', + sessionID: 'mimo-session' + } + }, + 'production' + ) + const idle = normalizeHookPayload( + state, + 'mimo-code', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionIdle', sessionID: 'mimo-session' } + }, + 'production' + ) + + expect(message?.payload).toMatchObject({ + agentType: 'mimo-code', + state: 'working', + prompt: 'ship the fix' + }) + expect(message?.promptInteractionKey).toBe('mimo-code-message-message-1') + expect(message?.providerSession).toMatchObject({ key: 'session_id', id: 'mimo-session' }) + expect(tool?.payload).toMatchObject({ agentType: 'mimo-code', state: 'working' }) + expect(idle?.payload).toMatchObject({ agentType: 'mimo-code', state: 'done' }) + }) + it('maps Kimi AskUserQuestion PreToolUse to waiting, then back to working on answer', () => { const question = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ba0d5acde1f..f3149fd79b0 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -1895,6 +1895,7 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { case 'amp': return eventName === 'agent.start' case 'opencode': + case 'mimo-code': return false case 'cursor': return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' @@ -1947,7 +1948,7 @@ function hasExplicitUserPrompt( return true } if (extractedPrompt.source === 'role_user_text') { - return source === 'opencode' && eventName === 'MessagePart' + return (source === 'opencode' || source === 'mimo-code') && eventName === 'MessagePart' } if (extractedPrompt.text.length === 0) { return false @@ -1988,6 +1989,7 @@ function extractToolFields( case 'amp': return extractAmpToolFields(eventName, hookPayload) case 'opencode': + case 'mimo-code': return extractOpenCodeToolFields(eventName, hookPayload) case 'cursor': return extractCursorToolFields(eventName, hookPayload) @@ -2520,7 +2522,8 @@ function normalizeCodexEvent( ) } -function normalizeOpenCodeEvent( +function normalizeOpenCodeFamilyEvent( + source: 'opencode' | 'mimo-code', state: HookListenerState, eventName: unknown, promptText: string, @@ -2543,17 +2546,17 @@ function normalizeOpenCodeEvent( const snapshot = resolveToolState( state, paneKey, - extractToolFields('opencode', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('opencode', eventName) } + extractToolFields(source, eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent(source, eventName) } ) return parseAgentStatusPayload( JSON.stringify({ state: stateName, prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('opencode', eventName) + resetOnNewTurn: isNewTurnEvent(source, eventName) }), - agentType: 'opencode', + agentType: source, toolName: snapshot.toolName, toolInput: snapshot.toolInput, lastAssistantMessage: snapshot.lastAssistantMessage @@ -3054,15 +3057,24 @@ export function normalizeHookPayload( payload = normalizeAmpEvent(state, eventName, promptText, paneKey, hookPayloadRecord) break case 'opencode': + case 'mimo-code': if (extractedPrompt.source === 'role_user_text') { const messageId = readFirstString(hookPayloadRecord, [ 'messageID', 'messageId', 'message_id' ]) - promptInteractionKey = messageId ? `opencode-message-${messageId}` : undefined + const prefix = source === 'mimo-code' ? 'mimo-code-message' : 'opencode-message' + promptInteractionKey = messageId ? `${prefix}-${messageId}` : undefined } - payload = normalizeOpenCodeEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + payload = normalizeOpenCodeFamilyEvent( + source, + state, + eventName, + promptText, + paneKey, + hookPayloadRecord + ) break case 'cursor': payload = normalizeCursorEvent(state, eventName, promptText, paneKey, hookPayloadRecord) @@ -3171,6 +3183,7 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/antigravity': 'antigravity', '/hook/amp': 'amp', '/hook/opencode': 'opencode', + '/hook/mimo-code': 'mimo-code', '/hook/cursor': 'cursor', '/hook/pi': 'pi', '/hook/omp': 'omp', diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index df3ddcf9910..4500dc3f26e 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -38,6 +38,7 @@ export type AgentHookSource = | 'antigravity' | 'amp' | 'opencode' + | 'mimo-code' | 'cursor' | 'pi' | 'omp' diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index fa933dbe3c8..ddacd75659b 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -20,6 +20,7 @@ const TUI_AGENT_KIND_BY_AGENT = { codex: 'codex', autohand: 'autohand', opencode: 'opencode', + 'mimo-code': 'mimo-code', pi: 'pi', omp: 'omp', gemini: 'gemini', diff --git a/src/shared/agent-name-token-match.ts b/src/shared/agent-name-token-match.ts index c99d2dc3bcb..157f1c8f608 100644 --- a/src/shared/agent-name-token-match.ts +++ b/src/shared/agent-name-token-match.ts @@ -22,6 +22,7 @@ export const AGENT_NAMES = [ 'gemini', 'antigravity', 'opencode', + 'mimo', 'openclaw', 'aider', 'grok', diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts index 720f4a48c8b..8469a9c0cd5 100644 --- a/src/shared/agent-session-resume.test.ts +++ b/src/shared/agent-session-resume.test.ts @@ -21,6 +21,7 @@ describe('agent session resume metadata', () => { { key: 'conversation_id', id: 'agy-conversation' } ], ['opencode', { sessionID: 'opencode-session' }, { key: 'session_id', id: 'opencode-session' }], + ['mimo-code', { sessionID: 'mimo-session' }, { key: 'session_id', id: 'mimo-session' }], ['droid', { session_id: 'droid-session' }, { key: 'session_id', id: 'droid-session' }], ['grok', { sessionId: 'grok-session' }, { key: 'session_id', id: 'grok-session' }], ['devin', { session_id: 'devin-session' }, { key: 'session_id', id: 'devin-session' }] @@ -34,6 +35,7 @@ describe('agent session resume metadata', () => { ['gemini', { key: 'session_id', id: 's1' }, ['gemini', '--resume', 's1']], ['antigravity', { key: 'conversation_id', id: 's1' }, ['agy', '--conversation', 's1']], ['opencode', { key: 'session_id', id: 's1' }, ['opencode', '--session', 's1']], + ['mimo-code', { key: 'session_id', id: 's1' }, ['mimo', '--session', 's1']], ['droid', { key: 'session_id', id: 's1' }, ['droid', '--resume', 's1']], ['grok', { key: 'session_id', id: 's1' }, ['grok', '--resume', 's1']], ['devin', { key: 'session_id', id: 'abc12345' }, ['devin', '--resume', 'abc12345']] diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index aacd548dfed..f135e90d6be 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -8,6 +8,7 @@ export const RESUMABLE_TUI_AGENTS = [ 'gemini', 'antigravity', 'opencode', + 'mimo-code', 'droid', 'grok', 'devin' @@ -125,7 +126,8 @@ export function extractAgentProviderSession( const id = readSessionId(payload, ['conversationId']) return id ? { key: 'conversation_id', id } : null } - case 'opencode': { + case 'opencode': + case 'mimo-code': { const id = readSessionId(payload, ['sessionID']) return id ? { key: 'session_id', id } : null } @@ -164,6 +166,8 @@ export function getAgentResumeArgv( return providerSession.key === 'conversation_id' ? ['agy', '--conversation', id] : null case 'opencode': return providerSession.key === 'session_id' ? ['opencode', '--session', id] : null + case 'mimo-code': + return providerSession.key === 'session_id' ? ['mimo', '--session', id] : null case 'droid': return providerSession.key === 'session_id' ? ['droid', '--resume', id] : null case 'grok': diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 9c199550d53..75257f8aab4 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -21,6 +21,7 @@ export type WellKnownAgentType = | 'antigravity' | 'amp' | 'opencode' + | 'mimo-code' | 'cursor' | 'copilot' | 'aider' diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index e2083eaf2e2..7ea6439b81e 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -75,6 +75,7 @@ export const AGENT_KIND_VALUES = [ 'codex', 'autohand', 'opencode', + 'mimo-code', 'pi', 'omp', 'gemini', diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 8f1488a0ec9..bea54d857bc 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -116,6 +116,12 @@ export const TUI_AGENT_CONFIG: Record = { expectedProcess: 'opencode', promptInjectionMode: 'flag-prompt' }, + 'mimo-code': { + detectCmd: 'mimo', + launchCmd: 'mimo', + expectedProcess: 'mimo', + promptInjectionMode: 'flag-prompt' + }, pi: { detectCmd: 'pi', launchCmd: 'pi', diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index 8d16ae926e2..4d62d35f961 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -14,6 +14,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { ante: 'Ante', autohand: 'Autohand Code', opencode: 'OpenCode', + 'mimo-code': 'MiMo Code', pi: 'Pi', omp: 'OMP', gemini: 'Gemini', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index caedd8a0338..7ac9d252713 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -11,6 +11,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'grok', 'copilot', 'opencode', + 'mimo-code', 'ante', 'pi', 'omp', diff --git a/src/shared/types.ts b/src/shared/types.ts index c56b9af4acc..9d904f1c6a7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2221,6 +2221,7 @@ export type TuiAgent = | 'codex' // OpenAI Codex | 'autohand' // Autohand Code CLI | 'opencode' // OpenCode + | 'mimo-code' | 'pi' // Pi (pi.dev) | 'omp' // OMP (omp.sh) | 'gemini' // Gemini CLI