@mobius-os/mobius 0.2.2

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/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # Mobius
2
+
3
+ A terminal client for [Mobius](../..), written in Node + TypeScript +
4
+ [Ink](https://github.com/vadimdemes/ink) (React for CLIs). It reuses the web
5
+ frontend's TypeScript domain types and jsonl entry shapes verbatim, and drives
6
+ the same backend HTTP + SSE API the web UI uses.
7
+
8
+ ```
9
+ mobius/tui/
10
+ src/
11
+ main.tsx entry — Ink render, exitOnCtrlC disabled
12
+ App.tsx route machine: login → prep → chat (+ resume)
13
+ config.ts ~/.mobius/{login,projects,dir2project,dir2project_preference}.json
14
+ api.ts MobiusClient — all HTTP endpoints (bearer auth)
15
+ sse.ts SseConnection — streaming fetch SSE frame parser
16
+ markdown.ts terminal markdown (marked lexer + cli-highlight + chalk)
17
+ types.ts domain + jsonl entry types (copied from frontend)
18
+ lib/entry-view.ts jsonl entry → renderable view (copied classify fns)
19
+ hooks/useChat.ts one session: lazy create + SSE + send
20
+ components/
21
+ primitives.tsx TextInput / Select (single+multi) / Spinner
22
+ Login.tsx server + username + optional password
23
+ PrepScreen.tsx project picker + preference wizard (issue→model→language→skill→memory)
24
+ Chat.tsx viewport-aware transcript + welcome card + composer/status
25
+ ResumePicker.tsx /resume — 32 most-recent project sessions
26
+ tests/
27
+ integration.test.ts real backend end-to-end (your server)
28
+ ui.test.tsx ink-testing-library + mocked fetch/SSE
29
+ bin/mobius-tui.js launcher
30
+ ```
31
+
32
+ ## Run
33
+
34
+ ```bash
35
+ cd mobius/tui
36
+ npm install --include=dev # NODE_ENV=production prunes devDeps → must pass --include=dev
37
+ npm start --silent # suppress npm's script banner
38
+ # or (cleanest): ./bin/mobius-tui.js
39
+ ```
40
+
41
+ On first launch there is no `~/.mobius/login.json`, so the login screen appears.
42
+ Enter your Mobius server URL and username (most servers are passwordless).
43
+ On success the token is saved and the next launch auto-logs in
44
+ (validated via `/api/auth/me`; re-login on expiry).
45
+
46
+ After a successful login the TUI also starts its local AIMUX reverse connection
47
+ in the background. The first run creates `~/.mobius/aimux-venv` and installs
48
+ the `aimux` Python package, then runs:
49
+
50
+ ```text
51
+ aimux reverse connect <server>/aimux_bridge --identifier tui-<hostname> --token <jwt> --replace
52
+ ```
53
+
54
+ Python 3.10+ is discovered from `MOBIUS_TUI_PYTHON`, `python3`/`python` (or
55
+ Windows `py`). If none is available and `uv` is installed, the TUI runs
56
+ `uv python install 3.11` for a user-local interpreter. Set
57
+ `MOBIUS_TUI_DISABLE_AIMUX=1` to opt out, for example on a machine that should
58
+ only use the web API. A failed child is retried automatically with exponential
59
+ backoff; AIMUX installation or connection failures do not prevent the chat
60
+ client from opening. Python discovery, virtual-environment creation, pip
61
+ download progress, bridge heartbeat state, and reconnect attempts remain
62
+ visible in the TUI status area while the rest of the client stays usable.
63
+
64
+ Once started, a background heartbeat checks
65
+ `/aimux_bridge/api/remotes/<identifier>/connection` every five seconds with the
66
+ current Mobius JWT. Three consecutive failed checks terminate the stale AIMUX
67
+ child and reconnect with 1/2/4/8/15-second capped exponential backoff. A
68
+ successful bridge heartbeat resets the backoff and changes the status indicator
69
+ to green.
70
+
71
+ ## Flow
72
+
73
+ 1. **Login** — server / username / password → `POST /api/auth/login` → save
74
+ `~/.mobius/login.json`.
75
+ 2. **Prep**
76
+ - read `cwd`; look up `dir2project.json[cwd]`.
77
+ - if unbound → pick an existing project or create one (bound to `cwd`).
78
+ - preference wizard, stepped only through what's missing:
79
+ **Issue (task)** → **model** → **language** → **skills** → **memories**.
80
+ Preferences are stored *inside* the selected issue, so switching issues
81
+ restores that issue's choices.
82
+ 3. **Chat**: startup identity/context card, viewport-aware
83
+ transcript, composer, and persistent model/project/task status at the bottom.
84
+ The final line always shows the current web issue/session URL as an OSC 8
85
+ hyperlink, so supported terminals can open the matching Mobius page directly
86
+ (the URL remains visible and copyable everywhere).
87
+ - The first submitted message lazily creates a session
88
+ (`POST /api/issues/:iid/sessions`) with the saved preferences, opens the
89
+ SSE stream (`GET /api/sessions/:id/events?token=`), and posts the message.
90
+ The session carries `pc_client_metadata` with `is_tui: true`, the local
91
+ AIMUX identifier and current directory; TUI sessions always default to PC mode
92
+ and always include the `mobius-aimux` Skill.
93
+ - `jsonl_entry` events append to the transcript as they arrive; the view keeps
94
+ recent output inside the current terminal height instead of mixing permanent
95
+ `<Static>` rows with dynamic UI.
96
+ - The `typing` event drives the working indicator. Press Esc (or Ctrl+C) to
97
+ interrupt a running turn.
98
+ - Slash commands: `/clear` (new session), `/resume` (history), `/help`,
99
+ `/quit`. Ctrl+C stops a running turn, or quits when idle.
100
+
101
+ ## Reusing the web frontend
102
+
103
+ The domain interfaces (`User`, `Project`, `Issue`, `Session`, `Message`,
104
+ `SessionModelOption`) are copied from `frontend/src/store.ts`; the jsonl
105
+ entry / render-block types from `frontend/src/components/viewer/types.ts`; and
106
+ the pure helpers `assistantResponseText`, `assistantEntryText`, `entryUserText`
107
+ and the noise predicates from `frontend/src/components/viewer/entry-classify.ts`.
108
+ `api.ts` mirrors the frontend's `api()` helper (`Authorization: Bearer`, JSON).
109
+
110
+ ## Backend contract notes (gotchas)
111
+
112
+ - Sessions are created under **issues**, not projects:
113
+ `POST /api/issues/:issueId/sessions`.
114
+ - Issue title field is **`title`**; session name field is **`name`**.
115
+ - Worktree flag is **`use_worktree`** (issues) / **`defaultUseWorktree`** (projects).
116
+ - Skill/Memory preferences are **exclusion lists** (`excluded_skill_ids` /
117
+ `excluded_memory_ids`); omitting = everything enabled.
118
+ - Model field is a short **`key`** from `GET /api/sessions/model-options`.
119
+ - No SSE `done`/`tool_call` events — everything is a `jsonl_entry` carrying a raw
120
+ Claude/Codex SDK entry; turn-end = `typing active:false`.
121
+
122
+ ## Test
123
+
124
+ ```bash
125
+ npm run typecheck
126
+ npm run test:ui # mocked fetch + fake SSE, no network
127
+ MOBIUS_TUI_WAIT_MS=90000 npm run test:integration # real backend (your server)
128
+ npm test # all three
129
+ ```
130
+
131
+ ## Build an installable package
132
+
133
+ From the repository root:
134
+
135
+ ```bash
136
+ python3 build.py --build-tui
137
+ ```
138
+
139
+ To build and immediately install the global command into the current user's
140
+ `~/.local` prefix:
141
+
142
+ ```bash
143
+ python3 build.py --build-tui-and-install
144
+ ```
145
+
146
+ For CI or a custom location:
147
+
148
+ ```bash
149
+ python3 build.py --build-tui-and-install --tui-install-prefix /custom/writable/prefix
150
+ ```
151
+
152
+ The command runs the TUI typecheck and AIMUX regression tests, then writes an
153
+ installable npm package plus checksum metadata to `mobius/tui-builds/`:
154
+
155
+ ```text
156
+ mobius-tui-<version>.tgz
157
+ mobius-tui-<version>.tgz.sha256
158
+ manifest.json
159
+ ```
160
+
161
+ Install it without `sudo` and without writing to `/usr/local`:
162
+
163
+ ```bash
164
+ npm install --global --prefix "$HOME/.local" \
165
+ /path/to/mobius/tui-builds/mobius-tui-<version>.tgz
166
+ export PATH="$HOME/.local/bin:$PATH"
167
+ mobius
168
+ ```
169
+
170
+ If the PATH export is not already in the shell profile, add it to `~/.bashrc`
171
+ once. Using the explicit user prefix avoids the `EACCES ... /usr/local/lib/node_modules/mobius`
172
+ error produced by a root-owned npm global prefix. The built package promotes
173
+ `tsx` to a runtime dependency, so a production/global install can execute the
174
+ TypeScript entry point without retaining the source checkout's devDependencies.
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // bin/mobius-tui.js — launch the Mobius TUI via tsx (no build step required).
3
+ import { spawnSync } from 'node:child_process'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dirname, join } from 'node:path'
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url))
8
+ const tsx = join(here, '..', 'node_modules', '.bin', 'tsx')
9
+ const entry = join(here, '..', 'src', 'main.tsx')
10
+
11
+ const result = spawnSync(tsx, [entry], { stdio: 'inherit' })
12
+ process.exit(result.status ?? 0)
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@mobius-os/mobius",
3
+ "version": "0.2.2",
4
+ "type": "module",
5
+ "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
+ "bin": {
7
+ "mobius": "bin/mobius-tui.js",
8
+ "mobius-tui": "bin/mobius-tui.js"
9
+ },
10
+ "scripts": {
11
+ "start": "tsx src/main.tsx"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "dependencies": {
19
+ "chalk": "^5.3.0",
20
+ "cli-highlight": "2.1.11",
21
+ "ink": "5.2.0",
22
+ "marked": "12.0.2",
23
+ "react": "18.3.1",
24
+ "tsx": "4.19.2"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ }
29
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * App — top-level route machine.
3
+ *
4
+ * login ──(success / auto-login)──▶ prep ──(preferences ready)──▶ chat
5
+ * chat ──/resume──▶ resume ──pick──▶ chat(resume)
6
+ * chat ──/clear───▶ chat (fresh, remounted)
7
+ *
8
+ * Auto-login: on startup, read ~/.mobius/login.json and validate the token via
9
+ * GET /api/auth/me; on a stale token, re-login with the stored username/password
10
+ * (matching the desktop electron flow). Otherwise show the login form.
11
+ */
12
+ import React, { useEffect, useState } from 'react'
13
+ import { Box, Text } from 'ink'
14
+ import { MobiusClient, getMe, login, ApiError } from './api.js'
15
+ import { loadLogin, saveLogin, type LoginRecord } from './config.js'
16
+ import { LoginScreen } from './components/Login.js'
17
+ import { PrepScreen, type ReadyState } from './components/PrepScreen.js'
18
+ import { ChatScreen } from './components/Chat.js'
19
+ import { ResumePicker } from './components/ResumePicker.js'
20
+ import { startAimuxConnection, stopAimuxConnection, type AimuxStatus } from './aimux.js'
21
+ import { AimuxStatusLine } from './components/AimuxStatus.js'
22
+
23
+ type Route = 'boot' | 'login' | 'prep' | 'chat' | 'resume'
24
+
25
+ export function App() {
26
+ const [route, setRoute] = useState<Route>('boot')
27
+ const [bootMsg, setBootMsg] = useState('初始化…')
28
+ const [client, setClient] = useState<MobiusClient | null>(null)
29
+ const [userId, setUserId] = useState<string | null>(null)
30
+ const [prefill, setPrefill] = useState<{ server?: string; username?: string }>({})
31
+ const [ready, setReady] = useState<ReadyState | null>(null)
32
+ const [chatKey, setChatKey] = useState(0)
33
+ const [resumeSessionId, setResumeSessionId] = useState<string | null>(null)
34
+ const [aimuxStatus, setAimuxStatus] = useState<AimuxStatus>({
35
+ state: process.env.MOBIUS_TUI_DISABLE_AIMUX === '1' ? 'disabled' : 'stopped',
36
+ phase: 'idle',
37
+ detail: process.env.MOBIUS_TUI_DISABLE_AIMUX === '1' ? 'AIMUX 自动连接已关闭' : '登录后自动连接',
38
+ })
39
+
40
+ function bootAimux(rec: LoginRecord): void {
41
+ // AIMUX installation/connection is deliberately backgrounded: the TUI can
42
+ // continue into project preparation while a first-time pip install runs.
43
+ void startAimuxConnection({
44
+ server: rec.server,
45
+ token: rec.token,
46
+ onStatus: (status: AimuxStatus) => {
47
+ setAimuxStatus(status)
48
+ if (status.detail) setBootMsg(status.detail)
49
+ },
50
+ }).catch((e: any) => setBootMsg(`AIMUX 启动失败: ${e?.message ?? String(e)}`))
51
+ }
52
+
53
+ useEffect(() => () => { void stopAimuxConnection() }, [])
54
+
55
+ // ── bootstrap ──────────────────────────────────────────────────────────────
56
+ useEffect(() => { (async () => {
57
+ const rec = await loadLogin()
58
+ if (!rec) { setRoute('login'); return }
59
+ setPrefill({ server: rec.server, username: rec.username })
60
+ const c = new MobiusClient(rec.server, rec.token)
61
+ try {
62
+ setBootMsg('校验登录态…')
63
+ const me = await getMe(rec.server, rec.token)
64
+ setUserId(me.id)
65
+ setClient(c); setRoute('prep')
66
+ bootAimux(rec)
67
+ } catch {
68
+ // token expired — try to re-login with stored creds
69
+ if (rec.password) {
70
+ try {
71
+ setBootMsg('登录态已过期,重新登录…')
72
+ const r = await login(rec.server, rec.username, rec.password)
73
+ const updated: LoginRecord = { ...rec, token: r.token, user: r.user }
74
+ await saveLogin(updated)
75
+ setUserId(r.user.id)
76
+ setClient(new MobiusClient(rec.server, r.token))
77
+ setRoute('prep')
78
+ bootAimux(updated)
79
+ return
80
+ } catch { /* fall through to login */ }
81
+ }
82
+ setRoute('login')
83
+ }
84
+ })() }, [])
85
+
86
+ // ── handlers ───────────────────────────────────────────────────────────────
87
+ function onLoginSuccess(rec: LoginRecord) {
88
+ setUserId(rec.user.id)
89
+ setClient(new MobiusClient(rec.server, rec.token))
90
+ setRoute('prep')
91
+ bootAimux(rec)
92
+ }
93
+
94
+ function onPrepReady(st: ReadyState) {
95
+ setReady(st)
96
+ setResumeSessionId(null)
97
+ setChatKey(k => k + 1)
98
+ setRoute('chat')
99
+ }
100
+
101
+ function onClear() {
102
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] clear')
103
+ setResumeSessionId(null)
104
+ setChatKey(k => k + 1) // remount Chat → fresh session on next send
105
+ }
106
+
107
+ function onResume() { if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] resume'); setRoute('resume') }
108
+
109
+ function onResumed(sid: string) {
110
+ setResumeSessionId(sid)
111
+ setChatKey(k => k + 1)
112
+ setRoute('chat')
113
+ }
114
+
115
+ function onQuit() {
116
+ void stopAimuxConnection().finally(() => process.exit(0))
117
+ }
118
+
119
+ // ── render ─────────────────────────────────────────────────────────────────
120
+ if (route === 'boot') {
121
+ return <Box paddingX={2} paddingY={1}><Text color="cyan">{bootMsg}</Text></Box>
122
+ }
123
+ if (route === 'login' || !client) {
124
+ return <LoginScreen onSuccess={onLoginSuccess} />
125
+ }
126
+ if (route === 'prep' || !ready) {
127
+ return <Box flexDirection="column"><AimuxStatusLine status={aimuxStatus} /><PrepScreen client={client} onReady={onPrepReady} onQuit={onQuit} /></Box>
128
+ }
129
+ if (route === 'resume') {
130
+ return <Box flexDirection="column"><AimuxStatusLine status={aimuxStatus} /><ResumePicker client={client} project={ready.project} onPick={onResumed} onBack={() => setRoute('chat')} /></Box>
131
+ }
132
+ return (
133
+ <ChatScreen
134
+ key={chatKey}
135
+ client={client}
136
+ ready={ready}
137
+ webUserId={ready.project.created_by || userId || ready.issue.created_by || ''}
138
+ resumeSessionId={resumeSessionId}
139
+ onClear={onClear}
140
+ onResume={onResume}
141
+ onQuit={onQuit}
142
+ aimuxStatus={aimuxStatus}
143
+ />
144
+ )
145
+ }
package/src/aimux.ts ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * AIMUX bootstrap for the terminal client.
3
+ *
4
+ * This mirrors the Electron shell: create a user-owned Python venv, install
5
+ * aimux on first use, then keep `aimux reverse connect` attached to the
6
+ * currently authenticated Mobius server. Nothing is started before login.
7
+ */
8
+ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
9
+ import { promises as fs, existsSync } from 'node:fs'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+ import { mobiusHome } from './config.js'
13
+
14
+ export type AimuxState = 'starting' | 'connected' | 'failed' | 'stopped' | 'disabled'
15
+ export type AimuxPhase = 'idle' | 'python' | 'venv' | 'install' | 'connecting' | 'heartbeat' | 'retrying' | 'connected'
16
+ export interface AimuxStatus {
17
+ state: AimuxState
18
+ phase?: AimuxPhase
19
+ detail?: string
20
+ identifier?: string
21
+ attempt?: number
22
+ }
23
+ export interface InstallProgress { phase: 'python' | 'venv' | 'install' | 'ready'; detail?: string }
24
+
25
+ const AIMUX_PACKAGE = 'aimux'
26
+ const WIN = process.platform === 'win32'
27
+ const venvDir = () => path.join(mobiusHome(), 'aimux-venv')
28
+ const venvPython = () => WIN ? path.join(venvDir(), 'Scripts', 'python.exe') : path.join(venvDir(), 'bin', 'python')
29
+ const aimuxExe = () => WIN ? path.join(venvDir(), 'Scripts', 'aimux.exe') : path.join(venvDir(), 'bin', 'aimux')
30
+
31
+ interface RunResult { code: number; stdout: string; stderr: string }
32
+
33
+ function run(cmd: string, args: string[], onLine?: (line: string) => void): Promise<RunResult> {
34
+ return new Promise(resolve => {
35
+ let stdout = '', stderr = ''
36
+ let child: ChildProcess
37
+ try { child = spawn(cmd, args, { windowsHide: true }) } catch (e: any) {
38
+ resolve({ code: 1, stdout, stderr: e?.message ?? String(e) }); return
39
+ }
40
+ const feed = (buf: Buffer, sink: (s: string) => void) => {
41
+ const text = buf.toString('utf8'); sink(text)
42
+ for (const line of text.split(/[\r\n]+/).map(s => s.trim()).filter(Boolean)) onLine?.(line)
43
+ }
44
+ child.stdout?.on('data', b => feed(b, s => { stdout += s }))
45
+ child.stderr?.on('data', b => feed(b, s => { stderr += s }))
46
+ child.on('error', e => resolve({ code: 1, stdout, stderr: e.message }))
47
+ child.on('close', code => resolve({ code: code ?? 0, stdout, stderr }))
48
+ })
49
+ }
50
+
51
+ function executable(cmd: string, args: string[] = ['--version']): boolean {
52
+ try { return spawnSync(cmd, args, { stdio: 'ignore', windowsHide: true }).status === 0 } catch { return false }
53
+ }
54
+
55
+ /** Find Python without assuming a package-manager-specific installation. */
56
+ function findPython(): string | null {
57
+ const configured = process.env.MOBIUS_TUI_PYTHON
58
+ if (configured && executable(configured, ['--version'])) return configured
59
+ const candidates = WIN ? ['python.exe', 'python', 'py'] : ['python3', 'python']
60
+ for (const candidate of candidates) if (executable(candidate, candidate === 'py' ? ['-3', '--version'] : ['--version'])) return candidate
61
+ return null
62
+ }
63
+
64
+ /** Install a user-local Python when no interpreter is present (requires uv). */
65
+ async function installPython(onProgress?: (p: InstallProgress) => void): Promise<string | null> {
66
+ if (!executable('uv', ['--version'])) return null
67
+ onProgress?.({ phase: 'python', detail: '未找到 Python,使用 uv 安装 Python 3.11…' })
68
+ const r = await run('uv', ['python', 'install', '3.11'], line => onProgress?.({ phase: 'python', detail: line.slice(0, 120) }))
69
+ if (r.code !== 0) return null
70
+ const found = await run('uv', ['python', 'find', '3.11'])
71
+ const candidate = found.stdout.trim().split(/\r?\n/).pop()?.trim()
72
+ return candidate && executable(candidate, ['--version']) ? candidate : findPython()
73
+ }
74
+
75
+ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promise<string | null> {
76
+ return findPython() ?? installPython(onProgress)
77
+ }
78
+
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 } }
81
+ 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 }
94
+ }
95
+
96
+ export function tuiAimuxIdentifier(): string {
97
+ const host = os.hostname().toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32)
98
+ return `tui-${host || 'pc'}`
99
+ }
100
+
101
+ export async function probeAimuxBridgeConnection(
102
+ server: string,
103
+ token: string,
104
+ identifier: string,
105
+ timeoutMs = 4_000,
106
+ ): Promise<boolean> {
107
+ const controller = new AbortController()
108
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
109
+ try {
110
+ const response = await fetch(
111
+ `${server.replace(/\/$/, '')}/aimux_bridge/api/remotes/${encodeURIComponent(identifier)}/connection`,
112
+ { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal },
113
+ )
114
+ if (!response.ok) return false
115
+ const data: any = await response.json().catch(() => ({}))
116
+ return data?.identifier === identifier && data?.event_stream_connected === true
117
+ } catch {
118
+ return false
119
+ } finally {
120
+ clearTimeout(timeout)
121
+ }
122
+ }
123
+
124
+ interface SupervisorOptions {
125
+ server: string
126
+ token: string
127
+ identifier: string
128
+ onStatus: (s: AimuxStatus) => void
129
+ heartbeatIntervalMs?: number
130
+ heartbeatFailureThreshold?: number
131
+ retryBaseMs?: number
132
+ probeConnection?: () => Promise<boolean>
133
+ spawnProcess?: () => ChildProcess
134
+ }
135
+
136
+ export class AimuxSupervisor {
137
+ private child: ChildProcess | null = null
138
+ private stopping = false
139
+ private retry: ReturnType<typeof setTimeout> | null = null
140
+ private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
141
+ private heartbeatEpoch = 0
142
+ private heartbeatFailures = 0
143
+ private reconnectAttempt = 0
144
+ private bridgeConnected = false
145
+ private opts: SupervisorOptions
146
+ constructor(opts: SupervisorOptions) { this.opts = opts }
147
+ start() { this.stopping = false; this.spawnChild() }
148
+ private spawnChild() {
149
+ const { server, token, identifier, onStatus } = this.opts
150
+ onStatus({ state: 'starting', phase: 'connecting', detail: '正在连接 Mobius AIMUX bridge…', identifier, attempt: this.reconnectAttempt })
151
+ const child = this.opts.spawnProcess?.() ?? spawn(
152
+ aimuxExe(),
153
+ ['reverse', 'connect', `${server.replace(/\/$/, '')}/aimux_bridge`, '--identifier', identifier, '--token', token, '--replace'],
154
+ { windowsHide: true },
155
+ )
156
+ this.child = child
157
+ this.startHeartbeat()
158
+ const classify = (buf: Buffer) => {
159
+ const text = buf.toString('utf8')
160
+ if (!this.bridgeConnected && /connected|registered|event stream|heartbeat|sse/i.test(text)) {
161
+ onStatus({ state: 'starting', phase: 'heartbeat', detail: 'AIMUX 已启动,等待 bridge 心跳确认…', identifier })
162
+ } else if (/connection (refused|reset|closed|error)|failed to connect|unauthorized|forbidden|token.*invalid/i.test(text)) {
163
+ onStatus({ state: 'failed', phase: 'heartbeat', detail: text.trim().slice(-200), identifier })
164
+ }
165
+ }
166
+ child.stdout?.on('data', classify); child.stderr?.on('data', classify)
167
+ child.on('error', e => onStatus({ state: 'failed', phase: 'retrying', detail: `AIMUX 启动失败: ${e.message}`, identifier }))
168
+ child.on('exit', code => {
169
+ if (this.child !== child) return
170
+ this.child = null
171
+ this.stopHeartbeat()
172
+ if (this.stopping) { onStatus({ state: 'stopped', phase: 'idle', detail: 'AIMUX 已停止', identifier }); return }
173
+ this.scheduleReconnect(`AIMUX 进程退出(code=${code})`)
174
+ })
175
+ }
176
+
177
+ private startHeartbeat() {
178
+ this.stopHeartbeat()
179
+ this.heartbeatFailures = 0
180
+ this.bridgeConnected = false
181
+ const epoch = ++this.heartbeatEpoch
182
+ void this.checkHeartbeat(epoch)
183
+ }
184
+
185
+ private stopHeartbeat() {
186
+ this.heartbeatEpoch += 1
187
+ if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer)
188
+ this.heartbeatTimer = null
189
+ }
190
+
191
+ private async checkHeartbeat(epoch: number): Promise<void> {
192
+ const connected = await (this.opts.probeConnection?.() ?? probeAimuxBridgeConnection(this.opts.server, this.opts.token, this.opts.identifier))
193
+ if (this.stopping || epoch !== this.heartbeatEpoch || !this.child) return
194
+
195
+ const threshold = this.opts.heartbeatFailureThreshold ?? 3
196
+ if (connected) {
197
+ this.heartbeatFailures = 0
198
+ this.reconnectAttempt = 0
199
+ this.bridgeConnected = true
200
+ this.opts.onStatus({
201
+ state: 'connected', phase: 'connected',
202
+ detail: `心跳正常 · ${this.opts.identifier}`,
203
+ identifier: this.opts.identifier,
204
+ })
205
+ } else {
206
+ this.heartbeatFailures += 1
207
+ if (this.heartbeatFailures >= threshold) {
208
+ this.bridgeConnected = false
209
+ await this.restartAfterDisconnect(`bridge 心跳连续 ${this.heartbeatFailures} 次未响应`)
210
+ return
211
+ }
212
+ this.opts.onStatus({
213
+ state: 'starting', phase: 'heartbeat',
214
+ detail: `等待 bridge 心跳确认(${this.heartbeatFailures}/${threshold})…`,
215
+ identifier: this.opts.identifier,
216
+ })
217
+ }
218
+ const interval = this.opts.heartbeatIntervalMs ?? 5_000
219
+ this.heartbeatTimer = setTimeout(() => void this.checkHeartbeat(epoch), interval)
220
+ }
221
+
222
+ private async restartAfterDisconnect(reason: string) {
223
+ this.stopHeartbeat()
224
+ await this.killChild()
225
+ if (!this.stopping) this.scheduleReconnect(reason)
226
+ }
227
+
228
+ private scheduleReconnect(reason: string) {
229
+ if (this.stopping || this.retry) return
230
+ this.reconnectAttempt += 1
231
+ const base = this.opts.retryBaseMs ?? 1_000
232
+ const delay = Math.min(15_000, base * (2 ** Math.min(this.reconnectAttempt - 1, 4)))
233
+ const seconds = Math.max(1, Math.ceil(delay / 1_000))
234
+ this.opts.onStatus({
235
+ state: 'failed', phase: 'retrying',
236
+ detail: `${reason},${seconds} 秒后进行第 ${this.reconnectAttempt} 次重连…`,
237
+ identifier: this.opts.identifier,
238
+ attempt: this.reconnectAttempt,
239
+ })
240
+ this.retry = setTimeout(() => {
241
+ this.retry = null
242
+ if (!this.stopping) this.spawnChild()
243
+ }, delay)
244
+ }
245
+
246
+ private async killChild() {
247
+ const child = this.child
248
+ this.child = null
249
+ if (!child?.pid) return
250
+ if (WIN) await run('taskkill', ['/PID', String(child.pid), '/T', '/F'])
251
+ else try { child.kill('SIGTERM') } catch { /* ignore */ }
252
+ }
253
+
254
+ async stop() {
255
+ this.stopping = true
256
+ if (this.retry) clearTimeout(this.retry)
257
+ this.retry = null
258
+ this.stopHeartbeat()
259
+ await this.killChild()
260
+ this.opts.onStatus({ state: 'stopped', phase: 'idle', detail: 'AIMUX 已停止', identifier: this.opts.identifier })
261
+ }
262
+ }
263
+
264
+ let supervisor: AimuxSupervisor | null = null
265
+ let installing: Promise<void> | null = null
266
+
267
+ export async function startAimuxConnection(opts: { server: string; token: string; onStatus?: (s: AimuxStatus) => void }): Promise<void> {
268
+ const onStatus = opts.onStatus ?? (() => {})
269
+ // Tests and explicitly opted-out users should not spawn a network worker.
270
+ if (process.env.MOBIUS_TUI_DISABLE_AIMUX === '1') {
271
+ onStatus({ state: 'disabled', phase: 'idle', detail: 'AIMUX 自动连接已关闭' }); return
272
+ }
273
+ if (process.env.NODE_ENV === 'test' || /^https?:\/\/mock(?:\.local)?(?::\d+)?$/i.test(opts.server)) {
274
+ onStatus({ state: 'disabled', phase: 'idle', detail: 'AIMUX 测试连接已跳过' }); return
275
+ }
276
+ if (supervisor || installing) return
277
+ installing = (async () => {
278
+ onStatus({ state: 'starting', phase: 'python', detail: '检查 Python 与 AIMUX 运行环境…' })
279
+ const ready = await ensureAimux(p => onStatus({
280
+ state: 'starting',
281
+ phase: p.phase === 'ready' ? 'connecting' : p.phase,
282
+ detail: p.detail || (p.phase === 'ready' ? 'AIMUX 已就绪,准备连接…' : p.phase),
283
+ }))
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 })
286
+ supervisor.start()
287
+ })().finally(() => { installing = null })
288
+ await installing
289
+ }
290
+
291
+ export async function stopAimuxConnection(): Promise<void> {
292
+ const current = supervisor; supervisor = null
293
+ await current?.stop()
294
+ }