@mobius-os/mobius 0.3.45 → 0.3.47

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.45",
3
+ "version": "0.3.47",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
package/src/api.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  AnyEntry,
10
10
  AuthConfig,
11
11
  HistoryGroup,
12
+ HistoryPendingOpener,
12
13
  Issue,
13
14
  LoginResponse,
14
15
  Memory,
@@ -163,11 +164,16 @@ export class MobiusClient {
163
164
  }
164
165
 
165
166
  // ── agent-history (协议 ①②: 组元数据 + 整组条目) ──────────────────────────
166
- /** ① 全部组元数据, 一次给全. */
167
- async listHistoryGroups(sessionId: string): Promise<{ session_version: number; groups: HistoryGroup[] }> {
167
+ /** ① 全部组元数据, 一次给全; 顺带返回挂起中的开轮卡 (pending). */
168
+ async listHistoryGroups(sessionId: string): Promise<{ session_version: number; groups: HistoryGroup[]; pending: HistoryPendingOpener[] }> {
168
169
  return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups`)
169
170
  }
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
+
171
177
  /** ② 某组全部条目 (全量, 无分页, 条目不可变). */
172
178
  async listHistoryGroupEntries(sessionId: string, groupId: string): Promise<{ group_id: string; version: number; entries: AnyEntry[] }> {
173
179
  return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups/${encodeURIComponent(groupId)}/entries`)
@@ -161,15 +161,32 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
161
161
  // (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
162
162
  // 避免在累积视图里把同一条提问显示多次.
163
163
  const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
164
- const pendingEntry = useMemo<AnyEntry | null>(() => chat.pendingUser === null ? null : ({
165
- type: 'user',
166
- __id: '__pending-user__',
167
- message: { role: 'user', content: chat.pendingUser },
168
- }), [chat.pendingUser])
169
- const transcriptEntries = useMemo(
170
- () => pendingEntry ? [...dedupedEntries, pendingEntry] : dedupedEntries,
171
- [dedupedEntries, pendingEntry],
172
- )
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])
173
190
 
174
191
  // Markdown parsing and wrapping are paid once per entry/terminal width. Keep
175
192
  // the two most recent widths so resize-back does not immediately reparse the
@@ -372,6 +389,12 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
372
389
  ? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
373
390
  : <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
374
391
 
392
+ {chat.switchedAway ? (
393
+ <Box flexShrink={0} borderStyle="round" borderColor="yellow" paddingX={1}>
394
+ <Text color="yellow" bold>注意:智能体已经离开此设备前往新设备({chat.switchedAway})</Text>
395
+ </Box>
396
+ ) : null}
397
+
375
398
  {!showWelcome
376
399
  ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {navigationPosition}{viewport.hasNewer ? <Text color="yellowBright">↓ 有新内容</Text> : null}{navigationDetail}</Text></Box>
377
400
  : null}
@@ -414,6 +437,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
414
437
  commands={SLASH_COMMANDS}
415
438
  onHeightChange={setComposerRows}
416
439
  inputActiveRef={chatInputActiveRef}
440
+ onPauseToDequeue={chat.pauseToDequeue}
441
+ hasPending={chat.pending.length > 0}
417
442
  />
418
443
  <StatusArea
419
444
  ready={ready}
@@ -499,7 +524,8 @@ function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
499
524
  : tone === 'edit_header' || tone === 'reasoning' ? 'magenta'
500
525
  : tone === 'edit_new' ? 'green'
501
526
  : tone === 'system' ? 'yellow'
502
- : undefined
527
+ : tone === 'pending' ? 'yellow'
528
+ : undefined
503
529
  const dimColor = tone === 'tool_result' || tone === 'tool_error' || tone === 'reasoning' || tone === 'system'
504
530
  return <Text wrap="truncate-end" bold={tone === 'user'} dimColor={dimColor} color={color}>{text}</Text>
505
531
  }
@@ -614,9 +640,12 @@ interface ComposerProps {
614
640
  commands: { cmd: string; desc: string }[]
615
641
  onHeightChange?: (rows: number) => void
616
642
  inputActiveRef?: React.RefObject<boolean>
643
+ // 排队插队: 有挂起指令时空输入回车触发; hasPending 控制提示文案与触发开关.
644
+ onPauseToDequeue?: () => void
645
+ hasPending?: boolean
617
646
  }
618
647
 
619
- export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange, inputActiveRef }: ComposerProps) {
648
+ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange, inputActiveRef, onPauseToDequeue, hasPending }: ComposerProps) {
620
649
  const [value, setValue] = useState('')
621
650
  const [cursor, setCursor] = useState(0)
622
651
  const [popupIdx, setPopupIdx] = useState(0)
@@ -837,6 +866,9 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
837
866
  onSubmit(submitted)
838
867
  edit('', 0)
839
868
  setHistIdx(null)
869
+ } else if (hasPending) {
870
+ // 空输入回车 = 插队: 打断当前 turn 并出队下一条排队指令.
871
+ void onPauseToDequeue?.()
840
872
  }
841
873
  return
842
874
  }
@@ -969,7 +1001,10 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
969
1001
  <Box justifyContent="space-between">
970
1002
  {confirmQuit
971
1003
  ? <Text color="yellowBright" bold>请再次按下Ctrl+C退出</Text>
972
- : <Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>}
1004
+ : <Text dimColor>
1005
+ {hasPending ? <Text color="yellowBright">空回车插队 · </Text> : null}
1006
+ {(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}
1007
+ </Text>}
973
1008
  <Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
974
1009
  </Box>
975
1010
  </Box>
@@ -19,7 +19,7 @@ import { SseConnection } from '../sse.js'
19
19
  import { updateIssuePreference } from '../config.js'
20
20
  import { tuiAimuxIdentifier, probeAimuxBridgeConnection } from '../aimux.js'
21
21
  import { viewsForEntry } from '../lib/entry-view.js'
22
- import type { AnyEntry } from '../types.js'
22
+ import type { AnyEntry, HistoryPendingOpener } from '../types.js'
23
23
  import type { ReadyState } from '../components/PrepScreen.js'
24
24
 
25
25
  export interface ChatApi {
@@ -31,12 +31,16 @@ export interface ChatApi {
31
31
  export interface ChatController {
32
32
  entries: AnyEntry[]
33
33
  pendingUser: string | null
34
+ pending: HistoryPendingOpener[]
34
35
  typing: boolean
35
36
  sending: boolean
36
37
  error: string | null
37
38
  sessionId: string | null
39
+ /** 智能体已离开本 TUI 设备、前往的新设备 ID; null = 未离开 (仍绑定本设备或非本设备会话). */
40
+ switchedAway: string | null
38
41
  send: (text: string) => Promise<void>
39
42
  stop: () => Promise<void>
43
+ pauseToDequeue: () => Promise<void>
40
44
  }
41
45
 
42
46
  let ID = 0
@@ -110,9 +114,11 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
110
114
  const [sessionId, setSessionId] = useState<string | null>(resumeSessionId ?? null)
111
115
  const [entries, setEntries] = useState<AnyEntry[]>([])
112
116
  const [pendingUser, setPendingUser] = useState<string | null>(null)
117
+ const [pending, setPending] = useState<HistoryPendingOpener[]>([])
113
118
  const [typing, setTyping] = useState(false)
114
119
  const [sending, setSending] = useState(false)
115
120
  const [error, setError] = useState<string | null>(null)
121
+ const [switchedAway, setSwitchedAway] = useState<string | null>(null)
116
122
  const sseRef = useRef<SseConnection | null>(null)
117
123
  // agent-history mini group store (协议 ①②③ 的 TUI 侧消费形态).
118
124
  const groupSlotsRef = useRef<Map<string, GroupSlot>>(new Map())
@@ -185,6 +191,8 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
185
191
  try {
186
192
  const data = await client.listHistoryGroups(sid)
187
193
  const groups: any[] = Array.isArray(data?.groups) ? data.groups : []
194
+ // 挂起中的开轮卡 (排队指令): /groups 是权威快照, 覆盖本地增量.
195
+ setPending(Array.isArray(data?.pending) ? data.pending : [])
188
196
  const local = groupSlotsRef.current
189
197
  const targets = local.size === 0 ? groups.slice(-BOOTSTRAP_GROUP_COUNT) : groups
190
198
  let changed = false
@@ -240,6 +248,9 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
240
248
  version: Number(group.version) || 1,
241
249
  entries: [],
242
250
  })
251
+ // 新组开轮 = 后端已把挂起的 pending_round_openers 一次性出队 (flushPendingOpenersToSink).
252
+ // 排队行随之清空; 随后 entries 事件会把这一整组内容补齐.
253
+ setPending([])
243
254
  },
244
255
  onEntries: ({ group_id, group_id_version, entries }) => {
245
256
  if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntries]', group_id, group_id_version, entries.length)
@@ -269,6 +280,16 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
269
280
  setPendingUser(null)
270
281
  }
271
282
  },
283
+ onPendingOpener: (opener) => {
284
+ // 忙时提交的新指令被挂起: 追加到排队行 (uuid 去重), 乐观占位随之退役.
285
+ if (!opener || typeof opener !== 'object') return
286
+ const id = String(opener.id ?? '')
287
+ setPending(prev => {
288
+ if (id && prev.some(p => p.id === id)) return prev
289
+ return [...prev, { id, opener_ts: opener.opener_ts ?? null, user_summary: opener.user_summary ?? '' }]
290
+ })
291
+ setPendingUser(null)
292
+ },
272
293
  onSubscribed: () => {
273
294
  reconnectAttemptRef.current = 0
274
295
  // 订阅即对账 (首开 = bootstrap 拉末尾几组; 重连 = stateless 补差额).
@@ -389,6 +410,14 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
389
410
  if (stopped || epoch !== statusEpochRef.current) return
390
411
  aliveRef.current = !!status.alive
391
412
 
413
+ // 设备切换检测: 本会话最初绑定本 TUI 设备 (initial_aimux_id == myId) 但当前已指向
414
+ // 别处 (aimux_id != myId) → 智能体"已离开本设备前往新设备". 用于渲染显眼提示 (仅 TUI 端).
415
+ const myId = tuiAimuxIdentifier()
416
+ const left = status.initial_aimux_id === myId && status.aimux_id && status.aimux_id !== myId
417
+ ? status.aimux_id
418
+ : null
419
+ setSwitchedAway(left)
420
+
392
421
  if (status.alive && status.working) {
393
422
  workingHintUntilRef.current = 0
394
423
  updateTyping(true)
@@ -521,5 +550,18 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
521
550
  pollNowRef.current?.()
522
551
  }, [sessionId, client, updateTyping])
523
552
 
524
- return { entries, pendingUser, typing, sending, error, sessionId, send, stop }
553
+ // 打断当前 turn 并出队下一条排队指令 (空输入回车 / 插队). 不追加新 prompt,
554
+ // 后端对 claude-code/codex 发一次 C-c, deepseek harness 是空实现. 排队行会在
555
+ // 新组开轮 (group_created) 时被清空, 这里只需触发并刷新状态轮询.
556
+ const pauseToDequeue = useCallback(async () => {
557
+ if (!sessionId) return
558
+ statusEpochRef.current += 1
559
+ try { await client.pauseToDequeue(sessionId) } catch (e: any) {
560
+ const msg = e instanceof ApiError ? e.message : `插队失败: ${e?.message ?? e}`
561
+ setError(msg)
562
+ }
563
+ pollNowRef.current?.()
564
+ }, [sessionId, client])
565
+
566
+ return { entries, pendingUser, pending, typing, sending, error, sessionId, switchedAway, send, stop, pauseToDequeue }
525
567
  }
@@ -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
@@ -19,6 +19,8 @@ export interface SseHandlers {
19
19
  onGroupCreated?: (group: any) => void
20
20
  /** ③ 组条目增量: version = 应用该批后的组版本 (调用方水位线判据). */
21
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
22
24
  onTyping?: (active: boolean) => void
23
25
  onError?: (message: string, category?: string) => void
24
26
  onClose?: () => void
@@ -117,6 +119,9 @@ export class SseConnection {
117
119
  entries: Array.isArray(p.entries) ? p.entries : [],
118
120
  })
119
121
  break
122
+ case 'pending_opener':
123
+ this.handlers.onPendingOpener?.(p.entry)
124
+ break
120
125
  case 'typing':
121
126
  this.handlers.onTyping?.(!!p.active)
122
127
  break
package/src/types.ts CHANGED
@@ -84,6 +84,7 @@ export interface Issue {
84
84
  export interface PcClientMetadata {
85
85
  work_mode: 'hub' | 'pc' | 'dual'
86
86
  aimux_id: string
87
+ initial_aimux_id?: string
87
88
  local_path?: string
88
89
  is_tui: boolean
89
90
  add_remote_aimux_mcp?: boolean
@@ -137,6 +138,8 @@ export interface SessionRuntimeStatus {
137
138
  agent_backend?: string
138
139
  real_time_info?: string
139
140
  model_available?: boolean
141
+ aimux_id?: string | null
142
+ initial_aimux_id?: string | null
140
143
  }
141
144
 
142
145
  // ── Preferences lookups ──────────────────────────────────────────────────────
@@ -203,6 +206,16 @@ export interface HistoryGroup {
203
206
  entry_count: number
204
207
  }
205
208
 
209
+ /**
210
+ * 挂起中的开轮卡 (pending_round_openers): 忙时提交、尚未出队的用户指令.
211
+ * /groups 端点返回的 `pending` 数组与 SSE `pending_opener` 事件的 `entry` 字段同形.
212
+ */
213
+ export interface HistoryPendingOpener {
214
+ id: string
215
+ opener_ts: string | null
216
+ user_summary: string
217
+ }
218
+
206
219
  // ── SSE envelope events (GET /api/sessions/:id/events) ───────────────────────
207
220
  // Each SSE frame's data is a JSON object with an `event` discriminator.
208
221
  export type SseEvent =