@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/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
|
+
}
|