@mobius-os/mobius 0.3.43 → 0.3.46

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.46",
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,10 @@
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,
12
+ HistoryPendingOpener,
10
13
  Issue,
11
14
  LoginResponse,
12
15
  Memory,
@@ -160,6 +163,22 @@ export class MobiusClient {
160
163
  return this.request<SessionRuntimeStatus>(`/api/sessions/${encodeURIComponent(sessionId)}/status`, { signal })
161
164
  }
162
165
 
166
+ // ── agent-history (协议 ①②: 组元数据 + 整组条目) ──────────────────────────
167
+ /** ① 全部组元数据, 一次给全; 顺带返回挂起中的开轮卡 (pending). */
168
+ async listHistoryGroups(sessionId: string): Promise<{ session_version: number; groups: HistoryGroup[]; pending: HistoryPendingOpener[] }> {
169
+ return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups`)
170
+ }
171
+
172
+ /** 打断当前 turn 并出队下一条排队指令 (不追加新 prompt). */
173
+ async pauseToDequeue(sessionId: string): Promise<{ ok: boolean }> {
174
+ return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/pause-to-dequeue`, { method: 'POST', body: '{}' })
175
+ }
176
+
177
+ /** ② 某组全部条目 (全量, 无分页, 条目不可变). */
178
+ async listHistoryGroupEntries(sessionId: string, groupId: string): Promise<{ group_id: string; version: number; entries: AnyEntry[] }> {
179
+ return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups/${encodeURIComponent(groupId)}/entries`)
180
+ }
181
+
163
182
  // ── preference lookups ────────────────────────────────────────────────────
164
183
  async modelOptions(): Promise<SessionModelOption[]> {
165
184
  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)
@@ -162,15 +161,32 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
162
161
  // (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
163
162
  // 避免在累积视图里把同一条提问显示多次.
164
163
  const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
165
- const pendingEntry = useMemo<AnyEntry | null>(() => chat.pendingUser === null ? null : ({
166
- type: 'user',
167
- __id: '__pending-user__',
168
- message: { role: 'user', content: chat.pendingUser },
169
- }), [chat.pendingUser])
170
- const transcriptEntries = useMemo(
171
- () => pendingEntry ? [...dedupedEntries, pendingEntry] : dedupedEntries,
172
- [dedupedEntries, pendingEntry],
173
- )
164
+ const pendingEntry = useMemo<AnyEntry | null>(() => {
165
+ // 忙时提交的指令已由排队行统一呈现, 乐观占位退役 (避免 "You:" 与 "排队" 双重显示).
166
+ if (chat.pending.length > 0) return null
167
+ return chat.pendingUser === null ? null : ({
168
+ type: 'user',
169
+ __id: '__pending-user__',
170
+ message: { role: 'user', content: chat.pendingUser },
171
+ })
172
+ }, [chat.pendingUser, chat.pending.length])
173
+ // 排队行 (合成条目): 显示最后一条挂起指令 + 总数, 紧跟对话末尾.
174
+ const pendingQueueEntry = useMemo<AnyEntry | null>(() => {
175
+ if (chat.pending.length === 0) return null
176
+ const last = chat.pending[chat.pending.length - 1]
177
+ return {
178
+ type: '__pending_queue__',
179
+ __id: '__pending-queue__',
180
+ text: last?.user_summary ?? '',
181
+ count: chat.pending.length,
182
+ }
183
+ }, [chat.pending])
184
+ const transcriptEntries = useMemo(() => {
185
+ const parts = [...dedupedEntries]
186
+ if (pendingEntry) parts.push(pendingEntry)
187
+ if (pendingQueueEntry) parts.push(pendingQueueEntry)
188
+ return parts
189
+ }, [dedupedEntries, pendingEntry, pendingQueueEntry])
174
190
 
175
191
  // Markdown parsing and wrapping are paid once per entry/terminal width. Keep
176
192
  // the two most recent widths so resize-back does not immediately reparse the
@@ -415,6 +431,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
415
431
  commands={SLASH_COMMANDS}
416
432
  onHeightChange={setComposerRows}
417
433
  inputActiveRef={chatInputActiveRef}
434
+ onPauseToDequeue={chat.pauseToDequeue}
435
+ hasPending={chat.pending.length > 0}
418
436
  />
419
437
  <StatusArea
420
438
  ready={ready}
@@ -500,7 +518,8 @@ function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
500
518
  : tone === 'edit_header' || tone === 'reasoning' ? 'magenta'
501
519
  : tone === 'edit_new' ? 'green'
502
520
  : tone === 'system' ? 'yellow'
503
- : undefined
521
+ : tone === 'pending' ? 'yellow'
522
+ : undefined
504
523
  const dimColor = tone === 'tool_result' || tone === 'tool_error' || tone === 'reasoning' || tone === 'system'
505
524
  return <Text wrap="truncate-end" bold={tone === 'user'} dimColor={dimColor} color={color}>{text}</Text>
506
525
  }
@@ -615,9 +634,12 @@ interface ComposerProps {
615
634
  commands: { cmd: string; desc: string }[]
616
635
  onHeightChange?: (rows: number) => void
617
636
  inputActiveRef?: React.RefObject<boolean>
637
+ // 排队插队: 有挂起指令时空输入回车触发; hasPending 控制提示文案与触发开关.
638
+ onPauseToDequeue?: () => void
639
+ hasPending?: boolean
618
640
  }
619
641
 
620
- export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange, inputActiveRef }: ComposerProps) {
642
+ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange, inputActiveRef, onPauseToDequeue, hasPending }: ComposerProps) {
621
643
  const [value, setValue] = useState('')
622
644
  const [cursor, setCursor] = useState(0)
623
645
  const [popupIdx, setPopupIdx] = useState(0)
@@ -838,6 +860,9 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
838
860
  onSubmit(submitted)
839
861
  edit('', 0)
840
862
  setHistIdx(null)
863
+ } else if (hasPending) {
864
+ // 空输入回车 = 插队: 打断当前 turn 并出队下一条排队指令.
865
+ void onPauseToDequeue?.()
841
866
  }
842
867
  return
843
868
  }
@@ -970,7 +995,10 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
970
995
  <Box justifyContent="space-between">
971
996
  {confirmQuit
972
997
  ? <Text color="yellowBright" bold>请再次按下Ctrl+C退出</Text>
973
- : <Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>}
998
+ : <Text dimColor>
999
+ {hasPending ? <Text color="yellowBright">空回车插队 · </Text> : null}
1000
+ {(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}
1001
+ </Text>}
974
1002
  <Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
975
1003
  </Box>
976
1004
  </Box>
@@ -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'
@@ -16,7 +19,7 @@ import { SseConnection } from '../sse.js'
16
19
  import { updateIssuePreference } from '../config.js'
17
20
  import { tuiAimuxIdentifier, probeAimuxBridgeConnection } from '../aimux.js'
18
21
  import { viewsForEntry } from '../lib/entry-view.js'
19
- import type { AnyEntry } from '../types.js'
22
+ import type { AnyEntry, HistoryPendingOpener } from '../types.js'
20
23
  import type { ReadyState } from '../components/PrepScreen.js'
21
24
 
22
25
  export interface ChatApi {
@@ -28,12 +31,14 @@ export interface ChatApi {
28
31
  export interface ChatController {
29
32
  entries: AnyEntry[]
30
33
  pendingUser: string | null
34
+ pending: HistoryPendingOpener[]
31
35
  typing: boolean
32
36
  sending: boolean
33
37
  error: string | null
34
38
  sessionId: string | null
35
39
  send: (text: string) => Promise<void>
36
40
  stop: () => Promise<void>
41
+ pauseToDequeue: () => Promise<void>
37
42
  }
38
43
 
39
44
  let ID = 0
@@ -42,7 +47,7 @@ function nextId(): number { ID += 1; return ID }
42
47
  /**
43
48
  * Does this entry represent the user's just-submitted message? Used to retire the
44
49
  * 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).
50
+ * via a reconnect's history reconciliation (the live `entries` path already clears it).
46
51
  *
47
52
  * Mobius may prepend injected context (project/issue framing) to a user turn, so we
48
53
  * match the typed text as a suffix of the entry's normalized text rather than
@@ -66,6 +71,22 @@ function entryKey(entry: AnyEntry): string | null {
66
71
  return typeof entry?.uuid === 'string' ? entry.uuid : null
67
72
  }
68
73
 
74
+ // 首次 bootstrap 拉取的末尾组数 (旧 SSE 尾部回放的等价物; 更早的组按需不拉,
75
+ // TUI 是平铺字幕, 没有轮次展开概念, 末尾几组已覆盖活跃对话).
76
+ const BOOTSTRAP_GROUP_COUNT = 3
77
+ // A fresh session can spend several seconds creating the worker and loading
78
+ // context before /status reports alive=true. Keep the first-turn indicator
79
+ // visible during that bootstrap window instead of letting the short generic
80
+ // hint expire and leaving the user with no feedback.
81
+ const FIRST_TURN_BOOTSTRAP_GRACE_MS = 30_000
82
+
83
+ /** Mini group store: 组序 + 水位线 (version) + 组内条目. */
84
+ interface GroupSlot {
85
+ seq: number
86
+ version: number
87
+ entries: AnyEntry[]
88
+ }
89
+
69
90
  // Retry transient gateway/transport errors so a brief 502/503/504 (a reverse-
70
91
  // proxy blip, a backend worker recycling after a deploy, a transient upstream
71
92
  // failure) doesn't immediately fail a message dispatch. 4xx errors are not
@@ -91,10 +112,13 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
91
112
  const [sessionId, setSessionId] = useState<string | null>(resumeSessionId ?? null)
92
113
  const [entries, setEntries] = useState<AnyEntry[]>([])
93
114
  const [pendingUser, setPendingUser] = useState<string | null>(null)
115
+ const [pending, setPending] = useState<HistoryPendingOpener[]>([])
94
116
  const [typing, setTyping] = useState(false)
95
117
  const [sending, setSending] = useState(false)
96
118
  const [error, setError] = useState<string | null>(null)
97
119
  const sseRef = useRef<SseConnection | null>(null)
120
+ // agent-history mini group store (协议 ①②③ 的 TUI 侧消费形态).
121
+ const groupSlotsRef = useRef<Map<string, GroupSlot>>(new Map())
98
122
  const pollNowRef = useRef<(() => void) | null>(null)
99
123
  const typingRef = useRef(false)
100
124
  const sendingRef = useRef(false)
@@ -103,7 +127,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
103
127
  // SSE auto-reconnect state. A reverse proxy's idle timeout (or a server
104
128
  // restart) drops the stream mid-session; without reconnect the TUI stops
105
129
  // receiving new jsonl entries even though the web client keeps updating.
106
- // On reconnect the server replays jsonl_history, so no entries are lost.
130
+ // On reconnect the stateless ①② reconciliation refills any missed entries.
107
131
  const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
108
132
  const reconnectAttemptRef = useRef(0)
109
133
  const aliveRef = useRef(true)
@@ -119,7 +143,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
119
143
  const appendEntries = useCallback((newOnes: AnyEntry[]) => {
120
144
  if (!newOnes.length) return
121
145
  setEntries(prev => {
122
- // De-duplicate by uuid so a live jsonl_entry that also appears in a
146
+ // De-duplicate by uuid so a live `entries` batch that also appears in a
123
147
  // reconnect's history replay is never shown twice.
124
148
  const seen = new Set<string>()
125
149
  for (const e of prev) { const k = entryKey(e); if (k) seen.add(k) }
@@ -145,6 +169,65 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
145
169
  setEntries(out)
146
170
  }, [])
147
171
 
172
+ // ── agent-history (协议 ①②③): bootstrap / 重连对账 ──────────────────────
173
+
174
+ /** 按组序摊平重建 transcript (uuid 去重由 setHistory 兜底). */
175
+ const rebuildEntriesFromGroups = useCallback(() => {
176
+ const slots = [...groupSlotsRef.current.values()].sort((a, b) => a.seq - b.seq)
177
+ const flat: AnyEntry[] = []
178
+ for (const s of slots) flat.push(...s.entries)
179
+ setHistory(flat)
180
+ }, [setHistory])
181
+
182
+ /**
183
+ * Stateless 对账 (订阅时/重连时同一条路径):
184
+ * ① 拿全部组元数据 → 本地没有的或 version 变了的组 ② 整组重拉 → 重建 transcript.
185
+ * 首次 (本地空) 只拉末尾 BOOTSTRAP_GROUP_COUNT 组; 之后每次只补差额, 常态零请求.
186
+ */
187
+ const reconcileHistory = useCallback(async (sid: string) => {
188
+ try {
189
+ const data = await client.listHistoryGroups(sid)
190
+ const groups: any[] = Array.isArray(data?.groups) ? data.groups : []
191
+ // 挂起中的开轮卡 (排队指令): /groups 是权威快照, 覆盖本地增量.
192
+ setPending(Array.isArray(data?.pending) ? data.pending : [])
193
+ const local = groupSlotsRef.current
194
+ const targets = local.size === 0 ? groups.slice(-BOOTSTRAP_GROUP_COUNT) : groups
195
+ let changed = false
196
+ for (const g of targets) {
197
+ const gid = String(g?.id ?? '')
198
+ if (!gid) continue
199
+ const ver = Number(g?.version) || 0
200
+ const cur = local.get(gid)
201
+ if (cur && cur.version >= ver) continue
202
+ try {
203
+ const r = await client.listHistoryGroupEntries(sid, gid)
204
+ local.set(gid, {
205
+ seq: Number(g?.seq) || (local.size + 1),
206
+ version: Number(r?.version) || 0,
207
+ entries: Array.isArray(r?.entries) ? r.entries : [],
208
+ })
209
+ changed = true
210
+ } catch { /* 单组失败不阻塞其余组 */ }
211
+ }
212
+ // 服务端已不存在的组 → 丢弃 (会话被删/重建的防御).
213
+ const alive = new Set(groups.map((g: any) => String(g?.id ?? '')))
214
+ for (const gid of [...local.keys()]) {
215
+ if (!alive.has(gid)) { local.delete(gid); changed = true }
216
+ }
217
+ if (changed) rebuildEntriesFromGroups()
218
+ // 对账补齐后, 若乐观占位已被真实条目覆盖 → 退掉 (断线期间整轮完成的场景).
219
+ setPendingUser(prev => {
220
+ if (prev === null) return prev
221
+ const slots = [...local.values()].sort((a, b) => a.seq - b.seq)
222
+ const flat: AnyEntry[] = []
223
+ for (const s of slots) flat.push(...s.entries)
224
+ return flat.some(e => entryMatchesPendingUser(e, prev)) ? null : prev
225
+ })
226
+ } catch (e) {
227
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[history-reconcile]', (e as Error)?.message ?? e)
228
+ }
229
+ }, [client, rebuildEntriesFromGroups])
230
+
148
231
  // ── SSE connection ────────────────────────────────────────────────────────
149
232
  const connect = useCallback((sid: string) => {
150
233
  if (process.env.MOBIUS_TUI_DEBUG) console.error('[connect]', sid)
@@ -153,23 +236,62 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
153
236
  if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null }
154
237
  const url = `${client.server}/api/sessions/${encodeURIComponent(sid)}/events?token=${encodeURIComponent(client.token)}`
155
238
  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))
239
+ onGroupCreated: (group) => {
240
+ if (!group || typeof group !== 'object') return
241
+ const gid = String(group.id ?? '')
242
+ if (!gid || groupSlotsRef.current.has(gid)) return // 元数据不可变, 已知即忽略
243
+ groupSlotsRef.current.set(gid, {
244
+ seq: Number(group.seq) || (groupSlotsRef.current.size + 1),
245
+ version: Number(group.version) || 1,
246
+ entries: [],
247
+ })
248
+ // 新组开轮 = 后端已把挂起的 pending_round_openers 一次性出队 (flushPendingOpenersToSink).
249
+ // 排队行随之清空; 随后 entries 事件会把这一整组内容补齐.
250
+ setPending([])
166
251
  },
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])
252
+ onEntries: ({ group_id, group_id_version, entries }) => {
253
+ if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntries]', group_id, group_id_version, entries.length)
254
+ const gid = String(group_id ?? '')
255
+ if (!gid) return
256
+ let slot = groupSlotsRef.current.get(gid)
257
+ if (!slot) {
258
+ // 事件早到且本地无该组 (错过 group_created): 建槽后按水位线对账.
259
+ slot = { seq: groupSlotsRef.current.size + 1, version: 0, entries: [] }
260
+ groupSlotsRef.current.set(gid, slot)
261
+ }
262
+ const version = Number(group_id_version) || 0
263
+ if (slot.entries.length > 0 && version <= slot.version) return // 水位线: ≤ 本地即丢弃
264
+ // uuid 去重保险丝: 与整组重拉/对账重叠的条目只留一份.
265
+ const known = new Set<string>()
266
+ for (const e of slot.entries) { const k = entryKey(e); if (k) known.add(k) }
267
+ const fresh = entries.filter(e => {
268
+ const k = entryKey(e)
269
+ if (k && known.has(k)) return false
270
+ if (k) known.add(k)
271
+ return true
272
+ })
273
+ slot.entries = slot.entries.concat(fresh)
274
+ slot.version = Math.max(slot.version, version)
275
+ if (fresh.length > 0) {
276
+ appendEntries(fresh)
277
+ setPendingUser(null)
278
+ }
279
+ },
280
+ onPendingOpener: (opener) => {
281
+ // 忙时提交的新指令被挂起: 追加到排队行 (uuid 去重), 乐观占位随之退役.
282
+ if (!opener || typeof opener !== 'object') return
283
+ const id = String(opener.id ?? '')
284
+ setPending(prev => {
285
+ if (id && prev.some(p => p.id === id)) return prev
286
+ return [...prev, { id, opener_ts: opener.opener_ts ?? null, user_summary: opener.user_summary ?? '' }]
287
+ })
170
288
  setPendingUser(null)
171
289
  },
172
- onSubscribed: () => { reconnectAttemptRef.current = 0 },
290
+ onSubscribed: () => {
291
+ reconnectAttemptRef.current = 0
292
+ // 订阅即对账 (首开 = bootstrap 拉末尾几组; 重连 = stateless 补差额).
293
+ void reconcileHistory(sid)
294
+ },
173
295
  onTyping: (active) => {
174
296
  // SSE is a low-latency hint, not the source of truth. A `true` event
175
297
  // lights the indicator immediately; either edge requests a fresh
@@ -179,7 +301,11 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
179
301
  workingHintUntilRef.current = Date.now() + 1_500
180
302
  updateTyping(true)
181
303
  } else {
182
- workingHintUntilRef.current = 0
304
+ // A fresh turn uses a longer bootstrap grace period. Some agents
305
+ // emit an early typing=false edge before their worker is observable;
306
+ // do not let that transient edge erase the first-turn indicator.
307
+ const remaining = workingHintUntilRef.current - Date.now()
308
+ if (remaining < 5_000) workingHintUntilRef.current = 0
183
309
  }
184
310
  pollNowRef.current?.()
185
311
  },
@@ -204,7 +330,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
204
330
  })
205
331
  sseRef.current = conn
206
332
  conn.start()
207
- }, [client.server, client.token, appendEntries, setHistory, updateTyping])
333
+ }, [client.server, client.token, appendEntries, setHistory, updateTyping, reconcileHistory])
208
334
  doConnectRef.current = connect
209
335
 
210
336
  const ensureSseForSend = useCallback((sid: string): boolean => {
@@ -374,7 +500,8 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
374
500
  setError(null)
375
501
  setPendingUser(body)
376
502
  statusEpochRef.current += 1
377
- workingHintUntilRef.current = Date.now() + 2_000
503
+ const firstTurn = !sessionId && entries.length === 0
504
+ workingHintUntilRef.current = Date.now() + (firstTurn ? FIRST_TURN_BOOTSTRAP_GRACE_MS : 2_000)
378
505
  sendingRef.current = true
379
506
  updateTyping(true)
380
507
  setSending(true)
@@ -400,7 +527,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
400
527
  setSending(false)
401
528
  pollNowRef.current?.()
402
529
  }
403
- }, [sending, ensureSession, ensureSseForSend, client, updateTyping])
530
+ }, [sending, sessionId, entries.length, ensureSession, ensureSseForSend, client, updateTyping])
404
531
 
405
532
  const stop = useCallback(async () => {
406
533
  if (!sessionId) return
@@ -412,5 +539,18 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
412
539
  pollNowRef.current?.()
413
540
  }, [sessionId, client, updateTyping])
414
541
 
415
- return { entries, pendingUser, typing, sending, error, sessionId, send, stop }
542
+ // 打断当前 turn 并出队下一条排队指令 (空输入回车 / 插队). 不追加新 prompt,
543
+ // 后端对 claude-code/codex 发一次 C-c, deepseek harness 是空实现. 排队行会在
544
+ // 新组开轮 (group_created) 时被清空, 这里只需触发并刷新状态轮询.
545
+ const pauseToDequeue = useCallback(async () => {
546
+ if (!sessionId) return
547
+ statusEpochRef.current += 1
548
+ try { await client.pauseToDequeue(sessionId) } catch (e: any) {
549
+ const msg = e instanceof ApiError ? e.message : `插队失败: ${e?.message ?? e}`
550
+ setError(msg)
551
+ }
552
+ pollNowRef.current?.()
553
+ }, [sessionId, client])
554
+
555
+ return { entries, pendingUser, pending, typing, sending, error, sessionId, send, stop, pauseToDequeue }
416
556
  }
@@ -31,6 +31,7 @@ export type EntryView =
31
31
  | { kind: 'write_file'; filePath: string; content: string }
32
32
  | { kind: 'system'; text: string }
33
33
  | { kind: 'error'; text: string }
34
+ | { kind: 'pending'; text: string; count: number }
34
35
 
35
36
  export interface ToolResultView {
36
37
  text: string
@@ -113,7 +114,7 @@ function extractLocalCommandParts(entry: AnyEntry): LocalCommandPart[] {
113
114
  }
114
115
 
115
116
  /**
116
- * 整卡隐藏的噪声: 对齐 web entry-classify.ts isHiddenJsonlNoiseEntry 的 7
117
+ * 整卡隐藏的噪声: 对齐 web entry-classify.ts isHiddenJsonlNoiseEntry 的 8
117
118
  * - token_count : codex 每轮 token 用量统计 (event_msg)
118
119
  * - environment_context : codex 每轮注入的 <environment_context> 纯系统 user 消息
119
120
  * - session_meta : codex 会话首条元数据
@@ -121,10 +122,12 @@ function extractLocalCommandParts(entry: AnyEntry): LocalCommandPart[] {
121
122
  * - turn_duration : Claude Code 每轮结束注入的 system 耗时统计
122
123
  * - skill_listing : Claude Code 注入的可用 Skill 清单
123
124
  * - agent_listing_delta : Claude Code 注入的可用 subagent 清单
125
+ * - mcp_tool_call_end : MCP 工具调用完成生命周期标记
124
126
  * 注: context_compacted 不在此列 (对齐 web — 它保留为可见事件, TUI 显示成 system 行).
125
127
  */
126
128
  export function isHiddenNoise(entry: AnyEntry): boolean {
127
129
  if (entry?.type === 'event_msg' && entry?.payload?.type === 'token_count') return true
130
+ if (entry?.type === 'event_msg' && entry?.payload?.type === 'mcp_tool_call_end') return true
128
131
  if (entry?.type === 'session_meta') return true
129
132
  if (entry?.type === 'turn_context') return true
130
133
  if (entry?.type === 'system' && entry?.subtype === 'turn_duration') return true
@@ -515,6 +518,10 @@ function parseCustomToolCall(raw: any): { name: string; input: Record<string, an
515
518
  export function viewsForBlock(block: Block): EntryView[] {
516
519
  const entry = block.entry
517
520
  if (!entry || typeof entry !== 'object') return [{ kind: 'skip' }]
521
+ // 排队行 (合成条目): 显示忙时挂起的用户指令, 与 web 排队卡片同形.
522
+ if (entry.type === '__pending_queue__') {
523
+ return [{ kind: 'pending', text: String(entry.text ?? ''), count: Number(entry.count) || 1 }]
524
+ }
518
525
  if (isHiddenNoise(entry)) return [{ kind: 'skip' }]
519
526
  const type = entry.type
520
527
 
@@ -118,6 +118,7 @@ export type ScreenRowTone =
118
118
  | 'reasoning'
119
119
  | 'system'
120
120
  | 'error'
121
+ | 'pending'
121
122
 
122
123
  export interface ScreenRow {
123
124
  /** Visible text used for geometry, hit-testing, and clipboard extraction. */
@@ -204,6 +205,11 @@ export function viewScreenRows(view: EntryView, columns: number): ScreenRows {
204
205
  const rows = view.text.split('\n').map((l, i) => `${i === 0 ? '⚠ ' : ' '}${l}`)
205
206
  return { marginTop: true, rows: fit(rows, 'error') }
206
207
  }
208
+ case 'pending': {
209
+ const summary = clampLines(view.text, width - 6, 1)[0] || '(无内容)'
210
+ const tail = view.count > 1 ? ` · 等 ${view.count} 条指令` : ''
211
+ return { marginTop: true, rows: fit([`⚡ 排队 · ${summary}${tail}`], 'pending') }
212
+ }
207
213
  default:
208
214
  return { marginTop: false, rows: [] }
209
215
  }
package/src/sse.ts CHANGED
@@ -15,8 +15,12 @@ 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
22
+ /** 挂起开轮卡 (忙时提交、尚未出队的用户指令): { id, opener_ts, user_summary }. */
23
+ onPendingOpener?: (pending: { id: string; opener_ts: string | null; user_summary: string }) => void
20
24
  onTyping?: (active: boolean) => void
21
25
  onError?: (message: string, category?: string) => void
22
26
  onClose?: () => void
@@ -105,11 +109,18 @@ export class SseConnection {
105
109
  const ev = p?.event ?? eventName
106
110
  switch (ev) {
107
111
  case 'subscribed': this.handlers.onSubscribed?.(p.session); break
108
- case 'jsonl_history':
109
- this.handlers.onHistoryEntries?.(p.entries ?? [], !!p.done)
112
+ case 'group_created':
113
+ this.handlers.onGroupCreated?.(p.group)
110
114
  break
111
- case 'jsonl_entry':
112
- this.handlers.onEntry?.(p.entry)
115
+ case 'entries':
116
+ this.handlers.onEntries?.({
117
+ group_id: String(p.group_id ?? ''),
118
+ group_id_version: Number(p.group_id_version) || 0,
119
+ entries: Array.isArray(p.entries) ? p.entries : [],
120
+ })
121
+ break
122
+ case 'pending_opener':
123
+ this.handlers.onPendingOpener?.(p.entry)
113
124
  break
114
125
  case 'typing':
115
126
  this.handlers.onTyping?.(!!p.active)
@@ -119,7 +130,7 @@ export class SseConnection {
119
130
  this.handlers.onError?.(p.message ?? p.error ?? '未知错误', p.category)
120
131
  break
121
132
  default:
122
- // history / jsonl_meta / message / stream / etc. — currently unused by the TUI.
133
+ // history / message / stream / etc. — currently unused by the TUI.
123
134
  break
124
135
  }
125
136
  }
package/src/types.ts CHANGED
@@ -193,14 +193,33 @@ 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
+
206
+ /**
207
+ * 挂起中的开轮卡 (pending_round_openers): 忙时提交、尚未出队的用户指令.
208
+ * /groups 端点返回的 `pending` 数组与 SSE `pending_opener` 事件的 `entry` 字段同形.
209
+ */
210
+ export interface HistoryPendingOpener {
211
+ id: string
212
+ opener_ts: string | null
213
+ user_summary: string
214
+ }
215
+
196
216
  // ── SSE envelope events (GET /api/sessions/:id/events) ───────────────────────
197
217
  // Each SSE frame's data is a JSON object with an `event` discriminator.
198
218
  export type SseEvent =
199
219
  | { event: 'subscribed'; session: Session }
200
220
  | { 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 }
221
+ | { event: 'group_created'; session_id: string; group: HistoryGroup }
222
+ | { event: 'entries'; session_id: string; group_id: string; group_id_version: number; entries: AnyEntry[] }
204
223
  | { event: 'typing'; active: boolean }
205
224
  | { event: 'error'; message?: string; category?: string }
206
225
  | { event: 'server_error'; message?: string }