@mobius-os/mobius 0.3.27 → 0.3.31

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.
@@ -103,8 +103,29 @@ export function headTailLines(text: string, width: number, maxLines: number): st
103
103
  export interface ScreenRows {
104
104
  /** Whether this view renders with a leading blank row (its Box has marginTop). */
105
105
  marginTop: boolean
106
- /** Full screen lines: prefix included, wrapped/truncated, plain text (no ANSI). */
107
- rows: string[]
106
+ /** Full screen rows: a plain layout/copy projection plus its styled ANSI text. */
107
+ rows: ScreenRow[]
108
+ }
109
+
110
+ export type ScreenRowTone =
111
+ | 'normal'
112
+ | 'user'
113
+ | 'tool'
114
+ | 'tool_result'
115
+ | 'tool_error'
116
+ | 'edit_header'
117
+ | 'edit_old'
118
+ | 'edit_new'
119
+ | 'reasoning'
120
+ | 'system'
121
+ | 'error'
122
+
123
+ export interface ScreenRow {
124
+ /** Visible text used for geometry, hit-testing, and clipboard extraction. */
125
+ plain: string
126
+ /** Same row with Markdown/syntax ANSI styling preserved for terminal output. */
127
+ styled: string
128
+ tone: ScreenRowTone
108
129
  }
109
130
 
110
131
  const textWidth = (columns: number) => Math.max(8, columns - 4)
@@ -114,72 +135,97 @@ const fullWidth = (columns: number) => Math.max(1, columns - 2) // root paddingX
114
135
  export function viewScreenRows(view: EntryView, columns: number): ScreenRows {
115
136
  const width = textWidth(columns)
116
137
  const full = fullWidth(columns)
117
- const fit = (rows: string[]): string[] => rows.map((r) => truncateText(r, full))
138
+ const row = (styled: string, tone: ScreenRowTone = 'normal'): ScreenRow => ({
139
+ plain: stripAnsi(styled),
140
+ styled,
141
+ tone,
142
+ })
143
+ // Keep the original styled string and let Ink perform its ANSI-aware
144
+ // truncate-end rendering. The plain projection mirrors the visible row.
145
+ const fit = (rows: string[], tone: ScreenRowTone = 'normal'): ScreenRow[] => rows.map((styled) => ({
146
+ plain: truncateText(stripAnsi(styled), full),
147
+ styled,
148
+ tone,
149
+ }))
150
+ const wrap = (styled: string, tone: ScreenRowTone = 'normal'): ScreenRow[] => (
151
+ wrapAnsi(styled, full, { trim: false, hard: true }).split('\n').map(text => row(text, tone))
152
+ )
118
153
  switch (view.kind) {
119
154
  case 'skip':
120
155
  return { marginTop: false, rows: [] }
121
156
  case 'user': {
122
157
  const rows = view.text.split('\n').map((l, i) => (i === 0 ? `› ${l}` : ` ${l}`))
123
- return { marginTop: true, rows: fit(rows) }
158
+ return { marginTop: true, rows: fit(rows, 'user') }
124
159
  }
125
160
  case 'assistant': {
126
161
  const md = renderMarkdownLines(view.text)
127
- const rows: string[] = []
162
+ const rows: ScreenRow[] = []
128
163
  md.forEach((line, i) => {
129
164
  const prefix = i === 0 ? '• ' : ' '
130
- const fullLine = prefix + stripAnsi(line.text || ' ')
131
- if (line.code) rows.push(truncateText(fullLine, full))
132
- else rows.push(...wrapText(fullLine, full))
165
+ const fullLine = prefix + (line.text || ' ')
166
+ if (line.code) rows.push(...fit([fullLine]))
167
+ else rows.push(...wrap(fullLine))
133
168
  })
134
169
  return { marginTop: true, rows }
135
170
  }
136
171
  case 'tool_call': {
137
172
  const head = clampLines(`${toolLabel(view.toolName)} ${view.summary}`.trim(), width - 2, 1)[0]
138
- const rows = [`• ${head}`]
139
- if (view.result) rows.push(` └ ${clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}`)
140
- return { marginTop: true, rows: fit(rows) }
173
+ const rows = fit([`• ${head}`], 'tool')
174
+ if (view.result) rows.push(...fit(
175
+ [` └ ${clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}`],
176
+ view.result.isError ? 'tool_error' : 'tool_result',
177
+ ))
178
+ return { marginTop: true, rows }
141
179
  }
142
180
  case 'tool_result': {
143
181
  const lines = headTailLines(view.text, width - 4, 5)
144
- return { marginTop: false, rows: fit(lines.map((l, i) => `${i === 0 ? ' └ ' : ' '}${l}`)) }
182
+ return { marginTop: false, rows: fit(
183
+ lines.map((l, i) => `${i === 0 ? ' └ ' : ' '}${l}`),
184
+ view.isError ? 'tool_error' : 'tool_result',
185
+ ) }
145
186
  }
146
187
  case 'code_edit': {
147
- const rows = [`✎ 编辑 ${view.filePath || '(未指定文件)'}`]
148
- if (view.oldString) rows.push(...view.oldString.split('\n').map((l) => ` − ${l}`))
149
- if (view.newString) rows.push(...view.newString.split('\n').map((l) => ` + ${l}`))
150
- return { marginTop: true, rows: fit(rows) }
188
+ const rows = fit([`✎ 编辑 ${view.filePath || '(未指定文件)'}`], 'edit_header')
189
+ if (view.oldString) rows.push(...fit(view.oldString.split('\n').map((l) => ` − ${l}`), 'edit_old'))
190
+ if (view.newString) rows.push(...fit(view.newString.split('\n').map((l) => ` + ${l}`), 'edit_new'))
191
+ return { marginTop: true, rows }
151
192
  }
152
193
  case 'write_file': {
153
- const rows = [`✎ 写入 ${view.filePath || '(未指定文件)'}`]
154
- rows.push(...view.content.split('\n').map((l) => ` + ${l}`))
155
- return { marginTop: true, rows: fit(rows) }
194
+ const rows = fit([`✎ 写入 ${view.filePath || '(未指定文件)'}`], 'edit_header')
195
+ rows.push(...fit(view.content.split('\n').map((l) => ` + ${l}`), 'edit_new'))
196
+ return { marginTop: true, rows }
156
197
  }
157
198
  case 'reasoning': {
158
199
  const lines = clampLines(view.text, width - 4, 2)
159
- return { marginTop: true, rows: fit(lines.map((l, i) => `${i === 0 ? ' ◇ ' : ' '}${l}`)) }
200
+ return { marginTop: true, rows: fit(lines.map((l, i) => `${i === 0 ? ' ◇ ' : ' '}${l}`), 'reasoning') }
160
201
  }
161
202
  case 'system':
162
- return { marginTop: false, rows: fit([` ${clampLines(view.text, width - 2, 2)[0]}`]) }
203
+ return { marginTop: false, rows: fit([` ${clampLines(view.text, width - 2, 2)[0]}`], 'system') }
163
204
  case 'error': {
164
205
  const rows = view.text.split('\n').map((l, i) => `${i === 0 ? '⚠ ' : ' '}${l}`)
165
- return { marginTop: true, rows: fit(rows) }
206
+ return { marginTop: true, rows: fit(rows, 'error') }
166
207
  }
167
208
  default:
168
209
  return { marginTop: false, rows: [] }
169
210
  }
170
211
  }
171
212
 
172
- /** Flatten a whole entry (all its views) into screen lines, margins as ''. */
173
- export function entryScreenLines(views: EntryView[], columns: number): string[] {
174
- const lines: string[] = []
213
+ /** Flatten a whole entry into stable rows, with margins represented explicitly. */
214
+ export function entryScreenRows(views: EntryView[], columns: number): ScreenRow[] {
215
+ const lines: ScreenRow[] = []
175
216
  for (const v of views) {
176
217
  const { marginTop, rows } = viewScreenRows(v, columns)
177
- if (marginTop) lines.push('')
218
+ if (marginTop) lines.push({ plain: '', styled: '', tone: 'normal' })
178
219
  lines.push(...rows)
179
220
  }
180
221
  return lines
181
222
  }
182
223
 
224
+ /** Plain projection used by fitting, geometry, hit-testing, and copying. */
225
+ export function entryScreenLines(views: EntryView[], columns: number): string[] {
226
+ return entryScreenRows(views, columns).map(row => row.plain)
227
+ }
228
+
183
229
  // ── vertical geometry ────────────────────────────────────────────────────────
184
230
  export interface TranscriptGeometry {
185
231
  /** Screen row where the transcript box's top edge sits. */
@@ -269,7 +315,7 @@ function charAtDisplayWidth(line: string, col: number): number {
269
315
  const w = displayWidth(ch)
270
316
  if (col < acc + w) return i
271
317
  acc += w
272
- i++
318
+ i += ch.length
273
319
  }
274
320
  return i
275
321
  }
@@ -0,0 +1,186 @@
1
+ import { Transform } from 'node:stream'
2
+
3
+ const ENABLE_WIN32_INPUT_MODE = '\x1b[?9001h'
4
+ const DISABLE_WIN32_INPUT_MODE = '\x1b[?9001l'
5
+
6
+ const WIN32_KEY_RECORD_RE = /^\x1b\[(\d*);(\d*);(\d*);(\d*);(\d*);(\d*)_/
7
+
8
+ /**
9
+ * Decode Windows Terminal's win32-input-mode KEY_EVENT_RECORD sequences back
10
+ * into the VT input Ink expects. This preserves modifiers that legacy ConPTY
11
+ * input loses, most importantly the distinction between Enter and Shift+Enter.
12
+ */
13
+ export class WindowsInputDecoder {
14
+ private buffer = ''
15
+
16
+ get hasPendingInput(): boolean {
17
+ return this.buffer.length > 0
18
+ }
19
+
20
+ push(chunk: string): string {
21
+ this.buffer += chunk
22
+ let output = ''
23
+
24
+ while (this.buffer) {
25
+ const start = this.buffer.indexOf('\x1b[')
26
+ if (start < 0) {
27
+ if (this.buffer.endsWith('\x1b')) {
28
+ output += this.buffer.slice(0, -1)
29
+ this.buffer = '\x1b'
30
+ } else {
31
+ output += this.buffer
32
+ this.buffer = ''
33
+ }
34
+ break
35
+ }
36
+
37
+ output += this.buffer.slice(0, start)
38
+ this.buffer = this.buffer.slice(start)
39
+ const match = WIN32_KEY_RECORD_RE.exec(this.buffer)
40
+ if (match) {
41
+ this.buffer = this.buffer.slice(match[0].length)
42
+ output += translateWindowsKeyRecord(match.slice(1))
43
+ continue
44
+ }
45
+
46
+ if (isPartialWindowsKeyRecord(this.buffer)) break
47
+
48
+ // A normal VT sequence (arrows, mouse, paste markers, and so on) is not
49
+ // part of win32-input-mode. Release its ESC byte and scan the remainder.
50
+ output += this.buffer[0]
51
+ this.buffer = this.buffer.slice(1)
52
+ }
53
+
54
+ return output
55
+ }
56
+
57
+ flush(): string {
58
+ const remainder = this.buffer
59
+ this.buffer = ''
60
+ return remainder
61
+ }
62
+ }
63
+
64
+ function isPartialWindowsKeyRecord(value: string): boolean {
65
+ if (value === '\x1b' || value === '\x1b[') return true
66
+ if (!value.startsWith('\x1b[')) return false
67
+ const body = value.slice(2)
68
+ return /^[\d;]*$/.test(body) && body.split(';').length <= 6
69
+ }
70
+
71
+ function numberParam(value: string | undefined, fallback: number): number {
72
+ return value === undefined || value === '' ? fallback : Number(value)
73
+ }
74
+
75
+ function translateWindowsKeyRecord(params: string[]): string {
76
+ const virtualKey = numberParam(params[0], 0)
77
+ const unicode = numberParam(params[2], 0)
78
+ const keyDown = numberParam(params[3], 1) !== 0
79
+ const controlState = numberParam(params[4], 0)
80
+ const repeat = Math.max(1, Math.min(100, numberParam(params[5], 1)))
81
+ if (!keyDown) return ''
82
+
83
+ const shift = (controlState & 0x0010) !== 0
84
+ const leftAlt = (controlState & 0x0002) !== 0
85
+ const rightAlt = (controlState & 0x0001) !== 0
86
+ const leftCtrl = (controlState & 0x0008) !== 0
87
+ const altGr = rightAlt && leftCtrl
88
+
89
+ // Modifier-only records carry no text and must not leak into the composer.
90
+ if (unicode === 0 && [0x10, 0x11, 0x12, 0x14, 0x5b, 0x5c].includes(virtualKey)) return ''
91
+
92
+ let encoded = ''
93
+ if (virtualKey === 0x0d) {
94
+ encoded = shift ? '\x1b[13;2u' : '\r'
95
+ } else if (virtualKey === 0x08) {
96
+ encoded = '\x7f'
97
+ } else if (virtualKey === 0x09) {
98
+ encoded = shift ? '\x1b[Z' : '\t'
99
+ } else if (virtualKey === 0x1b) {
100
+ encoded = '\x1b'
101
+ } else if (unicode !== 0) {
102
+ encoded = String.fromCharCode(unicode)
103
+ if ((leftAlt || rightAlt) && !altGr) encoded = `\x1b${encoded}`
104
+ } else {
105
+ encoded = virtualKeySequence(virtualKey, controlState)
106
+ }
107
+
108
+ return encoded.repeat(repeat)
109
+ }
110
+
111
+ function virtualKeySequence(virtualKey: number, controlState: number): string {
112
+ const shift = (controlState & 0x0010) !== 0
113
+ const alt = (controlState & 0x0003) !== 0
114
+ const ctrl = (controlState & 0x000c) !== 0
115
+ const modifier = 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0)
116
+ const suffix = modifier === 1 ? '' : `1;${modifier}`
117
+ const csiLetter: Record<number, string> = {
118
+ 0x23: 'F', 0x24: 'H', 0x25: 'D', 0x26: 'A',
119
+ 0x27: 'C', 0x28: 'B',
120
+ }
121
+ if (csiLetter[virtualKey]) return `\x1b[${suffix}${csiLetter[virtualKey]}`
122
+
123
+ const csiTilde: Record<number, number> = {
124
+ 0x21: 5, 0x22: 6, 0x2d: 2, 0x2e: 3,
125
+ 0x74: 15, 0x75: 17, 0x76: 18, 0x77: 19,
126
+ 0x78: 20, 0x79: 21, 0x7a: 23, 0x7b: 24,
127
+ }
128
+ if (csiTilde[virtualKey]) {
129
+ const code = csiTilde[virtualKey]
130
+ return modifier === 1 ? `\x1b[${code}~` : `\x1b[${code};${modifier}~`
131
+ }
132
+
133
+ const ss3: Record<number, string> = { 0x70: 'P', 0x71: 'Q', 0x72: 'R', 0x73: 'S' }
134
+ if (ss3[virtualKey]) return modifier === 1 ? `\x1bO${ss3[virtualKey]}` : `\x1b[1;${modifier}${ss3[virtualKey]}`
135
+ return ''
136
+ }
137
+
138
+ /** Use a translating stdin only on a real Windows TTY; other platforms remain untouched. */
139
+ export function createInkInputStream(
140
+ input: NodeJS.ReadStream,
141
+ output: NodeJS.WriteStream,
142
+ ): NodeJS.ReadStream {
143
+ if (process.platform !== 'win32' || !input.isTTY) return input
144
+
145
+ const decoder = new WindowsInputDecoder()
146
+ let modeEnabled = false
147
+ let pendingTimer: ReturnType<typeof setTimeout> | null = null
148
+ const translated = new Transform({
149
+ transform(chunk, _encoding, callback) {
150
+ if (pendingTimer) clearTimeout(pendingTimer)
151
+ const decoded = decoder.push(String(chunk))
152
+ if (decoder.hasPendingInput) {
153
+ // An unsupported terminal still sends a legacy standalone Esc. Give a
154
+ // split win32 record one event-loop beat to finish, then release it.
155
+ pendingTimer = setTimeout(() => {
156
+ pendingTimer = null
157
+ translated.push(decoder.flush())
158
+ }, 15)
159
+ }
160
+ callback(null, decoded)
161
+ },
162
+ flush(callback) {
163
+ if (pendingTimer) clearTimeout(pendingTimer)
164
+ callback(null, decoder.flush())
165
+ },
166
+ }) as Transform & Partial<NodeJS.ReadStream>
167
+
168
+ Object.defineProperty(translated, 'isTTY', { value: true })
169
+ Object.defineProperty(translated, 'isRaw', { get: () => input.isRaw })
170
+ translated.setRawMode = (enabled: boolean) => {
171
+ input.setRawMode?.(enabled)
172
+ if (enabled !== modeEnabled) {
173
+ output.write(enabled ? ENABLE_WIN32_INPUT_MODE : DISABLE_WIN32_INPUT_MODE)
174
+ modeEnabled = enabled
175
+ }
176
+ return translated as NodeJS.ReadStream
177
+ }
178
+ translated.ref = () => { input.ref(); return translated as NodeJS.ReadStream }
179
+ translated.unref = () => { input.unref(); return translated as NodeJS.ReadStream }
180
+
181
+ input.pipe(translated)
182
+ process.once('exit', () => {
183
+ if (modeEnabled) output.write(DISABLE_WIN32_INPUT_MODE)
184
+ })
185
+ return translated as NodeJS.ReadStream
186
+ }
package/src/main.tsx CHANGED
@@ -9,5 +9,9 @@
9
9
  import React from 'react'
10
10
  import { render } from 'ink'
11
11
  import { App } from './App.js'
12
+ import { createInkInputStream } from './lib/windows-input.js'
12
13
 
13
- render(React.createElement(App), { exitOnCtrlC: true })
14
+ render(React.createElement(App), {
15
+ exitOnCtrlC: true,
16
+ stdin: createInkInputStream(process.stdin, process.stdout),
17
+ })
@@ -0,0 +1,210 @@
1
+ /** AIMUX status UI + heartbeat/reconnect regression tests. */
2
+ import React from 'react'
3
+ import { EventEmitter } from 'node:events'
4
+ import { spawn } from 'node:child_process'
5
+ import { promises as fs, existsSync } from 'node:fs'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import { render } from 'ink-testing-library'
9
+ import { AimuxStatusLine } from '../src/components/AimuxStatus.js'
10
+ import { AimuxSupervisor, probeAimuxBridgeConnection, bundleArch, bundleUrl, spawnLauncher, ensureFromBundle, downloadBundleForTest, reverseConnectArgs, aimuxLogPath, bundleHealthCheckCode } from '../src/aimux.js'
11
+
12
+ const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
13
+ let pass = 0, fail = 0
14
+ function ok(condition: boolean, message: string) {
15
+ if (condition) { pass += 1; console.log(` ✓ ${message}`) }
16
+ else { fail += 1; console.error(` ✗ ${message}`) }
17
+ }
18
+
19
+ function fakeChild(onKill: () => void): any {
20
+ const child: any = new EventEmitter()
21
+ child.pid = 12345
22
+ child.stdout = new EventEmitter()
23
+ child.stderr = new EventEmitter()
24
+ child.kill = () => { onKill(); return true }
25
+ return child
26
+ }
27
+
28
+ async function testStatusLine() {
29
+ console.log('\n[AIMUX 1] status display')
30
+ const { lastFrame, rerender, unmount } = render(
31
+ <AimuxStatusLine status={{ state: 'starting', phase: 'install', detail: '下载并安装 aimux… 48%' }} />,
32
+ )
33
+ ok((lastFrame() ?? '').includes('AIMUX · 安装') && (lastFrame() ?? '').includes('48%'), 'installation phase and progress stay visible')
34
+ rerender(<AimuxStatusLine status={{ state: 'failed', phase: 'retrying', detail: '心跳中断,2 秒后进行第 2 次重连…', attempt: 2 }} />)
35
+ ok((lastFrame() ?? '').includes('AIMUX · 重连') && (lastFrame() ?? '').includes('第 2 次重连'), 'retry phase and attempt are explicit')
36
+ unmount()
37
+ }
38
+
39
+ async function testProbeContract() {
40
+ console.log('\n[AIMUX 2] bridge heartbeat contract')
41
+ const realFetch = globalThis.fetch
42
+ let requestedUrl = '', auth = ''
43
+ globalThis.fetch = (async (input: any, init?: RequestInit) => {
44
+ requestedUrl = String(input)
45
+ auth = String((init?.headers as Record<string, string>)?.Authorization ?? '')
46
+ return new Response(JSON.stringify({ identifier: 'tui-test', event_stream_connected: true }), { status: 200 })
47
+ }) as typeof fetch
48
+ try {
49
+ const connected = await probeAimuxBridgeConnection('https://mobius.test/', 'jwt-test', 'tui-test', 100)
50
+ ok(connected, 'heartbeat accepts only an active event stream for this identifier')
51
+ ok(requestedUrl.endsWith('/aimux_bridge/api/remotes/tui-test/connection'), 'heartbeat calls the bridge connection endpoint')
52
+ ok(auth === 'Bearer jwt-test', 'heartbeat carries the Mobius JWT')
53
+ } finally { globalThis.fetch = realFetch }
54
+ }
55
+
56
+ async function testAutomaticReconnect() {
57
+ console.log('\n[AIMUX 3] heartbeat-triggered reconnect')
58
+ const statuses: string[] = []
59
+ let probes = 0, spawns = 0, kills = 0
60
+ const supervisor = new AimuxSupervisor({
61
+ server: 'https://mobius.test', token: 'jwt-test', identifier: 'tui-test',
62
+ heartbeatIntervalMs: 5, heartbeatFailureThreshold: 2, retryBaseMs: 5,
63
+ probeConnection: async () => { probes += 1; return probes >= 3 },
64
+ spawnProcess: () => { spawns += 1; return fakeChild(() => { kills += 1 }) },
65
+ onStatus: status => statuses.push(`${status.state}:${status.phase}:${status.detail}`),
66
+ })
67
+ supervisor.start()
68
+ for (let i = 0; i < 30 && !statuses.some(s => s.startsWith('connected:')); i += 1) await delay(5)
69
+ ok(kills >= 1, 'two failed heartbeats terminate the stale AIMUX process')
70
+ ok(spawns >= 2, 'supervisor starts a fresh AIMUX process after heartbeat loss')
71
+ ok(statuses.some(s => s.includes('第 1 次重连')), 'reconnect status reports its retry attempt')
72
+ ok(statuses.some(s => s.startsWith('connected:connected:心跳正常')), 'a later successful heartbeat restores connected state')
73
+ await supervisor.stop()
74
+ }
75
+
76
+ async function testBundleArchAndUrl() {
77
+ console.log('\n[AIMUX 4] Plan B bundle arch / url')
78
+ const arch = bundleArch()
79
+ ok(arch === 'linux-x64' || arch === 'win-x64' || arch === 'mac-x64', `bundleArch returns a supported arch on this host (${arch})`)
80
+ const before = bundleUrl('linux-x64')
81
+ ok(before.includes('mobius-python-linux-x64-v2') && before.endsWith('.zip'), 'bundleUrl follows the fixed filename pattern')
82
+ const saved = process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL
83
+ process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL = 'https://example.test/cdn/'
84
+ try {
85
+ ok(bundleUrl('win-x64') === 'https://example.test/cdn/mobius-python-win-x64-v2.zip', 'MOBIUS_TUI_PYTHON_BUNDLE_URL overrides the CDN base and trims trailing slash')
86
+ } finally { if (saved === undefined) delete process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL; else process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL = saved }
87
+ }
88
+
89
+ async function testPersistentProcessLog() {
90
+ console.log('\n[AIMUX 9] persistent process diagnostics')
91
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), 'mobius-tui-aimux-log-'))
92
+ const savedHome = process.env.MOBIUS_TUI_HOME
93
+ process.env.MOBIUS_TUI_HOME = home
94
+ const statuses: string[] = []
95
+ let childRef: any
96
+ const supervisor = new AimuxSupervisor({
97
+ server: 'https://mobius.test', token: 'secret-token', identifier: 'tui-log',
98
+ retryBaseMs: 100_000,
99
+ probeConnection: async () => true,
100
+ spawnProcess: () => {
101
+ childRef = fakeChild(() => {})
102
+ return childRef
103
+ },
104
+ onStatus: status => statuses.push(status.detail || ''),
105
+ })
106
+ supervisor.start()
107
+ childRef.stderr.emit('data', Buffer.from('Traceback\n File "site-packages/loguru/_ctime_functions.py", line 7\nImportError: win32_setctime missing\n'))
108
+ childRef.emit('exit', 1)
109
+ await delay(40)
110
+ const log = await fs.readFile(aimuxLogPath(), 'utf8')
111
+ ok(log.includes('win32_setctime missing') && log.includes('AIMUX exit code=1'), 'AIMUX stdout/stderr and exit code are persisted')
112
+ ok(log.includes('_ctime_functions.py') && !log.includes('secret-token'), 'diagnostic log keeps traceback context without JWT')
113
+ ok(statuses.some(s => s.includes('日志:') && s.includes('aimux.log')), 'failure status points to the persistent log path')
114
+ await supervisor.stop()
115
+ if (savedHome === undefined) delete process.env.MOBIUS_TUI_HOME; else process.env.MOBIUS_TUI_HOME = savedHome
116
+ await fs.rm(home, { recursive: true, force: true })
117
+ }
118
+
119
+ function captureStdout(child: ReturnType<typeof spawn>): Promise<string> {
120
+ let out = ''
121
+ child.stdout?.on('data', d => { out += d.toString() })
122
+ return new Promise(resolve => child.on('close', () => resolve(out)))
123
+ }
124
+
125
+ async function testSpawnLauncher() {
126
+ console.log('\n[AIMUX 5] Plan B spawnLauncher routing')
127
+ // exe launcher: spawn the binary directly with the given args
128
+ let out = await captureStdout(spawnLauncher({ kind: 'exe', path: '/bin/echo' }, ['HELLO', 'arg']))
129
+ ok(out.trim() === 'HELLO arg', `exe launcher runs the aimux binary directly (got: ${out.trim()})`)
130
+ // module launcher: inject `-m aimux` in front (so `<python> -m aimux ...`)
131
+ out = await captureStdout(spawnLauncher({ kind: 'module', python: '/bin/echo' }, ['reverse', 'connect']))
132
+ ok(out.trim() === '-m aimux reverse connect', `module launcher prepends -m aimux (got: ${out.trim()})`)
133
+ }
134
+
135
+ function testReverseConnectArgs() {
136
+ console.log('\n[AIMUX 6] reverse connect Windows shell visibility')
137
+ const win = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32')
138
+ const linux = reverseConnectArgs('https://mobius.test/', 'tui-linux', 'jwt-test', 'linux')
139
+ ok(win.includes('--silent-shell'), 'Windows reverse connection always requests hidden command shells')
140
+ ok(!linux.includes('--silent-shell'), 'non-Windows reverse connection does not receive the Windows-only flag')
141
+ ok(win[2] === 'https://mobius.test/aimux_bridge', 'reverse connection normalizes the bridge URL')
142
+ }
143
+
144
+ function testBundleHealthCheck() {
145
+ console.log('\n[AIMUX 6b] bundle dependency health check')
146
+ const win = bundleHealthCheckCode('win32')
147
+ const linux = bundleHealthCheckCode('linux')
148
+ ok(win.includes('aimux.bridge_client') && win.includes('win32_setctime'), 'Windows bundle probe imports the real bridge path and its platform dependency')
149
+ ok(win.includes("aimux.__version__ == '0.1.21'"), 'bundle probe rejects stale AIMUX versions')
150
+ ok(!linux.includes('win32_setctime'), 'non-Windows bundle probe does not require the Windows-only package')
151
+ }
152
+
153
+ async function testEnsureFromBundleReady() {
154
+ console.log('\n[AIMUX 7] Plan B ensureFromBundle fast-path (bundle already extracted)')
155
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), 'mobius-tui-bundle-'))
156
+ const savedHome = process.env.MOBIUS_TUI_HOME
157
+ process.env.MOBIUS_TUI_HOME = home
158
+ // 放一个"假 python": 任何 `-c import aimux` 都返回 0 → bundleReady() 为真
159
+ const fakePy = path.join(home, 'python-bundle', 'python', 'bin', 'python3')
160
+ await fs.mkdir(path.dirname(fakePy), { recursive: true })
161
+ await fs.writeFile(fakePy, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
162
+ try {
163
+ const r = await ensureFromBundle()
164
+ ok(r.ok === true && r.launcher?.kind === 'module', 'ensureFromBundle short-circuits when the bundle is already present')
165
+ ok(r.launcher?.kind === 'module' && r.launcher.python.endsWith(path.join('python-bundle', 'python', 'bin', 'python3')), 'launcher points at the bundled python')
166
+ ok(!existsSync(path.join(home, 'python-bundle-v1.zip.tmp')), 'no download tmp is left behind on the fast-path')
167
+ } finally { if (savedHome === undefined) delete process.env.MOBIUS_TUI_HOME; else process.env.MOBIUS_TUI_HOME = savedHome; await fs.rm(home, { recursive: true, force: true }) }
168
+ }
169
+
170
+ async function testDownloadBundleStream() {
171
+ console.log('\n[AIMUX 8] Plan B downloadBundle streams body to file + reports progress')
172
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), 'mobius-tui-dl-'))
173
+ const savedHome = process.env.MOBIUS_TUI_HOME
174
+ process.env.MOBIUS_TUI_HOME = home
175
+ const realFetch = globalThis.fetch
176
+ const payload = Buffer.from(Array.from({ length: 64 * 1024 }, (_, i) => i & 0xff))
177
+ globalThis.fetch = (async () => new Response(payload as any, {
178
+ status: 200, headers: { 'content-length': String(payload.length) },
179
+ })) as typeof fetch
180
+ let progressCalls = 0
181
+ try {
182
+ const r = await downloadBundleForTest('linux-x64', () => { progressCalls += 1 })
183
+ ok(r.ok === true && !!r.zipPath, 'downloadBundle writes the streamed body to a zip tmp')
184
+ const written = await fs.readFile(r.zipPath!)
185
+ ok(written.length === payload.length && written[0] === 0 && written[65535] === 255, 'downloaded bytes match the streamed payload')
186
+ ok(progressCalls > 0, 'progress callback fires during streaming download')
187
+ await fs.unlink(r.zipPath!)
188
+ } finally {
189
+ globalThis.fetch = realFetch
190
+ if (savedHome === undefined) delete process.env.MOBIUS_TUI_HOME; else process.env.MOBIUS_TUI_HOME = savedHome
191
+ await fs.rm(home, { recursive: true, force: true })
192
+ }
193
+ }
194
+
195
+ async function main() {
196
+ await testStatusLine()
197
+ await testProbeContract()
198
+ await testAutomaticReconnect()
199
+ await testBundleArchAndUrl()
200
+ await testSpawnLauncher()
201
+ testReverseConnectArgs()
202
+ testBundleHealthCheck()
203
+ await testEnsureFromBundleReady()
204
+ await testDownloadBundleStream()
205
+ await testPersistentProcessLog()
206
+ console.log(`\n==== AIMUX RESULT: ${pass} passed, ${fail} failed ====\n`)
207
+ process.exit(fail === 0 ? 0 : 1)
208
+ }
209
+
210
+ main().catch(error => { console.error('FATAL', error); process.exit(2) })