@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/src/config.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Local persistence for the Mobius terminal client under ~/.mobius/.
3
+ *
4
+ * Files (per the TUI spec):
5
+ * login.json — { server, username, password?, token, user }
6
+ * projects.json — cached list of known projects
7
+ * dir2project.json — { [cwd]: projectId }
8
+ * dir2project_preference.json — { [cwd]: CwdPreference }
9
+ *
10
+ * "Preferences are saved inside the task (Issue)": each cwd remembers the
11
+ * currently-selected issueId plus a per-issue preference store, so switching
12
+ * issues restores that issue's model / language / skill / memory choices.
13
+ *
14
+ * The base directory is resolved lazily so tests can redirect it via the
15
+ * MOBIUS_TUI_HOME env var (or setMobiusHome()) without touching the real home.
16
+ */
17
+ import { promises as fs } from 'node:fs'
18
+ import path from 'node:path'
19
+ import os from 'node:os'
20
+ import type { Project, User } from './types.js'
21
+
22
+ let _homeOverride: string | null = null
23
+ export function setMobiusHome(p: string): void { _homeOverride = p }
24
+ export function mobiusHome(): string {
25
+ return _homeOverride ?? process.env.MOBIUS_TUI_HOME ?? path.join(os.homedir(), '.mobius')
26
+ }
27
+ const LOGIN_FILE = () => path.join(mobiusHome(), 'login.json')
28
+ const PROJECTS_FILE = () => path.join(mobiusHome(), 'projects.json')
29
+ const DIR2PROJECT_FILE = () => path.join(mobiusHome(), 'dir2project.json')
30
+ const PREFERENCE_FILE = () => path.join(mobiusHome(), 'dir2project_preference.json')
31
+
32
+ export const MOBIUS_DIR = mobiusHome() // back-compat for any importer
33
+
34
+ export interface LoginRecord {
35
+ server: string
36
+ username: string
37
+ password?: string
38
+ token: string
39
+ user: User
40
+ }
41
+
42
+ /** Preferences bound to a single Issue (task). */
43
+ export interface IssuePreference {
44
+ model?: string
45
+ language?: 'zh' | 'en'
46
+ excluded_skill_ids: string[]
47
+ excluded_memory_ids: string[]
48
+ /** completed wizard step keys: 'model' | 'language' | 'skills' | 'memories' */
49
+ done?: string[]
50
+ }
51
+
52
+ /** Per-cwd preference state: which issue is active + each issue's saved prefs. */
53
+ export interface CwdPreference {
54
+ issueId?: string
55
+ issueTitle?: string
56
+ prefs: { [issueId: string]: IssuePreference }
57
+ }
58
+
59
+ async function readJson<T>(file: string, fallback: T): Promise<T> {
60
+ try {
61
+ const raw = await fs.readFile(file, 'utf8')
62
+ return JSON.parse(raw) as T
63
+ } catch {
64
+ return fallback
65
+ }
66
+ }
67
+
68
+ async function writeJson(file: string, data: unknown): Promise<void> {
69
+ await fs.mkdir(mobiusHome(), { recursive: true })
70
+ await fs.writeFile(file, JSON.stringify(data, null, 2), { mode: 0o600 })
71
+ }
72
+
73
+ // ── login.json ───────────────────────────────────────────────────────────────
74
+ export async function loadLogin(): Promise<LoginRecord | null> {
75
+ const rec = await readJson<LoginRecord | null>(LOGIN_FILE(), null)
76
+ if (!rec || !rec.token || !rec.server) return null
77
+ return rec
78
+ }
79
+
80
+ export async function saveLogin(rec: LoginRecord): Promise<void> {
81
+ await writeJson(LOGIN_FILE(), rec)
82
+ }
83
+
84
+ export async function clearLogin(): Promise<void> {
85
+ try { await fs.unlink(LOGIN_FILE()) } catch { /* ignore */ }
86
+ }
87
+
88
+ // ── projects.json (cache) ────────────────────────────────────────────────────
89
+ export async function loadProjectsCache(): Promise<Project[]> {
90
+ const data = await readJson<{ projects?: Project[] } | Project[]>(PROJECTS_FILE(), [])
91
+ return Array.isArray(data) ? data : (data.projects ?? [])
92
+ }
93
+
94
+ export async function saveProjectsCache(projects: Project[]): Promise<void> {
95
+ await writeJson(PROJECTS_FILE(), { projects })
96
+ }
97
+
98
+ // ── dir2project.json ─────────────────────────────────────────────────────────
99
+ export async function loadDir2Project(): Promise<Record<string, string>> {
100
+ return readJson<Record<string, string>>(DIR2PROJECT_FILE(), {})
101
+ }
102
+
103
+ export async function saveDir2Project(map: Record<string, string>): Promise<void> {
104
+ await writeJson(DIR2PROJECT_FILE(), map)
105
+ }
106
+
107
+ export async function bindCwdToProject(cwd: string, projectId: string): Promise<void> {
108
+ const map = await loadDir2Project()
109
+ map[cwd] = projectId
110
+ await saveDir2Project(map)
111
+ }
112
+
113
+ // ── dir2project_preference.json ──────────────────────────────────────────────
114
+ export async function loadPreferences(): Promise<Record<string, CwdPreference>> {
115
+ return readJson<Record<string, CwdPreference>>(PREFERENCE_FILE(), {})
116
+ }
117
+
118
+ export async function savePreferences(map: Record<string, CwdPreference>): Promise<void> {
119
+ await writeJson(PREFERENCE_FILE(), map)
120
+ }
121
+
122
+ export async function getCwdPreference(cwd: string): Promise<CwdPreference> {
123
+ const map = await loadPreferences()
124
+ return map[cwd] ?? { prefs: {} }
125
+ }
126
+
127
+ /** Persist the active issue for a cwd. */
128
+ export async function setCwdIssue(cwd: string, issueId: string, issueTitle?: string): Promise<IssuePreference> {
129
+ const map = await loadPreferences()
130
+ const cur = map[cwd] ?? { prefs: {} }
131
+ cur.issueId = issueId
132
+ cur.issueTitle = issueTitle
133
+ if (!cur.prefs[issueId]) {
134
+ cur.prefs[issueId] = { excluded_skill_ids: [], excluded_memory_ids: [] }
135
+ }
136
+ map[cwd] = cur
137
+ await savePreferences(map)
138
+ return cur.prefs[issueId]
139
+ }
140
+
141
+ /** Update one issue's preference fields for the cwd (merges into stored). */
142
+ export async function updateIssuePreference(
143
+ cwd: string,
144
+ issueId: string,
145
+ patch: Partial<IssuePreference>,
146
+ ): Promise<IssuePreference> {
147
+ const map = await loadPreferences()
148
+ const cur = map[cwd] ?? { prefs: {} }
149
+ const base: IssuePreference = cur.prefs[issueId] ?? { excluded_skill_ids: [], excluded_memory_ids: [] }
150
+ const merged: IssuePreference = { ...base, ...patch }
151
+ cur.prefs[issueId] = merged
152
+ map[cwd] = cur
153
+ await savePreferences(map)
154
+ return merged
155
+ }
156
+
157
+ export function cwd(): string {
158
+ return process.cwd()
159
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * useChat — drives one Mobius chat session.
3
+ *
4
+ * Lifecycle (per the TUI spec):
5
+ * - lazily create a session (POST /api/issues/:issueId/sessions) on the first
6
+ * submitted message, using the saved preferences;
7
+ * - open the SSE stream (GET /api/sessions/:id/events?token=) and append
8
+ * `jsonl_entry` payloads to the transcript as they arrive;
9
+ * - keep the agent's busy state synchronized with the runtime status API.
10
+ * `/clear` remounts the hook (fresh session next time); `/resume` injects a
11
+ * pre-existing sessionId so the stream replays its history.
12
+ */
13
+ import { useCallback, useEffect, useRef, useState } from 'react'
14
+ import { MobiusClient, ApiError } from '../api.js'
15
+ import { SseConnection } from '../sse.js'
16
+ import { updateIssuePreference } from '../config.js'
17
+ import { tuiAimuxIdentifier, probeAimuxBridgeConnection } from '../aimux.js'
18
+ import type { AnyEntry } from '../types.js'
19
+ import type { ReadyState } from '../components/PrepScreen.js'
20
+
21
+ export interface ChatApi {
22
+ client: MobiusClient
23
+ ready: ReadyState
24
+ resumeSessionId?: string | null
25
+ }
26
+
27
+ export interface ChatController {
28
+ entries: AnyEntry[]
29
+ pendingUser: string | null
30
+ typing: boolean
31
+ sending: boolean
32
+ error: string | null
33
+ sessionId: string | null
34
+ send: (text: string) => Promise<void>
35
+ stop: () => Promise<void>
36
+ }
37
+
38
+ let ID = 0
39
+ function nextId(): number { ID += 1; return ID }
40
+
41
+ // Retry transient gateway/transport errors so a brief 502/503/504 (a reverse-
42
+ // proxy blip, a backend worker recycling after a deploy, a transient upstream
43
+ // failure) doesn't immediately fail a message dispatch. 4xx errors are not
44
+ // retried — repeating them won't change the outcome. The caller passes one
45
+ // fixed reqId so the backend can de-duplicate across attempts.
46
+ async function sendWithRetry(fn: () => Promise<unknown>, maxAttempts = 3): Promise<void> {
47
+ for (let attempt = 0; ; attempt++) {
48
+ try {
49
+ await fn()
50
+ return
51
+ } catch (e: any) {
52
+ const status = e?.status
53
+ const transient =
54
+ status === 502 || status === 503 || status === 504 ||
55
+ status === 0 || e?.name === 'TypeError' // fetch-level network failure
56
+ if (!transient || attempt >= maxAttempts - 1) throw e
57
+ await new Promise(r => setTimeout(r, 500 * 2 ** attempt))
58
+ }
59
+ }
60
+ }
61
+
62
+ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatController {
63
+ const [sessionId, setSessionId] = useState<string | null>(resumeSessionId ?? null)
64
+ const [entries, setEntries] = useState<AnyEntry[]>([])
65
+ const [pendingUser, setPendingUser] = useState<string | null>(null)
66
+ const [typing, setTyping] = useState(false)
67
+ const [sending, setSending] = useState(false)
68
+ const [error, setError] = useState<string | null>(null)
69
+ const sseRef = useRef<SseConnection | null>(null)
70
+ const pollNowRef = useRef<(() => void) | null>(null)
71
+ const typingRef = useRef(false)
72
+ const sendingRef = useRef(false)
73
+ const workingHintUntilRef = useRef(0)
74
+ const statusEpochRef = useRef(0)
75
+ // SSE auto-reconnect state. A reverse proxy's idle timeout (or a server
76
+ // restart) drops the stream mid-session; without reconnect the TUI stops
77
+ // receiving new jsonl entries even though the web client keeps updating.
78
+ // On reconnect the server replays jsonl_history, so no entries are lost.
79
+ const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
80
+ const reconnectAttemptRef = useRef(0)
81
+ const aliveRef = useRef(true)
82
+ const stoppedRef = useRef(false)
83
+ const doConnectRef = useRef<(sid: string) => void>(() => {})
84
+
85
+ const updateTyping = useCallback((active: boolean) => {
86
+ typingRef.current = active
87
+ setTyping(active)
88
+ }, [])
89
+
90
+ const appendEntries = useCallback((newOnes: AnyEntry[]) => {
91
+ if (!newOnes.length) return
92
+ setEntries(prev => {
93
+ const stamped = newOnes.map(e => ({ ...e, __id: e.__id ?? nextId() }))
94
+ return [...prev, ...stamped]
95
+ })
96
+ }, [])
97
+
98
+ const setHistory = useCallback((list: AnyEntry[]) => {
99
+ setEntries(list.map(e => ({ ...e, __id: e.__id ?? nextId() })))
100
+ }, [])
101
+
102
+ // ── SSE connection ────────────────────────────────────────────────────────
103
+ const connect = useCallback((sid: string) => {
104
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[connect]', sid)
105
+ sseRef.current?.close()
106
+ if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null }
107
+ const url = `${client.server}/api/sessions/${encodeURIComponent(sid)}/events?token=${encodeURIComponent(client.token)}`
108
+ const conn = new SseConnection(url, {
109
+ onHistoryEntries: (es, _done) => {
110
+ if (es.length) setHistory(es)
111
+ },
112
+ onEntry: (entry) => {
113
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntry]', entry?.type, (entry?.message?.content?.[0]?.text ?? '').slice(0, 40))
114
+ appendEntries([entry])
115
+ setPendingUser(null)
116
+ },
117
+ onSubscribed: () => { reconnectAttemptRef.current = 0 },
118
+ onTyping: (active) => {
119
+ // SSE is a low-latency hint, not the source of truth. A `true` event
120
+ // lights the indicator immediately; either edge requests a fresh
121
+ // runtime status so missed/replayed events cannot leave stale UI.
122
+ statusEpochRef.current += 1
123
+ if (active) {
124
+ workingHintUntilRef.current = Date.now() + 1_500
125
+ updateTyping(true)
126
+ } else {
127
+ workingHintUntilRef.current = 0
128
+ }
129
+ pollNowRef.current?.()
130
+ },
131
+ onError: (msg) => setError(msg),
132
+ onClose: () => {
133
+ // Reconnect with exponential backoff as long as the session is still
134
+ // alive; stop once it ends (alive=false) or after a few failed tries.
135
+ if (stoppedRef.current || !aliveRef.current) return
136
+ const attempt = reconnectAttemptRef.current
137
+ if (attempt >= 6) return
138
+ const delay = Math.min(15_000, 500 * 2 ** attempt)
139
+ reconnectAttemptRef.current = attempt + 1
140
+ reconnectTimerRef.current = setTimeout(() => {
141
+ reconnectTimerRef.current = null
142
+ if (!stoppedRef.current) doConnectRef.current(sid)
143
+ }, delay)
144
+ },
145
+ })
146
+ sseRef.current = conn
147
+ conn.start()
148
+ }, [client.server, client.token, appendEntries, setHistory, updateTyping])
149
+ doConnectRef.current = connect
150
+
151
+ // Connect immediately when a resume session is provided, or after we create one.
152
+ useEffect(() => {
153
+ if (sessionId && !sseRef.current) connect(sessionId)
154
+ return () => { /* keep connection across re-renders; closed on unmount */ }
155
+ }, [sessionId, connect])
156
+
157
+ useEffect(() => () => {
158
+ stoppedRef.current = true
159
+ sseRef.current?.close()
160
+ if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
161
+ }, [])
162
+
163
+ // ── Runtime status synchronization ──────────────────────────────────────
164
+ // GET /api/sessions/:id/status is the only authoritative execution state.
165
+ // Poll recursively after each request completes so a slow network cannot
166
+ // accumulate overlapping requests. SSE merely asks this loop to run sooner.
167
+ useEffect(() => {
168
+ if (!sessionId) return
169
+
170
+ let stopped = false
171
+ let inFlight = false
172
+ let rerunImmediately = false
173
+ let timer: ReturnType<typeof setTimeout> | null = null
174
+ let controller: AbortController | null = null
175
+ let poll: () => Promise<void>
176
+
177
+ const schedule = (delayMs: number) => {
178
+ if (stopped) return
179
+ if (timer) clearTimeout(timer)
180
+ timer = setTimeout(() => { void poll() }, delayMs)
181
+ }
182
+
183
+ const requestNow = () => {
184
+ if (stopped) return
185
+ if (inFlight) {
186
+ rerunImmediately = true
187
+ return
188
+ }
189
+ schedule(0)
190
+ }
191
+
192
+ poll = async () => {
193
+ if (stopped || inFlight) return
194
+ inFlight = true
195
+ timer = null
196
+ const epoch = statusEpochRef.current
197
+ controller = new AbortController()
198
+ const timeout = setTimeout(() => controller?.abort(), 10_000)
199
+ let nextDelay = 5_000
200
+
201
+ try {
202
+ const status = await client.sessionStatus(sessionId, controller.signal)
203
+ if (stopped || epoch !== statusEpochRef.current) return
204
+ aliveRef.current = !!status.alive
205
+
206
+ if (status.alive && status.working) {
207
+ workingHintUntilRef.current = 0
208
+ updateTyping(true)
209
+ nextDelay = 2_000
210
+ } else {
211
+ const hintRemaining = workingHintUntilRef.current - Date.now()
212
+ if (hintRemaining > 0 || sendingRef.current) {
213
+ // Session creation and message dispatch can briefly precede the
214
+ // worker becoming observable. Preserve instant feedback while
215
+ // retrying quickly, with a bounded grace period.
216
+ updateTyping(true)
217
+ nextDelay = Math.max(100, Math.min(500, hintRemaining || 500))
218
+ } else {
219
+ updateTyping(false)
220
+ nextDelay = status.alive ? 5_000 : 15_000
221
+ }
222
+ }
223
+ } catch (e: any) {
224
+ // A status timeout or transient transport error must not disturb the
225
+ // transcript or make Working flicker. The next recursive poll retries.
226
+ if (process.env.MOBIUS_TUI_DEBUG && e?.name !== 'AbortError') {
227
+ console.error('[status-poll]', e?.message ?? e)
228
+ }
229
+ nextDelay = typingRef.current ? 2_000 : 5_000
230
+ } finally {
231
+ clearTimeout(timeout)
232
+ controller = null
233
+ inFlight = false
234
+ if (!stopped) {
235
+ if (rerunImmediately) {
236
+ rerunImmediately = false
237
+ schedule(0)
238
+ } else {
239
+ schedule(nextDelay)
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ pollNowRef.current = requestNow
246
+ requestNow()
247
+
248
+ return () => {
249
+ stopped = true
250
+ pollNowRef.current = null
251
+ if (timer) clearTimeout(timer)
252
+ controller?.abort()
253
+ }
254
+ }, [client, sessionId, updateTyping])
255
+
256
+ const ensureSession = useCallback(async (): Promise<string> => {
257
+ if (sessionId) return sessionId
258
+ const { project, issue, prefs } = ready
259
+ // 创建会话前确认 aimux reverse connect 已注册到服务器 bridge, 否则 codex 启动时
260
+ // 注入的 MCP server (aimux mcp serve --remote <id>) 会因 remote 不存在而退出.
261
+ // 不阻塞创建: 超时则继续 (aimux mcp serve 自身会兜底校验并报错给 codex).
262
+ const aimuxId = tuiAimuxIdentifier()
263
+ const probeDeadline = Date.now() + 8000
264
+ while (Date.now() < probeDeadline) {
265
+ try { if (await probeAimuxBridgeConnection(client.server, client.token, aimuxId)) break } catch {}
266
+ await new Promise(r => setTimeout(r, 500))
267
+ }
268
+ const name = `TUI ${new Date().toISOString().slice(5, 16).replace('T', ' ')}`
269
+ const s = await client.createSession(issue.id, {
270
+ name,
271
+ model: prefs.model,
272
+ language: prefs.language,
273
+ excluded_skill_ids: prefs.excluded_skill_ids,
274
+ excluded_memory_ids: prefs.excluded_memory_ids,
275
+ pc_client_metadata: {
276
+ work_mode: 'pc',
277
+ aimux_id: tuiAimuxIdentifier(),
278
+ local_path: process.cwd(),
279
+ is_tui: true,
280
+ add_remote_aimux_mcp: true,
281
+ },
282
+ })
283
+ const sid = s.session_id
284
+ setSessionId(sid)
285
+ // persist the chosen model/language onto this issue for next time
286
+ await updateIssuePreference(process.cwd(), issue.id, { model: prefs.model, language: prefs.language })
287
+ return sid
288
+ }, [sessionId, ready, client])
289
+
290
+ const send = useCallback(async (text: string) => {
291
+ const body = text.trim()
292
+ if (!body || sending) return
293
+ setError(null)
294
+ setPendingUser(body)
295
+ statusEpochRef.current += 1
296
+ workingHintUntilRef.current = Date.now() + 2_000
297
+ sendingRef.current = true
298
+ updateTyping(true)
299
+ setSending(true)
300
+ try {
301
+ const sid = await ensureSession()
302
+ // SSE may not be connected yet for a freshly created session; give it a tick.
303
+ if (!sseRef.current) await new Promise(r => setTimeout(r, 200))
304
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[send-post]', sid, 'sse=', !!sseRef.current, 'closed=', sseRef.current?.isClosed())
305
+ const reqId = `tui-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
306
+ await sendWithRetry(() => client.sendMessage(sid, body, reqId))
307
+ pollNowRef.current?.()
308
+ } catch (e: any) {
309
+ const msg = e instanceof ApiError ? e.message : `发送失败: ${e?.message ?? e}`
310
+ setError(msg)
311
+ setPendingUser(null)
312
+ workingHintUntilRef.current = 0
313
+ updateTyping(false)
314
+ } finally {
315
+ sendingRef.current = false
316
+ setSending(false)
317
+ pollNowRef.current?.()
318
+ }
319
+ }, [sending, ensureSession, client, updateTyping])
320
+
321
+ const stop = useCallback(async () => {
322
+ if (!sessionId) return
323
+ statusEpochRef.current += 1
324
+ workingHintUntilRef.current = 0
325
+ sendingRef.current = false
326
+ updateTyping(false)
327
+ try { await client.stopSession(sessionId) } catch { /* ignore */ }
328
+ pollNowRef.current?.()
329
+ }, [sessionId, client, updateTyping])
330
+
331
+ return { entries, pendingUser, typing, sending, error, sessionId, send, stop }
332
+ }