@mobius-os/mobius 0.3.0 → 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.3.0",
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": {
package/src/aimux.ts CHANGED
@@ -266,8 +266,10 @@ export class AimuxSupervisor {
266
266
  )
267
267
  this.child = child
268
268
  this.startHeartbeat()
269
+ let tail = '' // 缓存 aimux 最近输出, 进程异常退出时带进状态行, 便于诊断(code=1 不再是黑盒)
269
270
  const classify = (buf: Buffer) => {
270
271
  const text = buf.toString('utf8')
272
+ tail = (tail + text).slice(-4000)
271
273
  if (!this.bridgeConnected && /connected|registered|event stream|heartbeat|sse/i.test(text)) {
272
274
  onStatus({ state: 'starting', phase: 'heartbeat', detail: 'AIMUX 已启动,等待 bridge 心跳确认…', identifier })
273
275
  } else if (/connection (refused|reset|closed|error)|failed to connect|unauthorized|forbidden|token.*invalid/i.test(text)) {
@@ -281,7 +283,10 @@ export class AimuxSupervisor {
281
283
  this.child = null
282
284
  this.stopHeartbeat()
283
285
  if (this.stopping) { onStatus({ state: 'stopped', phase: 'idle', detail: 'AIMUX 已停止', identifier }); return }
284
- 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)
285
290
  })
286
291
  }
287
292
 
@@ -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) ──────────────────────