@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 +174 -0
- package/bin/mobius-tui.js +12 -0
- package/package.json +29 -0
- package/src/App.tsx +145 -0
- package/src/aimux.ts +294 -0
- package/src/api.ts +206 -0
- package/src/components/AimuxStatus.tsx +45 -0
- package/src/components/Chat.tsx +519 -0
- package/src/components/Login.tsx +88 -0
- package/src/components/PrepScreen.tsx +368 -0
- package/src/components/ResumePicker.tsx +63 -0
- package/src/components/primitives.tsx +241 -0
- package/src/config.ts +159 -0
- package/src/hooks/useChat.ts +332 -0
- package/src/lib/entry-view.ts +351 -0
- package/src/main.tsx +13 -0
- package/src/markdown.ts +160 -0
- package/src/sse.ts +126 -0
- package/src/types.ts +216 -0
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
|
+
}
|