@mobius-os/mobius 0.3.43 → 0.3.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.43",
3
+ "version": "0.3.45",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
package/src/aimux.ts CHANGED
@@ -92,7 +92,7 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
92
92
  // 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
93
93
  // 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
94
94
  const BUNDLE_VER = '3'
95
- const BUNDLE_AIMUX_VERSION = '0.1.28'
95
+ const BUNDLE_AIMUX_VERSION = '0.1.29'
96
96
  /** Version expected from the installed or bundled AIMUX runtime. */
97
97
  export const AIMUX_VERSION = BUNDLE_AIMUX_VERSION
98
98
  const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
package/src/api.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  * bearer token (header `Authorization: Bearer <jwt>`); there is no cookie auth.
7
7
  */
8
8
  import type {
9
+ AnyEntry,
9
10
  AuthConfig,
11
+ HistoryGroup,
10
12
  Issue,
11
13
  LoginResponse,
12
14
  Memory,
@@ -160,6 +162,17 @@ export class MobiusClient {
160
162
  return this.request<SessionRuntimeStatus>(`/api/sessions/${encodeURIComponent(sessionId)}/status`, { signal })
161
163
  }
162
164
 
165
+ // ── agent-history (协议 ①②: 组元数据 + 整组条目) ──────────────────────────
166
+ /** ① 全部组元数据, 一次给全. */
167
+ async listHistoryGroups(sessionId: string): Promise<{ session_version: number; groups: HistoryGroup[] }> {
168
+ return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups`)
169
+ }
170
+
171
+ /** ② 某组全部条目 (全量, 无分页, 条目不可变). */
172
+ async listHistoryGroupEntries(sessionId: string, groupId: string): Promise<{ group_id: string; version: number; entries: AnyEntry[] }> {
173
+ return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups/${encodeURIComponent(groupId)}/entries`)
174
+ }
175
+
163
176
  // ── preference lookups ────────────────────────────────────────────────────
164
177
  async modelOptions(): Promise<SessionModelOption[]> {
165
178
  const r = await this.request<any>('/api/sessions/model-options')
@@ -152,8 +152,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
152
152
 
153
153
  // First query of a fresh session triggers the full backend bootstrap (lazy
154
154
  // session creation, worker spawn, context load) before any output streams.
155
- // Label that phase "第一个问题,正在初始化+全平台同步中,请稍候" instead of "Working"
156
- // so it reads as startup rather than a stuck agent. Once the first assistant
155
+ // Label that phase as startup rather than a stuck agent. Once the first assistant
157
156
  // output is observed (or the session is a resumed one with prior history),
158
157
  // the indicator falls back to the normal Working label for every turn.
159
158
  const firstQueryInFlight = !resumeSessionId && !chat.entries.some(isAssistantOutput)
@@ -4,11 +4,14 @@
4
4
  * Lifecycle (per the TUI spec):
5
5
  * - lazily create a session (POST /api/issues/:issueId/sessions) on the first
6
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;
7
+ * - open the SSE stream (GET /api/sessions/:id/events?token=); on subscribe
8
+ * bootstrap the transcript via groups + ② tail-group entries, then apply
9
+ * live `entries` batches (watermark + uuid dedup) as they arrive;
10
+ * - reconnects re-run the same ① negotiation (stateless reconciliation):
11
+ * groups whose version changed are refetched whole, transcript rebuilt;
9
12
  * - keep the agent's busy state synchronized with the runtime status API.
10
13
  * `/clear` remounts the hook (fresh session next time); `/resume` injects a
11
- * pre-existing sessionId so the stream replays its history.
14
+ * pre-existing sessionId.
12
15
  */
13
16
  import { useCallback, useEffect, useRef, useState } from 'react'
14
17
  import { MobiusClient, ApiError } from '../api.js'
@@ -42,7 +45,7 @@ function nextId(): number { ID += 1; return ID }
42
45
  /**
43
46
  * Does this entry represent the user's just-submitted message? Used to retire the
44
47
  * optimistic `pendingUser` placeholder once the real entry is observed — including
45
- * via a reconnect's history replay (the live `jsonl_entry` path already clears it).
48
+ * via a reconnect's history reconciliation (the live `entries` path already clears it).
46
49
  *
47
50
  * Mobius may prepend injected context (project/issue framing) to a user turn, so we
48
51
  * match the typed text as a suffix of the entry's normalized text rather than
@@ -66,6 +69,22 @@ function entryKey(entry: AnyEntry): string | null {
66
69
  return typeof entry?.uuid === 'string' ? entry.uuid : null
67
70
  }
68
71
 
72
+ // 首次 bootstrap 拉取的末尾组数 (旧 SSE 尾部回放的等价物; 更早的组按需不拉,
73
+ // TUI 是平铺字幕, 没有轮次展开概念, 末尾几组已覆盖活跃对话).
74
+ const BOOTSTRAP_GROUP_COUNT = 3
75
+ // A fresh session can spend several seconds creating the worker and loading
76
+ // context before /status reports alive=true. Keep the first-turn indicator
77
+ // visible during that bootstrap window instead of letting the short generic
78
+ // hint expire and leaving the user with no feedback.
79
+ const FIRST_TURN_BOOTSTRAP_GRACE_MS = 30_000
80
+
81
+ /** Mini group store: 组序 + 水位线 (version) + 组内条目. */
82
+ interface GroupSlot {
83
+ seq: number
84
+ version: number
85
+ entries: AnyEntry[]
86
+ }
87
+
69
88
  // Retry transient gateway/transport errors so a brief 502/503/504 (a reverse-
70
89
  // proxy blip, a backend worker recycling after a deploy, a transient upstream
71
90
  // failure) doesn't immediately fail a message dispatch. 4xx errors are not
@@ -95,6 +114,8 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
95
114
  const [sending, setSending] = useState(false)
96
115
  const [error, setError] = useState<string | null>(null)
97
116
  const sseRef = useRef<SseConnection | null>(null)
117
+ // agent-history mini group store (协议 ①②③ 的 TUI 侧消费形态).
118
+ const groupSlotsRef = useRef<Map<string, GroupSlot>>(new Map())
98
119
  const pollNowRef = useRef<(() => void) | null>(null)
99
120
  const typingRef = useRef(false)
100
121
  const sendingRef = useRef(false)
@@ -103,7 +124,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
103
124
  // SSE auto-reconnect state. A reverse proxy's idle timeout (or a server
104
125
  // restart) drops the stream mid-session; without reconnect the TUI stops
105
126
  // receiving new jsonl entries even though the web client keeps updating.
106
- // On reconnect the server replays jsonl_history, so no entries are lost.
127
+ // On reconnect the stateless ①② reconciliation refills any missed entries.
107
128
  const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
108
129
  const reconnectAttemptRef = useRef(0)
109
130
  const aliveRef = useRef(true)
@@ -119,7 +140,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
119
140
  const appendEntries = useCallback((newOnes: AnyEntry[]) => {
120
141
  if (!newOnes.length) return
121
142
  setEntries(prev => {
122
- // De-duplicate by uuid so a live jsonl_entry that also appears in a
143
+ // De-duplicate by uuid so a live `entries` batch that also appears in a
123
144
  // reconnect's history replay is never shown twice.
124
145
  const seen = new Set<string>()
125
146
  for (const e of prev) { const k = entryKey(e); if (k) seen.add(k) }
@@ -145,6 +166,63 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
145
166
  setEntries(out)
146
167
  }, [])
147
168
 
169
+ // ── agent-history (协议 ①②③): bootstrap / 重连对账 ──────────────────────
170
+
171
+ /** 按组序摊平重建 transcript (uuid 去重由 setHistory 兜底). */
172
+ const rebuildEntriesFromGroups = useCallback(() => {
173
+ const slots = [...groupSlotsRef.current.values()].sort((a, b) => a.seq - b.seq)
174
+ const flat: AnyEntry[] = []
175
+ for (const s of slots) flat.push(...s.entries)
176
+ setHistory(flat)
177
+ }, [setHistory])
178
+
179
+ /**
180
+ * Stateless 对账 (订阅时/重连时同一条路径):
181
+ * ① 拿全部组元数据 → 本地没有的或 version 变了的组 ② 整组重拉 → 重建 transcript.
182
+ * 首次 (本地空) 只拉末尾 BOOTSTRAP_GROUP_COUNT 组; 之后每次只补差额, 常态零请求.
183
+ */
184
+ const reconcileHistory = useCallback(async (sid: string) => {
185
+ try {
186
+ const data = await client.listHistoryGroups(sid)
187
+ const groups: any[] = Array.isArray(data?.groups) ? data.groups : []
188
+ const local = groupSlotsRef.current
189
+ const targets = local.size === 0 ? groups.slice(-BOOTSTRAP_GROUP_COUNT) : groups
190
+ let changed = false
191
+ for (const g of targets) {
192
+ const gid = String(g?.id ?? '')
193
+ if (!gid) continue
194
+ const ver = Number(g?.version) || 0
195
+ const cur = local.get(gid)
196
+ if (cur && cur.version >= ver) continue
197
+ try {
198
+ const r = await client.listHistoryGroupEntries(sid, gid)
199
+ local.set(gid, {
200
+ seq: Number(g?.seq) || (local.size + 1),
201
+ version: Number(r?.version) || 0,
202
+ entries: Array.isArray(r?.entries) ? r.entries : [],
203
+ })
204
+ changed = true
205
+ } catch { /* 单组失败不阻塞其余组 */ }
206
+ }
207
+ // 服务端已不存在的组 → 丢弃 (会话被删/重建的防御).
208
+ const alive = new Set(groups.map((g: any) => String(g?.id ?? '')))
209
+ for (const gid of [...local.keys()]) {
210
+ if (!alive.has(gid)) { local.delete(gid); changed = true }
211
+ }
212
+ if (changed) rebuildEntriesFromGroups()
213
+ // 对账补齐后, 若乐观占位已被真实条目覆盖 → 退掉 (断线期间整轮完成的场景).
214
+ setPendingUser(prev => {
215
+ if (prev === null) return prev
216
+ const slots = [...local.values()].sort((a, b) => a.seq - b.seq)
217
+ const flat: AnyEntry[] = []
218
+ for (const s of slots) flat.push(...s.entries)
219
+ return flat.some(e => entryMatchesPendingUser(e, prev)) ? null : prev
220
+ })
221
+ } catch (e) {
222
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[history-reconcile]', (e as Error)?.message ?? e)
223
+ }
224
+ }, [client, rebuildEntriesFromGroups])
225
+
148
226
  // ── SSE connection ────────────────────────────────────────────────────────
149
227
  const connect = useCallback((sid: string) => {
150
228
  if (process.env.MOBIUS_TUI_DEBUG) console.error('[connect]', sid)
@@ -153,23 +231,49 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
153
231
  if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null }
154
232
  const url = `${client.server}/api/sessions/${encodeURIComponent(sid)}/events?token=${encodeURIComponent(client.token)}`
155
233
  const conn = new SseConnection(url, {
156
- onHistoryEntries: (es, _done) => {
157
- if (es.length) setHistory(es)
158
- // A reconnect replays the session tail. If our optimistic placeholder is
159
- // now backed by its real entry, retire it so the user's input isn't shown
160
- // twice (once as the entry, once as the placeholder). The live jsonl_entry
161
- // path already clears pendingUser, but a dropped SSE stream (reverse-proxy
162
- // idle timeout) can deliver the message only via this history replay — and
163
- // if the whole turn finished while disconnected, no live entry ever comes
164
- // to clear it, leaving the duplication on screen until the next send.
165
- setPendingUser(prev => (prev !== null && es.some(e => entryMatchesPendingUser(e, prev)) ? null : prev))
234
+ onGroupCreated: (group) => {
235
+ if (!group || typeof group !== 'object') return
236
+ const gid = String(group.id ?? '')
237
+ if (!gid || groupSlotsRef.current.has(gid)) return // 元数据不可变, 已知即忽略
238
+ groupSlotsRef.current.set(gid, {
239
+ seq: Number(group.seq) || (groupSlotsRef.current.size + 1),
240
+ version: Number(group.version) || 1,
241
+ entries: [],
242
+ })
166
243
  },
167
- onEntry: (entry) => {
168
- if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntry]', entry?.type, (entry?.message?.content?.[0]?.text ?? '').slice(0, 40))
169
- appendEntries([entry])
170
- setPendingUser(null)
244
+ onEntries: ({ group_id, group_id_version, entries }) => {
245
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntries]', group_id, group_id_version, entries.length)
246
+ const gid = String(group_id ?? '')
247
+ if (!gid) return
248
+ let slot = groupSlotsRef.current.get(gid)
249
+ if (!slot) {
250
+ // 事件早到且本地无该组 (错过 group_created): 建槽后按水位线对账.
251
+ slot = { seq: groupSlotsRef.current.size + 1, version: 0, entries: [] }
252
+ groupSlotsRef.current.set(gid, slot)
253
+ }
254
+ const version = Number(group_id_version) || 0
255
+ if (slot.entries.length > 0 && version <= slot.version) return // 水位线: ≤ 本地即丢弃
256
+ // uuid 去重保险丝: 与整组重拉/对账重叠的条目只留一份.
257
+ const known = new Set<string>()
258
+ for (const e of slot.entries) { const k = entryKey(e); if (k) known.add(k) }
259
+ const fresh = entries.filter(e => {
260
+ const k = entryKey(e)
261
+ if (k && known.has(k)) return false
262
+ if (k) known.add(k)
263
+ return true
264
+ })
265
+ slot.entries = slot.entries.concat(fresh)
266
+ slot.version = Math.max(slot.version, version)
267
+ if (fresh.length > 0) {
268
+ appendEntries(fresh)
269
+ setPendingUser(null)
270
+ }
271
+ },
272
+ onSubscribed: () => {
273
+ reconnectAttemptRef.current = 0
274
+ // 订阅即对账 (首开 = bootstrap 拉末尾几组; 重连 = stateless 补差额).
275
+ void reconcileHistory(sid)
171
276
  },
172
- onSubscribed: () => { reconnectAttemptRef.current = 0 },
173
277
  onTyping: (active) => {
174
278
  // SSE is a low-latency hint, not the source of truth. A `true` event
175
279
  // lights the indicator immediately; either edge requests a fresh
@@ -179,7 +283,11 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
179
283
  workingHintUntilRef.current = Date.now() + 1_500
180
284
  updateTyping(true)
181
285
  } else {
182
- workingHintUntilRef.current = 0
286
+ // A fresh turn uses a longer bootstrap grace period. Some agents
287
+ // emit an early typing=false edge before their worker is observable;
288
+ // do not let that transient edge erase the first-turn indicator.
289
+ const remaining = workingHintUntilRef.current - Date.now()
290
+ if (remaining < 5_000) workingHintUntilRef.current = 0
183
291
  }
184
292
  pollNowRef.current?.()
185
293
  },
@@ -204,7 +312,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
204
312
  })
205
313
  sseRef.current = conn
206
314
  conn.start()
207
- }, [client.server, client.token, appendEntries, setHistory, updateTyping])
315
+ }, [client.server, client.token, appendEntries, setHistory, updateTyping, reconcileHistory])
208
316
  doConnectRef.current = connect
209
317
 
210
318
  const ensureSseForSend = useCallback((sid: string): boolean => {
@@ -374,7 +482,8 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
374
482
  setError(null)
375
483
  setPendingUser(body)
376
484
  statusEpochRef.current += 1
377
- workingHintUntilRef.current = Date.now() + 2_000
485
+ const firstTurn = !sessionId && entries.length === 0
486
+ workingHintUntilRef.current = Date.now() + (firstTurn ? FIRST_TURN_BOOTSTRAP_GRACE_MS : 2_000)
378
487
  sendingRef.current = true
379
488
  updateTyping(true)
380
489
  setSending(true)
@@ -400,7 +509,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
400
509
  setSending(false)
401
510
  pollNowRef.current?.()
402
511
  }
403
- }, [sending, ensureSession, ensureSseForSend, client, updateTyping])
512
+ }, [sending, sessionId, entries.length, ensureSession, ensureSseForSend, client, updateTyping])
404
513
 
405
514
  const stop = useCallback(async () => {
406
515
  if (!sessionId) return
package/src/sse.ts CHANGED
@@ -15,8 +15,10 @@ import type { AnyEntry } from './types.js'
15
15
  export interface SseHandlers {
16
16
  onOpen?: () => void
17
17
  onSubscribed?: (session: any) => void
18
- onHistoryEntries?: (entries: AnyEntry[], done: boolean) => void
19
- onEntry?: (entry: AnyEntry) => void
18
+ /** ③ 新组元数据 (开轮卡本身随随后的 entries 事件到达). */
19
+ onGroupCreated?: (group: any) => void
20
+ /** ③ 组条目增量: version = 应用该批后的组版本 (调用方水位线判据). */
21
+ onEntries?: (payload: { group_id: string; group_id_version: number; entries: AnyEntry[] }) => void
20
22
  onTyping?: (active: boolean) => void
21
23
  onError?: (message: string, category?: string) => void
22
24
  onClose?: () => void
@@ -105,11 +107,15 @@ export class SseConnection {
105
107
  const ev = p?.event ?? eventName
106
108
  switch (ev) {
107
109
  case 'subscribed': this.handlers.onSubscribed?.(p.session); break
108
- case 'jsonl_history':
109
- this.handlers.onHistoryEntries?.(p.entries ?? [], !!p.done)
110
+ case 'group_created':
111
+ this.handlers.onGroupCreated?.(p.group)
110
112
  break
111
- case 'jsonl_entry':
112
- this.handlers.onEntry?.(p.entry)
113
+ case 'entries':
114
+ this.handlers.onEntries?.({
115
+ group_id: String(p.group_id ?? ''),
116
+ group_id_version: Number(p.group_id_version) || 0,
117
+ entries: Array.isArray(p.entries) ? p.entries : [],
118
+ })
113
119
  break
114
120
  case 'typing':
115
121
  this.handlers.onTyping?.(!!p.active)
@@ -119,7 +125,7 @@ export class SseConnection {
119
125
  this.handlers.onError?.(p.message ?? p.error ?? '未知错误', p.category)
120
126
  break
121
127
  default:
122
- // history / jsonl_meta / message / stream / etc. — currently unused by the TUI.
128
+ // history / message / stream / etc. — currently unused by the TUI.
123
129
  break
124
130
  }
125
131
  }
package/src/types.ts CHANGED
@@ -193,14 +193,23 @@ export interface ResourceAccess {
193
193
  // ════════════════════════════════════════════════════════════════════════════
194
194
  export type AnyEntry = Record<string, any>
195
195
 
196
+ // ── agent-history 组元数据 (协议 ① 的载荷) ───────────────────────────────────
197
+ export interface HistoryGroup {
198
+ id: string
199
+ seq: number
200
+ opener_ts: string | null
201
+ user_summary: string
202
+ version: number
203
+ entry_count: number
204
+ }
205
+
196
206
  // ── SSE envelope events (GET /api/sessions/:id/events) ───────────────────────
197
207
  // Each SSE frame's data is a JSON object with an `event` discriminator.
198
208
  export type SseEvent =
199
209
  | { event: 'subscribed'; session: Session }
200
210
  | { event: 'history'; messages: any[]; total?: number }
201
- | { event: 'jsonl_meta'; session_id: string; total?: number; total_approximate?: number; tail_count?: number; jsonl_path?: string }
202
- | { event: 'jsonl_history'; reset?: boolean; done?: boolean; chunk_index?: number; count?: number; entries: AnyEntry[] }
203
- | { event: 'jsonl_entry'; session_id: string; entry: AnyEntry }
211
+ | { event: 'group_created'; session_id: string; group: HistoryGroup }
212
+ | { event: 'entries'; session_id: string; group_id: string; group_id_version: number; entries: AnyEntry[] }
204
213
  | { event: 'typing'; active: boolean }
205
214
  | { event: 'error'; message?: string; category?: string }
206
215
  | { event: 'server_error'; message?: string }