mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(agents): native Xiaomi MiMo Code support (#6239)
* feat(agents): native Xiaomi MiMo Code support Add mimo-code TUI agent (detect mimo, --prompt, --session resume). Inject MIMOCODE_HOME overlay and /hook/mimo-code status plugin on mimo launch when agent status hooks are enabled; restore via shell-ready wrappers. Reuse OpenCode-family hook normalization in the listener. SSH remote MiMo hook overlays are not included (local/daemon first). Closes #6220 * fix(mimo): remirror overlay config idempotently rmSync overlay config before mirror so a second mimo launch does not hit EEXIST in mirrorEntry and fall back to the user MIMOCODE_HOME. * review: harden mimo code support Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Doan Bac Tam <24356000+doanbactam@users.noreply.github.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
co-authored by
Orca
Doan Bac Tam
Jinwoo-H
parent
b76a2ddae6
commit
0c0367ea88
@@ -178,6 +178,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
|
||||
@@ -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<TuiAgent, string> = {
|
||||
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<Record<TuiAgent, string>>
|
||||
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<TuiAgent, string> = {
|
||||
grok: 'grok',
|
||||
copilot: 'copilot',
|
||||
opencode: 'opencode',
|
||||
'mimo-code': 'mimo',
|
||||
ante: 'ante',
|
||||
pi: 'pi',
|
||||
omp: 'omp',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, string>): 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, string>): 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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, string> {
|
||||
// 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()
|
||||
@@ -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 || "",',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
() => {
|
||||
|
||||
@@ -40,7 +40,10 @@ function windowsShellArgs(shellName: string): string[] | null {
|
||||
|
||||
function hasOverlayRestoreEnv(env: Record<string, string>): 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
|
||||
|
||||
@@ -289,7 +289,8 @@
|
||||
"bf53f09bf8": "Claude Agent Teams",
|
||||
"0708ed89f1": "Claude",
|
||||
"fc80296033": "Devin",
|
||||
"da41abbdd4": "Ante"
|
||||
"da41abbdd4": "Ante",
|
||||
"mimo_code_label": "MiMo Code"
|
||||
},
|
||||
"skill": {
|
||||
"cli": {
|
||||
|
||||
@@ -289,7 +289,8 @@
|
||||
"bf53f09bf8": "Claude Agent Teams",
|
||||
"0708ed89f1": "Claude",
|
||||
"fc80296033": "Devin",
|
||||
"da41abbdd4": "Ante"
|
||||
"da41abbdd4": "Ante",
|
||||
"mimo_code_label": "MiMo Code"
|
||||
},
|
||||
"skill": {
|
||||
"cli": {
|
||||
|
||||
@@ -289,7 +289,8 @@
|
||||
"bf53f09bf8": "Claude Agent Teams",
|
||||
"0708ed89f1": "Claude",
|
||||
"fc80296033": "Devin",
|
||||
"da41abbdd4": "Ante"
|
||||
"da41abbdd4": "Ante",
|
||||
"mimo_code_label": "MiMo Code"
|
||||
},
|
||||
"skill": {
|
||||
"cli": {
|
||||
|
||||
@@ -289,7 +289,8 @@
|
||||
"bf53f09bf8": "Claude Agent Teams",
|
||||
"0708ed89f1": "Claude",
|
||||
"fc80296033": "Devin",
|
||||
"da41abbdd4": "Ante"
|
||||
"da41abbdd4": "Ante",
|
||||
"mimo_code_label": "MiMo Code"
|
||||
},
|
||||
"skill": {
|
||||
"cli": {
|
||||
|
||||
@@ -289,7 +289,8 @@
|
||||
"bf53f09bf8": "Claude Agent Teams",
|
||||
"0708ed89f1": "Claude",
|
||||
"fc80296033": "Devin",
|
||||
"da41abbdd4": "Ante"
|
||||
"da41abbdd4": "Ante",
|
||||
"mimo_code_label": "MiMo Code"
|
||||
},
|
||||
"skill": {
|
||||
"cli": {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -121,6 +121,7 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
|
||||
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<TuiAgent, true> = {
|
||||
codex: true,
|
||||
autohand: true,
|
||||
opencode: true,
|
||||
'mimo-code': true,
|
||||
pi: true,
|
||||
omp: true,
|
||||
gemini: true,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, AgentHookSource>>
|
||||
'/hook/antigravity': 'antigravity',
|
||||
'/hook/amp': 'amp',
|
||||
'/hook/opencode': 'opencode',
|
||||
'/hook/mimo-code': 'mimo-code',
|
||||
'/hook/cursor': 'cursor',
|
||||
'/hook/pi': 'pi',
|
||||
'/hook/omp': 'omp',
|
||||
|
||||
@@ -38,6 +38,7 @@ export type AgentHookSource =
|
||||
| 'antigravity'
|
||||
| 'amp'
|
||||
| 'opencode'
|
||||
| 'mimo-code'
|
||||
| 'cursor'
|
||||
| 'pi'
|
||||
| 'omp'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -22,6 +22,7 @@ export const AGENT_NAMES = [
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'opencode',
|
||||
'mimo',
|
||||
'openclaw',
|
||||
'aider',
|
||||
'grok',
|
||||
|
||||
@@ -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']]
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -21,6 +21,7 @@ export type WellKnownAgentType =
|
||||
| 'antigravity'
|
||||
| 'amp'
|
||||
| 'opencode'
|
||||
| 'mimo-code'
|
||||
| 'cursor'
|
||||
| 'copilot'
|
||||
| 'aider'
|
||||
|
||||
@@ -75,6 +75,7 @@ export const AGENT_KIND_VALUES = [
|
||||
'codex',
|
||||
'autohand',
|
||||
'opencode',
|
||||
'mimo-code',
|
||||
'pi',
|
||||
'omp',
|
||||
'gemini',
|
||||
|
||||
@@ -116,6 +116,12 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
|
||||
expectedProcess: 'opencode',
|
||||
promptInjectionMode: 'flag-prompt'
|
||||
},
|
||||
'mimo-code': {
|
||||
detectCmd: 'mimo',
|
||||
launchCmd: 'mimo',
|
||||
expectedProcess: 'mimo',
|
||||
promptInjectionMode: 'flag-prompt'
|
||||
},
|
||||
pi: {
|
||||
detectCmd: 'pi',
|
||||
launchCmd: 'pi',
|
||||
|
||||
@@ -14,6 +14,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record<TuiAgent, string> = {
|
||||
ante: 'Ante',
|
||||
autohand: 'Autohand Code',
|
||||
opencode: 'OpenCode',
|
||||
'mimo-code': 'MiMo Code',
|
||||
pi: 'Pi',
|
||||
omp: 'OMP',
|
||||
gemini: 'Gemini',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [
|
||||
'grok',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'mimo-code',
|
||||
'ante',
|
||||
'pi',
|
||||
'omp',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user