@mobius-os/mobius 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "dependencies": {
19
19
  "chalk": "^5.3.0",
20
20
  "cli-highlight": "2.1.11",
21
+ "extract-zip": "^2.0.1",
21
22
  "ink": "5.2.0",
22
23
  "marked": "12.0.2",
23
24
  "react": "18.3.1",
package/src/aimux.ts CHANGED
@@ -6,9 +6,11 @@
6
6
  * currently authenticated Mobius server. Nothing is started before login.
7
7
  */
8
8
  import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
9
- import { promises as fs, existsSync } from 'node:fs'
9
+ import { promises as fs, existsSync, createWriteStream } from 'node:fs'
10
10
  import os from 'node:os'
11
11
  import path from 'node:path'
12
+ import { Readable } from 'node:stream'
13
+ import extract from 'extract-zip'
12
14
  import { mobiusHome } from './config.js'
13
15
 
14
16
  export type AimuxState = 'starting' | 'connected' | 'failed' | 'stopped' | 'disabled'
@@ -22,6 +24,11 @@ export interface AimuxStatus {
22
24
  }
23
25
  export interface InstallProgress { phase: 'python' | 'venv' | 'install' | 'ready'; detail?: string }
24
26
 
27
+ /** aimux 的调用方式:venv 直接执行,或用内置 python 跑 `-m aimux`(Plan B 兜底)。 */
28
+ export type AimuxLauncher =
29
+ | { kind: 'exe'; path: string }
30
+ | { kind: 'module'; python: string }
31
+
25
32
  const AIMUX_PACKAGE = 'aimux'
26
33
  const WIN = process.platform === 'win32'
27
34
  const venvDir = () => path.join(mobiusHome(), 'aimux-venv')
@@ -76,21 +83,125 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
76
83
  return findPython() ?? installPython(onProgress)
77
84
  }
78
85
 
79
- export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string }> {
80
- if (existsSync(aimuxExe()) && existsSync(venvPython())) { onProgress?.({ phase: 'ready' }); return { ok: true } }
86
+ // ── Plan B: 内置 python+aimux 运行时(本地 venv/pip 失败时的离线兜底)─────────
87
+ // 一个 zip 内含完整的 python-build-standalone(自带 ensurepip+pip)+ 预装 aimux;
88
+ // 解压到 ~/.mobius/python-bundle/ 后用 `<python> -m aimux` 运行,彻底绕开宿主机
89
+ // 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
90
+ // 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
91
+ const BUNDLE_VER = '1'
92
+ const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
93
+ const bundlePython = () => WIN
94
+ ? path.join(bundleDir(), 'python', 'python.exe')
95
+ : path.join(bundleDir(), 'python', 'bin', 'python3')
96
+
97
+ /** 当前平台对应的内置运行时包名;mac-arm64 走 mac-x64(Rosetta 2)。 */
98
+ export function bundleArch(): string | null {
99
+ const { platform, arch } = process
100
+ if (platform === 'linux' && arch === 'x64') return 'linux-x64'
101
+ if (platform === 'win32' && arch === 'x64') return 'win-x64'
102
+ if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) return 'mac-x64'
103
+ return null
104
+ }
105
+
106
+ /** CDN 基址可用 MOBIUS_TUI_PYTHON_BUNDLE_URL 覆盖;文件名固定为 mobius-python-<arch>-v<N>.zip。 */
107
+ export const bundleBaseUrl = () => (process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL || 'https://serve.nutshellai.cn/publish/auto/mobius-tui').replace(/\/$/, '')
108
+ export const bundleUrl = (arch: string) => `${bundleBaseUrl()}/mobius-python-${arch}-v${BUNDLE_VER}.zip`
109
+
110
+ function bundleReady(): boolean {
111
+ return existsSync(bundlePython()) && spawnSync(bundlePython(), ['-c', 'import aimux'], { stdio: 'ignore', windowsHide: true }).status === 0
112
+ }
113
+
114
+ async function downloadBundle(arch: string, onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; zipPath?: string }> {
115
+ const url = bundleUrl(arch)
116
+ const zipPath = path.join(mobiusHome(), `python-bundle-v${BUNDLE_VER}.zip.tmp`)
117
+ await fs.mkdir(path.dirname(zipPath), { recursive: true }) // 首次安装 ~/.mobius 可能尚未创建
118
+ let res: Response
119
+ try { res = await fetch(url) } catch (e: any) { return { ok: false, error: `下载失败: ${e?.message ?? e}` } }
120
+ if (!res.ok) return { ok: false, error: `下载失败: HTTP ${res.status} (${url})` }
121
+ const total = Number(res.headers.get('content-length') || 0)
122
+ const ws = createWriteStream(zipPath)
123
+ let got = 0, last = 0
124
+ try {
125
+ const stream = Readable.fromWeb(res.body as any)
126
+ for await (const chunk of stream) {
127
+ ws.write(chunk as Buffer)
128
+ got += (chunk as Buffer).length
129
+ if (total && got - last > total * 0.03) { last = got; onProgress?.({ phase: 'install', detail: `下载内置运行时 ${Math.round((got / total) * 100)}%` }) }
130
+ }
131
+ if (!total && got) onProgress?.({ phase: 'install', detail: `下载内置运行时 ${(got / 1048576).toFixed(0)}MB` })
132
+ await new Promise<void>((resolve, reject) => { ws.end(() => resolve()); ws.on('error', reject) })
133
+ } catch (e: any) {
134
+ try { ws.destroy() } catch {}
135
+ try { await fs.unlink(zipPath) } catch {}
136
+ return { ok: false, error: `下载失败: ${e?.message ?? e}` }
137
+ }
138
+ return { ok: true, zipPath }
139
+ }
140
+
141
+ async function extractBundle(zipPath: string): Promise<{ ok: boolean; error?: string }> {
142
+ const staging = path.join(mobiusHome(), 'python-bundle.new')
143
+ const finalDir = bundleDir()
144
+ const stagingPython = WIN ? path.join(staging, 'python', 'python.exe') : path.join(staging, 'python', 'bin', 'python3')
145
+ await fs.rm(staging, { recursive: true, force: true }).catch(() => {})
146
+ await fs.mkdir(staging, { recursive: true })
147
+ try { await extract(zipPath, { dir: staging, defaultDirMode: 0o755, defaultFileMode: 0o644 }) }
148
+ catch (e: any) { await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); return { ok: false, error: `解压失败: ${e?.message ?? e}` } }
149
+ if (!WIN) try { await fs.chmod(stagingPython, 0o755) } catch {} // 保险:确保可执行位(extract-zip 通常已还原)
150
+ await fs.rm(finalDir, { recursive: true, force: true }).catch(() => {})
151
+ await fs.rename(staging, finalDir)
152
+ return { ok: true }
153
+ }
154
+
155
+ export async function ensureFromBundle(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; launcher?: AimuxLauncher }> {
156
+ if (bundleReady()) return { ok: true, launcher: { kind: 'module', python: bundlePython() } }
157
+ const arch = bundleArch()
158
+ if (!arch) return { ok: false, error: `当前平台无内置运行时 (platform=${process.platform} arch=${process.arch})` }
159
+ onProgress?.({ phase: 'install', detail: `下载内置运行时 (${arch})…` })
160
+ const dl = await downloadBundle(arch, onProgress)
161
+ if (!dl.ok || !dl.zipPath) return { ok: false, error: dl.error }
162
+ onProgress?.({ phase: 'install', detail: '解压内置运行时…' })
163
+ const ex = await extractBundle(dl.zipPath)
164
+ try { await fs.unlink(dl.zipPath) } catch {}
165
+ if (!ex.ok) return { ok: false, error: ex.error }
166
+ if (!bundleReady()) return { ok: false, error: '内置运行时解压后仍无法 import aimux' }
167
+ return { ok: true, launcher: { kind: 'module', python: bundlePython() } }
168
+ }
169
+
170
+ /** 按 launcher 把 aimux 参数变成实际 spawn。 */
171
+ export function spawnLauncher(launcher: AimuxLauncher, args: string[]): ChildProcess {
172
+ return launcher.kind === 'exe'
173
+ ? spawn(launcher.path, args, { windowsHide: true })
174
+ : spawn(launcher.python, ['-m', 'aimux', ...args], { windowsHide: true })
175
+ }
176
+
177
+ /** test-only 导出: 暴露内部 downloadBundle 以便单测 mock fetch 验证流式下载+进度。 */
178
+ export const downloadBundleForTest = downloadBundle
179
+
180
+ export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; launcher?: AimuxLauncher }> {
181
+ // Fast-path:venv 里已有 aimux 可执行 → 直接用。
182
+ if (existsSync(aimuxExe()) && existsSync(venvPython())) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: { kind: 'exe', path: aimuxExe() } } }
81
183
  const py = await pythonForAimux(onProgress)
82
- if (!py) return { ok: false, error: '未找到 Python。请先安装 Python 3.10+(或安装 uv 后重试)。' }
83
- onProgress?.({ phase: 'venv', detail: `创建 Python 虚拟环境(${py})…` })
84
- let r = await run(py, ['-m', 'venv', venvDir()])
85
- if (r.code !== 0 && py === 'py') r = await run(py, ['-3', '-m', 'venv', venvDir()])
86
- if (r.code !== 0) return { ok: false, error: `venv 创建失败: ${r.stderr || r.stdout}` }
87
- onProgress?.({ phase: 'install', detail: `下载并安装 ${AIMUX_PACKAGE}…` })
88
- r = await run(venvPython(), ['-m', 'pip', 'install', '--no-input', '--disable-pip-version-check', AIMUX_PACKAGE], line => {
89
- if (/downloading|collecting|installing|using cached|%\s*\d|━|─/i.test(line)) onProgress?.({ phase: 'install', detail: line.slice(0, 120) })
90
- })
91
- if (r.code !== 0) return { ok: false, error: `pip install 失败: ${r.stderr || r.stdout}` }
92
- if (!existsSync(aimuxExe())) return { ok: false, error: `aimux 可执行未生成: ${aimuxExe()}` }
93
- onProgress?.({ phase: 'ready' }); return { ok: true }
184
+ let venvError = '未找到 Python。请先安装 Python 3.10+(或安装 uv 后重试)。'
185
+ if (py) {
186
+ onProgress?.({ phase: 'venv', detail: `创建 Python 虚拟环境(${py})…` })
187
+ let r = await run(py, ['-m', 'venv', venvDir()])
188
+ if (r.code !== 0 && py === 'py') r = await run(py, ['-3', '-m', 'venv', venvDir()])
189
+ if (r.code === 0) {
190
+ onProgress?.({ phase: 'install', detail: `下载并安装 ${AIMUX_PACKAGE}…` })
191
+ r = await run(venvPython(), ['-m', 'pip', 'install', '--no-input', '--disable-pip-version-check', AIMUX_PACKAGE], line => {
192
+ if (/downloading|collecting|installing|using cached|%\s*\d|━|─/i.test(line)) onProgress?.({ phase: 'install', detail: line.slice(0, 120) })
193
+ })
194
+ if (r.code === 0 && existsSync(aimuxExe())) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: { kind: 'exe', path: aimuxExe() } } }
195
+ venvError = r.code === 0 ? `aimux 可执行未生成: ${aimuxExe()}` : `pip install 失败: ${r.stderr || r.stdout}`
196
+ } else {
197
+ venvError = `venv 创建失败: ${r.stderr || r.stdout}`
198
+ }
199
+ }
200
+ // ── Plan B 兜底:本地 python/venv 不可用 → 下载内置 python+aimux 运行时 ──
201
+ onProgress?.({ phase: 'install', detail: '本地 Python 不可用,改用内置运行时…' })
202
+ const bundle = await ensureFromBundle(onProgress)
203
+ if (bundle.ok && bundle.launcher) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: bundle.launcher } }
204
+ return { ok: false, error: `${venvError};内置运行时也失败: ${bundle.error}` }
94
205
  }
95
206
 
96
207
  export function tuiAimuxIdentifier(): string {
@@ -155,8 +266,10 @@ export class AimuxSupervisor {
155
266
  )
156
267
  this.child = child
157
268
  this.startHeartbeat()
269
+ let tail = '' // 缓存 aimux 最近输出, 进程异常退出时带进状态行, 便于诊断(code=1 不再是黑盒)
158
270
  const classify = (buf: Buffer) => {
159
271
  const text = buf.toString('utf8')
272
+ tail = (tail + text).slice(-4000)
160
273
  if (!this.bridgeConnected && /connected|registered|event stream|heartbeat|sse/i.test(text)) {
161
274
  onStatus({ state: 'starting', phase: 'heartbeat', detail: 'AIMUX 已启动,等待 bridge 心跳确认…', identifier })
162
275
  } else if (/connection (refused|reset|closed|error)|failed to connect|unauthorized|forbidden|token.*invalid/i.test(text)) {
@@ -170,7 +283,10 @@ export class AimuxSupervisor {
170
283
  this.child = null
171
284
  this.stopHeartbeat()
172
285
  if (this.stopping) { onStatus({ state: 'stopped', phase: 'idle', detail: 'AIMUX 已停止', identifier }); return }
173
- this.scheduleReconnect(`AIMUX 进程退出(code=${code})`)
286
+ const reason = code !== 0 && tail.trim()
287
+ ? `AIMUX 进程退出(code=${code}): ${tail.trim().split(/[\r\n]+/).filter(Boolean).slice(-3).join(' ⏎ ').slice(-200)}`
288
+ : `AIMUX 进程退出(code=${code})`
289
+ this.scheduleReconnect(reason)
174
290
  })
175
291
  }
176
292
 
@@ -281,8 +397,13 @@ export async function startAimuxConnection(opts: { server: string; token: string
281
397
  phase: p.phase === 'ready' ? 'connecting' : p.phase,
282
398
  detail: p.detail || (p.phase === 'ready' ? 'AIMUX 已就绪,准备连接…' : p.phase),
283
399
  }))
284
- if (!ready.ok) { onStatus({ state: 'failed', phase: 'idle', detail: ready.error }); return }
285
- supervisor = new AimuxSupervisor({ server: opts.server, token: opts.token, identifier: tuiAimuxIdentifier(), onStatus })
400
+ if (!ready.ok || !ready.launcher) { onStatus({ state: 'failed', phase: 'idle', detail: ready.error }); return }
401
+ const identifier = tuiAimuxIdentifier()
402
+ const launcher = ready.launcher
403
+ supervisor = new AimuxSupervisor({
404
+ server: opts.server, token: opts.token, identifier, onStatus,
405
+ spawnProcess: () => spawnLauncher(launcher, ['reverse', 'connect', `${opts.server.replace(/\/$/, '')}/aimux_bridge`, '--identifier', identifier, '--token', opts.token, '--replace']),
406
+ })
286
407
  supervisor.start()
287
408
  })().finally(() => { installing = null })
288
409
  await installing
@@ -10,15 +10,21 @@ const STYLE: Record<AimuxStatus['state'], { icon: string; color: 'green' | 'yell
10
10
  disabled: { icon: '○', color: 'gray' },
11
11
  }
12
12
 
13
- export function AimuxStatusLine({ status, compact = false }: { status: AimuxStatus; compact?: boolean }) {
14
- const style = STYLE[status.state]
13
+ // Plain status text (without the leading icon/space), so callers can measure its
14
+ // visible width and lay it out beside other status fragments on one row.
15
+ export function aimuxStatusText(status: AimuxStatus, compact = false): string {
15
16
  const phase = status.phase && !['idle', 'connected'].includes(status.phase) ? ` · ${phaseLabel(status.phase)}` : ''
16
17
  const detail = status.detail || stateLabel(status.state)
18
+ return `AIMUX${phase} · ${compact ? compactDetail(detail) : detail}`
19
+ }
20
+
21
+ export function AimuxStatusLine({ status, compact = false }: { status: AimuxStatus; compact?: boolean }) {
22
+ const style = STYLE[status.state]
17
23
  return (
18
24
  <Box>
19
25
  <Text color={style.color}>{style.icon}</Text>
20
26
  <Text dimColor={status.state === 'disabled' || status.state === 'stopped'}>
21
- {' '}AIMUX{phase} · {compact ? compactDetail(detail) : detail}
27
+ {' ' + aimuxStatusText(status, compact)}
22
28
  </Text>
23
29
  </Box>
24
30
  )
@@ -16,7 +16,7 @@ import { viewsForEntry, dedupeUserEntries, toolLabel, type EntryView } from '../
16
16
  import type { ReadyState } from './PrepScreen.js'
17
17
  import type { AnyEntry } from '../types.js'
18
18
  import type { AimuxStatus } from '../aimux.js'
19
- import { AimuxStatusLine } from './AimuxStatus.js'
19
+ import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
20
20
 
21
21
  interface ChatProps {
22
22
  client: MobiusClient
@@ -549,15 +549,44 @@ function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDispl
549
549
  <Text dimColor>{left}</Text>
550
550
  {right ? <Text dimColor>{right}</Text> : null}
551
551
  </Box>
552
- <Text>
553
- <Text dimColor>web · </Text>
554
- <Text color="cyan" underline>{clickableUrl(webUrl)}</Text>
555
- </Text>
556
- {aimuxStatus ? <AimuxStatusLine status={aimuxStatus} compact /> : null}
552
+ {/* Merged connectivity row: AIMUX status sits left, the clickable web URL
553
+ sits right and truncates to the remaining width (its OSC 8 link target
554
+ stays full so it stays clickable). This collapses the former separate
555
+ "web · url" and AIMUX rows into one, dropping the status area from
556
+ three rows to two. */}
557
+ <ConnectivityRow aimuxStatus={aimuxStatus} webUrl={webUrl} columns={columns} />
557
558
  </Box>
558
559
  )
559
560
  }
560
561
 
562
+ // AIMUX status (left) ⟷ clickable web URL (right) on a single row.
563
+ function ConnectivityRow({ aimuxStatus, webUrl, columns }: { aimuxStatus?: AimuxStatus; webUrl: string; columns: number }) {
564
+ const aimuxText = aimuxStatus ? aimuxStatusText(aimuxStatus, true) : ''
565
+ // icon (1) + leading space (1) + status text width
566
+ const aimuxWidth = aimuxText ? 2 + displayWidth(aimuxText) : 0
567
+ // No AIMUX status → web URL keeps the whole row (unchanged from before).
568
+ // Otherwise leave room for the AIMUX block + 'web · ' prefix + a safety gap
569
+ // (the gap also absorbs ambiguous-width chars like box-drawing in the detail).
570
+ const urlBudget = aimuxWidth
571
+ ? Math.max(8, columns - 2 - aimuxWidth - WEB_PREFIX.length - 6)
572
+ : undefined
573
+ const web = (
574
+ <Text>
575
+ <Text dimColor>{WEB_PREFIX}</Text>
576
+ <Text color="cyan" underline>{clickableUrl(webUrl, urlBudget)}</Text>
577
+ </Text>
578
+ )
579
+ if (!aimuxStatus) return <Box>{web}</Box>
580
+ return (
581
+ <Box justifyContent="space-between">
582
+ <AimuxStatusLine status={aimuxStatus} compact />
583
+ {web}
584
+ </Box>
585
+ )
586
+ }
587
+
588
+ const WEB_PREFIX = 'web · '
589
+
561
590
  function compactPath(path: string): string {
562
591
  const home = process.env.HOME
563
592
  if (!home) return path
@@ -577,10 +606,43 @@ function buildWebUrl(server: string, webUserId: string, ready: ReadyState, sessi
577
606
  return sessionId ? `${base}?session=${encodeURIComponent(sessionId)}` : base
578
607
  }
579
608
 
580
- /** OSC 8 hyperlinks remain readable as plain URLs in terminals without support. */
581
- function clickableUrl(url: string): string {
582
- if (process.env.MOBIUS_TUI_DISABLE_LINKS === '1') return url
583
- return `\u001B]8;;${url}\u0007${url}\u001B]8;;\u0007`
609
+ /** OSC 8 hyperlinks remain readable as plain URLs in terminals without support.
610
+ * When maxLen is given, only the *visible* text is truncated (the OSC 8 link
611
+ * target keeps the full URL, so it stays clickable on narrow terminals). */
612
+ function clickableUrl(url: string, maxLen?: number): string {
613
+ const display = maxLen != null ? truncateDisplay(url, maxLen) : url
614
+ if (process.env.MOBIUS_TUI_DISABLE_LINKS === '1') return display
615
+ return `\u001B]8;;${url}\u0007${display}\u001B]8;;\u0007`
616
+ }
617
+
618
+ // Visible-column width (CJK / emoji / fullwidth count as 2; combining marks as
619
+ // 0), used to size the AIMUX status block so the web URL truncates to exactly
620
+ // the remaining width without overflowing the row.
621
+ function displayWidth(str: string): number {
622
+ let w = 0
623
+ for (const ch of str) {
624
+ const code = ch.codePointAt(0) ?? 0
625
+ if (code >= 0x0300 && code <= 0x036F) continue // combining diacriticals: 0 cols
626
+ w += isWideCodepoint(code) ? 2 : 1
627
+ }
628
+ return w
629
+ }
630
+
631
+ function isWideCodepoint(code: number): boolean {
632
+ return (
633
+ (code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
634
+ (code >= 0x2E80 && code <= 0x303E) || // CJK radicals / punctuation
635
+ (code >= 0x3041 && code <= 0x33FF) || // Hiragana / Katakana / CJK compat
636
+ (code >= 0x3400 && code <= 0x4DBF) || // CJK Unified Extension A
637
+ (code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs (心跳正常 …)
638
+ (code >= 0xA000 && code <= 0xA4CF) || // Yi
639
+ (code >= 0xAC00 && code <= 0xD7A3) || // Hangul syllables
640
+ (code >= 0xF900 && code <= 0xFAFF) || // CJK compatibility ideographs
641
+ (code >= 0xFE30 && code <= 0xFE4F) || // CJK compatibility forms
642
+ (code >= 0xFF00 && code <= 0xFF60) || // Fullwidth ASCII
643
+ (code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
644
+ (code >= 0x1F300 && code <= 0x1FAFF) // Emoji / symbols
645
+ )
584
646
  }
585
647
 
586
648
  // (fitTranscript / blockRows / wrappedRows 视窗裁剪 + in-app 翻页逻辑已移除:
@@ -291,6 +291,7 @@ function truncate(s: string, n: number): string {
291
291
  /** Build a one-line summary of a tool call from its name + input. */
292
292
  export function summarizeToolInput(name: string, input: any): string {
293
293
  if (!input || typeof input !== 'object') return ''
294
+ name = normalizeToolName(name) // mcp__aimux__remote_exec_command → remote_exec_command
294
295
  const cmd = (s?: string) => truncate(s ?? '', 120)
295
296
  switch (name) {
296
297
  case 'Bash':
@@ -298,6 +299,7 @@ export function summarizeToolInput(name: string, input: any): string {
298
299
  case 'bash':
299
300
  case 'exec':
300
301
  case 'exec_command':
302
+ case 'remote_exec_command':
301
303
  case 'shell_command':
302
304
  case 'run_terminal_cmd':
303
305
  return cmd(input.cmd ?? input.command ?? input.script)
@@ -345,23 +347,61 @@ export function summarizeToolInput(name: string, input: any): string {
345
347
  function extractToolResult(content: any): { text: string; isError: boolean } {
346
348
  const isError = !!content?.is_error
347
349
  let body = content?.content
348
- if (typeof body === 'string') return { text: body, isError }
349
- if (Array.isArray(body)) {
350
- const t = body
350
+ let text = ''
351
+ if (typeof body === 'string') text = body
352
+ else if (Array.isArray(body)) {
353
+ text = body
351
354
  .map((b: any) => (typeof b === 'string' ? b : (b?.text ?? '')))
352
355
  .filter(Boolean)
353
356
  .join('\n')
354
- return { text: t, isError }
357
+ } else if (typeof body === 'object' && body) {
358
+ text = body.text ?? body.output ?? JSON.stringify(body)
355
359
  }
356
- if (typeof body === 'object' && body) {
357
- return { text: body.text ?? body.output ?? JSON.stringify(body), isError }
358
- }
359
- return { text: '', isError }
360
+ // claude MCP 工具( aimux remote_exec_command)的结果是 JSON {"output":"...","exit_code":0}
361
+ // 解包出 output, codex exec 的纯文本输出对齐; 再清掉终端标题/退出码探针等 shell 噪声.
362
+ return { text: cleanShellNoise(unwrapExecOutput(text)), isError }
363
+ }
364
+
365
+ /**
366
+ * aimux remote_exec_command 等 MCP 工具把命令输出包成 {"output":"...","exit_code":0,...}
367
+ * JSON 串。解包出 output 字段,使 claude-code 的命令结果与 codex 的纯文本输出一致。
368
+ * 仅当整体是 JSON 对象且含字符串 output 字段时才解包(避免误吞本身就是 JSON 的文件内容)。
369
+ */
370
+ function unwrapExecOutput(text: string): string {
371
+ const trimmed = text.trim()
372
+ if (!(trimmed.startsWith('{') && trimmed.endsWith('}'))) return text
373
+ try {
374
+ const obj = JSON.parse(trimmed)
375
+ if (obj && typeof obj === 'object' && typeof obj.output === 'string') return obj.output
376
+ } catch { /* 不是 JSON, 原样返回 */ }
377
+ return text
378
+ }
379
+
380
+ /**
381
+ * 清掉 aimux 交互式 shell 捕获里的纯噪声 (claude-code 与 codex 经 aimux 执行命令时都会产生):
382
+ * - OSC 终端标题序列 \x1b]0;root@host: cwd\x07 (最刺眼的乱码)
383
+ * - CSI 控制序列 \x1b[...m 等
384
+ * - aimux 退出码探针 __AIMUX_EXIT_<hex>__:<code> 及其 echo 回显
385
+ */
386
+ function cleanShellNoise(text: string): string {
387
+ if (!text) return text
388
+ return text
389
+ .replace(/\x1b\][^\x1b]*?(?:\x07|\x1b\\)/g, '') // OSC 终端标题
390
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') // CSI 控制序列
391
+ .replace(/__AIMUX_EXIT_[0-9a-fA-F]+__(:\d+)?/g, '') // aimux 退出码标记
392
+ .replace(/[ \t]*\r?\n[ \t]*\r?\n[ \t]*\r?\n+/g, '\n\n') // 压连续空行
393
+ .trim()
394
+ }
395
+
396
+ /** 还原 claude MCP 工具长名: mcp__<server>__<tool> → <tool>, 与 codex 短名对齐。 */
397
+ function normalizeToolName(name: string): string {
398
+ const m = /^mcp__[a-zA-Z0-9_-]+__(.+)$/.exec(name)
399
+ return m ? m[1] : name
360
400
  }
361
401
 
362
402
  const TOOL_LABEL: Record<string, string> = {
363
403
  Bash: '运行命令', bash: '运行命令', shell: '运行命令', exec: '运行命令',
364
- exec_command: '运行命令', shell_command: '运行命令', run_terminal_cmd: '运行命令',
404
+ exec_command: '运行命令', remote_exec_command: '运行命令', shell_command: '运行命令', run_terminal_cmd: '运行命令',
365
405
  result: '结果',
366
406
  write_stdin: '输入命令',
367
407
  Read: '读取文件', read_file: '读取文件',
@@ -588,7 +628,8 @@ export function viewsForEntry(entry: AnyEntry): EntryView[] {
588
628
  }
589
629
 
590
630
  export function toolLabel(name: string): string {
591
- return TOOL_LABEL[name] ?? name
631
+ const n = normalizeToolName(name) // mcp__aimux__remote_exec_command → remote_exec_command → 运行命令
632
+ return TOOL_LABEL[n] ?? n
592
633
  }
593
634
 
594
635
  // ── 用户输入去重 (对齐 web viewer/rounds.ts buildRounds) ──────────────────────