@miphamai/cli 0.85.5 → 0.85.7

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.
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import { getMetrics } from '../core/metrics'
3
+ import { settingsPathFor } from '../config/loader'
3
4
  import { resolveTelemetry, getOrCreateInstallId, type TelemetryConsent } from './consent'
4
5
  import { enqueueSync } from './queue'
5
6
  import { buildSessionEvent } from './payload'
@@ -50,6 +51,12 @@ export function initTelemetry(cwd: string = process.cwd()): TelemetryConsent {
50
51
 
51
52
  state = { consent, installId, startedAt: Date.now(), flushed: false }
52
53
 
54
+ // A project that declared a telemetry key we do not honour is told so, here
55
+ // rather than in the TUI path — this is the one place consent is resolved, so
56
+ // it is also the one place that knows something was withheld, and it covers
57
+ // remote-attach and non-interactive runs the TUI setup never reaches.
58
+ announceIgnoredProjectKeys(consent, cwd)
59
+
53
60
  // Crash capture is installed unconditionally, even when telemetry is off:
54
61
  // it is what keeps a crash from becoming a silent hang. When telemetry is
55
62
  // off the record is simply never uploaded.
@@ -71,6 +78,26 @@ export function initTelemetry(cwd: string = process.cwd()): TelemetryConsent {
71
78
  return consent
72
79
  }
73
80
 
81
+ /**
82
+ * Tell the user that a project-level telemetry key was read and not honoured.
83
+ *
84
+ * The same shape as `index.tsx`'s `projectModeSkipped` notice, and for the same
85
+ * reason: "ignored on purpose" and "never read your file" are the same silence
86
+ * from the outside — and this is the user's own line, in a file they can still
87
+ * open. Once per startup, on stderr, so it cannot corrupt stdout rendering.
88
+ *
89
+ * Not a permission prompt: the outcome is already final by the time this runs.
90
+ * It only removes the ambiguity about *why*.
91
+ */
92
+ function announceIgnoredProjectKeys(consent: TelemetryConsent, cwd: string): void {
93
+ if (consent.ignoredProjectKeys.length === 0) return
94
+ const keys = consent.ignoredProjectKeys.map((k) => `telemetry.${k}`).join(', ')
95
+ process.stderr.write(
96
+ `⚠ Mipham Code: ignored ${keys} from ${settingsPathFor('project', cwd)}\n` +
97
+ ` (a repository may turn telemetry off, but must not turn it on or choose where it goes — set that in ${settingsPathFor('user', cwd)})\n`,
98
+ )
99
+ }
100
+
74
101
  /**
75
102
  * Record a slash-command invocation.
76
103
  *
@@ -112,6 +112,78 @@ function getBlockingIds(task: Task): string[] {
112
112
  })
113
113
  }
114
114
 
115
+ /**
116
+ * Render the session's task list: status-grouped, availability-sorted, blockers marked.
117
+ *
118
+ * **One renderer, two readers.** The `list` action answers the model, and the
119
+ * `/tasks` panel answers the human — both show the same session registry, so
120
+ * both go through here. A second renderer for the panel would be the "two
121
+ * paths, one wired" mistake this repo has already paid for once.
122
+ */
123
+ export function formatTaskList(): string {
124
+ const all = getTasks()
125
+ if (all.length === 0) return '(no tasks)'
126
+
127
+ // Sort by availability: in_progress → pending(available) → pending(blocked) → completed
128
+ const sortOrder: Record<string, number> = {
129
+ in_progress: 0,
130
+ pending: 1,
131
+ completed: 2,
132
+ failed: 3,
133
+ }
134
+
135
+ const sorted = [...all].sort((a, b) => {
136
+ const orderA = sortOrder[a.status] ?? 3
137
+ const orderB = sortOrder[b.status] ?? 3
138
+ if (orderA !== orderB) return orderA - orderB
139
+ // Within same status: available before blocked
140
+ if (a.status === 'pending' && b.status === 'pending') {
141
+ const aBlocked = isBlocked(a)
142
+ const bBlocked = isBlocked(b)
143
+ if (aBlocked !== bBlocked) return aBlocked ? 1 : -1
144
+ }
145
+ return 0
146
+ })
147
+
148
+ const lines: string[] = []
149
+ const statusLabels: Record<string, { emoji: string; label: string }> = {
150
+ in_progress: { emoji: '🔄', label: 'In Progress' },
151
+ pending: { emoji: '📋', label: 'Pending' },
152
+ completed: { emoji: '✅', label: 'Completed' },
153
+ failed: { emoji: '❌', label: 'Failed' },
154
+ }
155
+
156
+ let currentStatus = ''
157
+ for (const t of sorted) {
158
+ const statusInfo = statusLabels[t.status]
159
+ if (!statusInfo) continue
160
+
161
+ if (statusInfo.label !== currentStatus) {
162
+ currentStatus = statusInfo.label
163
+ // Count tasks in this status group
164
+ const count = sorted.filter((s) => statusLabels[s.status]?.label === currentStatus).length
165
+ lines.push(`${statusInfo.emoji} ${currentStatus} (${count}):`)
166
+ }
167
+
168
+ const blocked = isBlocked(t)
169
+ const prefix = blocked && t.status === 'pending' ? '🚫 ' : ' '
170
+ const blockingNote = blocked ? ` 🚫 阻塞中 (等待: ${getBlockingIds(t).join(', ')})` : ''
171
+ lines.push(`${prefix}${formatTask(t)}${blockingNote}`)
172
+ }
173
+
174
+ // Summary footer
175
+ const available = all.filter((t) => t.status === 'pending' && !isBlocked(t)).length
176
+ const blockedCount = all.filter((t) => t.status === 'pending' && isBlocked(t)).length
177
+ if (blockedCount > 0) {
178
+ lines.push('')
179
+ lines.push(
180
+ `📊 ${available} available · ${blockedCount} blocked · ${all.filter((t) => t.status === 'completed').length} done`,
181
+ )
182
+ }
183
+
184
+ return lines.join('\n')
185
+ }
186
+
115
187
  export const taskTool: ToolDefinition = {
116
188
  name: 'Task',
117
189
  description:
@@ -205,67 +277,7 @@ export const taskTool: ToolDefinition = {
205
277
 
206
278
  // ── LIST ──
207
279
  if (action === 'list') {
208
- const all = Array.from(tasks.values()).filter((t) => t.status !== 'deleted')
209
- if (all.length === 0) return { success: true, content: '(no tasks)' }
210
-
211
- // Sort by availability: in_progress → pending(available) → pending(blocked) → completed
212
- const sortOrder: Record<string, number> = {
213
- in_progress: 0,
214
- pending: 1,
215
- completed: 2,
216
- failed: 3,
217
- }
218
-
219
- const sorted = [...all].sort((a, b) => {
220
- const orderA = sortOrder[a.status] ?? 3
221
- const orderB = sortOrder[b.status] ?? 3
222
- if (orderA !== orderB) return orderA - orderB
223
- // Within same status: available before blocked
224
- if (a.status === 'pending' && b.status === 'pending') {
225
- const aBlocked = isBlocked(a)
226
- const bBlocked = isBlocked(b)
227
- if (aBlocked !== bBlocked) return aBlocked ? 1 : -1
228
- }
229
- return 0
230
- })
231
-
232
- const lines: string[] = []
233
- const statusLabels: Record<string, { emoji: string; label: string }> = {
234
- in_progress: { emoji: '🔄', label: 'In Progress' },
235
- pending: { emoji: '📋', label: 'Pending' },
236
- completed: { emoji: '✅', label: 'Completed' },
237
- failed: { emoji: '❌', label: 'Failed' },
238
- }
239
-
240
- let currentStatus = ''
241
- for (const t of sorted) {
242
- const statusInfo = statusLabels[t.status]
243
- if (!statusInfo) continue
244
-
245
- if (statusInfo.label !== currentStatus) {
246
- currentStatus = statusInfo.label
247
- // Count tasks in this status group
248
- const count = sorted.filter((s) => statusLabels[s.status]?.label === currentStatus).length
249
- lines.push(`${statusInfo.emoji} ${currentStatus} (${count}):`)
250
- }
251
-
252
- const blocked = isBlocked(t)
253
- const prefix = blocked && t.status === 'pending' ? '🚫 ' : ' '
254
- const blockingNote = blocked ? ` 🚫 阻塞中 (等待: ${getBlockingIds(t).join(', ')})` : ''
255
- lines.push(`${prefix}${formatTask(t)}${blockingNote}`)
256
- }
257
-
258
- // Summary footer
259
- const available = all.filter((t) => t.status === 'pending' && !isBlocked(t)).length
260
- const blockedCount = all.filter((t) => t.status === 'pending' && isBlocked(t)).length
261
- if (blockedCount > 0) {
262
- lines.push('')
263
- lines.push(
264
- `📊 ${available} available · ${blockedCount} blocked · ${all.filter((t) => t.status === 'completed').length} done`,
265
- )
266
- }
267
-
268
- return { success: true, content: lines.join('\n') }
280
+ return { success: true, content: formatTaskList() }
269
281
  }
270
282
 
271
283
  // ── GET ──
@@ -11,7 +11,7 @@ import type { SkillsLoader } from '../skills/loader'
11
11
  import { loadSkillUsage } from '../skills/usage'
12
12
  import type { PluginManager } from '../plugin/plugin-manager'
13
13
  import type { Message } from '../shared/types.js'
14
- import type { UpdateStatus } from '../shared/update'
14
+ import type { InstallState, UpdateStatus } from '../shared/update'
15
15
  import { McpClient } from '../mcp/client'
16
16
  import { unregisterMcpServerTools } from '../mcp/registry'
17
17
  import { buildCapabilityReport } from '../core/capability-inventory'
@@ -110,6 +110,7 @@ import { listActiveAutoloops, formatLoopRows } from '../commands/autoloop-journa
110
110
  import { execSync } from 'node:child_process'
111
111
  import { MIPHAM_DIR, OLLAMA_PRESET_MODELS } from '../shared/constants'
112
112
  import { renameActiveSession } from '../agent/cross-session/discovery'
113
+ import { formatTaskList } from '../tools/exec/task'
113
114
 
114
115
  export interface CommandContext {
115
116
  engine: QueryEngine
@@ -2775,7 +2776,11 @@ const rewindCmd: CommandHandler = (ctx) => {
2775
2776
  count: String(result.messageCount),
2776
2777
  remaining: String(c.getCheckpoints().length),
2777
2778
  }),
2779
+ // `clearMessages` 单用是 `/clear` 的形状(屏幕上**没有**该留的东西);回退不一样 ——
2780
+ // 留到那一点为止的历史仍然算数,只是屏幕上的那份旧了。不带 `forwardedMessages`
2781
+ // 就会把消息列表清成空、再也不回填:模型看得见回退后的历史,用户看得见一片空白。
2778
2782
  clearMessages: true,
2783
+ forwardedMessages: c.getMessages(),
2779
2784
  }
2780
2785
  }
2781
2786
 
@@ -2918,24 +2923,20 @@ const ultracodeCmd: CommandHandler = (ctx, args) => {
2918
2923
 
2919
2924
  const tasksCmd: CommandHandler = (ctx) => {
2920
2925
  const t = resolveT(ctx)
2921
- const c = ctx.engine.getContext()
2922
- const msgs = c.getMessages()
2923
2926
 
2924
- // Scan for task-related tool uses in message history.
2925
- // 任务工具只有一个 `Task`,动作走 action 参数 —— 过滤条件必须按真实工具名匹配,
2926
- // 否则计数恒为 0,「已检测到 N 次任务操作」这条分支永远不可达。
2927
- const toolUses = msgs.flatMap((m) => {
2928
- if (Array.isArray(m.content)) {
2929
- return m.content.filter((b) => b.type === 'tool_use' && b.name === 'Task')
2930
- }
2931
- return []
2932
- })
2927
+ // 读**登记表**,不数历史。此前这个面板数的是历史里 `Task` 工具调用块的个数,
2928
+ // 于是压缩掉历史之后(块没了、任务还在)它会说「尚未跟踪任何任务」,而没有任务
2929
+ // 却有历史时又会说「检测到 N 次任务操作」。面板自称 "Background tasks"、标题写
2930
+ // 「后台任务」、空态写「尚未跟踪任何任务」—— 全是登记表词汇,而登记表就在
2931
+ // 一步之外(`getTasks()`,`GoalProgress` 早就接了)。
2932
+ const taskList = formatTaskList()
2933
+ const hasTasks = taskList !== '(no tasks)'
2933
2934
 
2934
2935
  return {
2935
2936
  content: stripIndent`
2936
2937
  ${t('commands.task_list.title')}
2937
2938
 
2938
- ${toolUses.length > 0 ? t('commands.task_list.detected', { count: String(toolUses.length) }) : t('commands.task_list.no_tasks')}
2939
+ ${hasTasks ? taskList : t('commands.task_list.no_tasks')}
2939
2940
 
2940
2941
  ${t('commands.task_list.reference')}
2941
2942
  Task(action: "create") — create a new task
@@ -4001,6 +4002,14 @@ const telemetryCmd: CommandHandler = async (ctx, args) => {
4001
4002
  `| Source | \`${consent.endpointSource}\` |`,
4002
4003
  `| Install id | ${settings.installId ?? '_(not yet generated)_'} |`,
4003
4004
  `| Prompted | ${settings.promptedAt ?? '_(never)_'} |`,
4005
+ // Only when there is something to say. An always-present "Ignored: _(none)_"
4006
+ // row would be noise in the common case, and this table is read when
4007
+ // something already looks wrong.
4008
+ ...(consent.ignoredProjectKeys.length > 0
4009
+ ? [
4010
+ `| Ignored from project | ${consent.ignoredProjectKeys.map((k) => `\`telemetry.${k}\``).join(', ')} — a repository may veto, not grant |`,
4011
+ ]
4012
+ : []),
4004
4013
  '',
4005
4014
  'Full data dictionary: `docs/telemetry.md`',
4006
4015
  ]
@@ -4438,6 +4447,21 @@ const memoryCmd: CommandHandler = async (ctx, args) => {
4438
4447
  // Upgrade
4439
4448
  // ═══════════════════════════════════════════════════════════════
4440
4449
 
4450
+ /**
4451
+ * 失败后「我手里还有没有 CLI」这句话的四种说法。
4452
+ *
4453
+ * `Record<InstallState, …>` 而非内联三元:漏一个状态**编译期就红**。写成
4454
+ * `result.rolledBack ? A : B` 时漏掉的那一格会静默落进 else,而 else 说的正是**最重的
4455
+ * 那句**(「原安装未能恢复」)—— 拿「最坏情况」当兜底,就等于把每条没想清楚的路径都
4456
+ * 报成灾难。
4457
+ */
4458
+ const UPGRADE_FAILURE_NOTE: Record<InstallState, string> = {
4459
+ untouched: 'commands.upgrade.install_untouched',
4460
+ restored: 'commands.upgrade.rolled_back',
4461
+ broken: 'commands.upgrade.no_rollback',
4462
+ unknown: 'commands.upgrade.install_unknown',
4463
+ }
4464
+
4441
4465
  const upgradeCmd: CommandHandler = async (ctx) => {
4442
4466
  const t = resolveT(ctx)
4443
4467
  const { checkForUpdates, backupConfig, performUpdate, restoreConfig, getConfigPath } =
@@ -4500,10 +4524,9 @@ const upgradeCmd: CommandHandler = async (ctx) => {
4500
4524
  lines.push(t('commands.upgrade.old_version_warning'))
4501
4525
  } else {
4502
4526
  lines.push('')
4503
- // 失败信息必须回答两件事:为什么,以及**我手里还有没有 CLI**。
4504
- const rollbackNote = t(
4505
- result.rolledBack ? 'commands.upgrade.rolled_back' : 'commands.upgrade.no_rollback',
4506
- )
4527
+ // 失败信息必须回答两件事:为什么,以及**我手里还有没有 CLI**。四态各有各的说法 ——
4528
+ // 尤其「旧树没被碰过」与「旧树没能恢复」是两件不同的事,合并成一句后者必定说谎。
4529
+ const rollbackNote = t(UPGRADE_FAILURE_NOTE[result.installState])
4507
4530
  lines.push(
4508
4531
  t('commands.upgrade.update_failed', {
4509
4532
  reason: result.reason ?? '',
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react'
1
+ import React, { useEffect, useState } from 'react'
2
2
  import { Box, Text } from 'ink'
3
3
  import { basename } from 'node:path'
4
4
  import { findGraftStats, type GraftStats } from '../shared/graft-stats'
@@ -13,17 +13,46 @@ function freshnessSegment(stats: GraftStats): { label: string; color: string } {
13
13
  return { label: '✓ synced', color: 'blue' }
14
14
  }
15
15
 
16
+ /**
17
+ * 只有**真变了**才换 state —— 否则每秒一次 setState 会把整棵树重渲染一遍。
18
+ * 两侧都出自 `findGraftStats` 的同一个 return 字面量,键序恒等,故字符串比较是稳的;
19
+ * 用整体比较而不是逐字段比,是为了将来给 `GraftStats` 加字段时**不必回来补这一行**。
20
+ */
21
+ function sameStats(a: GraftStats | null, b: GraftStats | null): boolean {
22
+ if (a === b) return true
23
+ if (!a || !b) return false
24
+ return JSON.stringify(a) === JSON.stringify(b)
25
+ }
26
+
27
+ /** 重读间隔。与 `goal-progress` / `workflow-progress` 同法(那两处也是 1s 一拍)。 */
28
+ const STATS_REFRESH_MS = 1000
29
+
16
30
  /**
17
31
  * Bottom-of-screen graft status line — mirrors graft's own "◤ graft · …" bar:
18
32
  * ◤ graft · N nodes / E edges · ✓ synced · ~T tok saved
19
33
  * ▸ ctx X% · last: file
20
- * Reads `graft/.cache/stats.json` (walking up to the repo root) once at mount;
21
- * the session's tok-saved total is read live each render (the app re-renders on
22
- * the agent tick). Renders nothing only when there's no graft graph AND no known
23
- * ctx% — the ctx% line shows even when graft isn't built in this directory.
34
+ *
35
+ * `graft/.cache/stats.json`(向上走到仓库根)是**别人在后台写的**:graft 自己重建索引时
36
+ * 先落 `syncing: true`,建完再落回来。挂载时读一次就永远说那一拍的话 ⇒ 启动正好撞上索引
37
+ * 重建的话,页脚会一直挂着 `syncing…`,而磁盘上早已 `"syncing": false` ——「有人的读数与
38
+ * 没人读的那个对象不一致」。故这里**按拍重读**。
39
+ *
40
+ * 别把它降成「每次渲染读一遍」:那要靠别的组件把这一棵带着重渲染(敲键盘 / agent tick)
41
+ * 才生效,而索引建完的那一刻**恰恰可能一个键都没有** —— 页脚会一直撒谎到用户下次敲键。
42
+ *
43
+ * 其余同前:tok-saved 每次渲染实时读;只在有 graft 图或有已知 ctx% 时渲染 —— ctx% 行
44
+ * 在本目录没建过 graft 时也照常显示。
24
45
  */
25
46
  export function GraftStatusLine({ cwd, ctxPct }: { cwd: string; ctxPct?: number }) {
26
- const [stats] = useState(() => findGraftStats(cwd))
47
+ const [stats, setStats] = useState(() => findGraftStats(cwd))
48
+
49
+ useEffect(() => {
50
+ const timer = setInterval(() => {
51
+ const next = findGraftStats(cwd)
52
+ setStats((prev) => (sameStats(prev, next) ? prev : next))
53
+ }, STATS_REFRESH_MS)
54
+ return () => clearInterval(timer)
55
+ }, [cwd])
27
56
  const showCtx = typeof ctxPct === 'number'
28
57
  if (!stats && !showCtx) return null
29
58
  const fresh = stats ? freshnessSegment(stats) : null
package/src/ui/input.tsx CHANGED
@@ -324,7 +324,12 @@ export function InputBar({
324
324
  const suggestionReqIdRef = useRef(0)
325
325
 
326
326
  // 统一清理:清 suggestion + 使在途/待发请求失效 + 取消防抖定时器。
327
- // Escape / handleSubmit / 翻历史三处共用,避免各自手写漏掉某一环(rule of three)。
327
+ // Escape / handleSubmit / 翻历史 / **Tab 接受** 四处共用,避免各自手写漏掉某一环。
328
+ // Tab 那一处原先只手写 `setSuggestion(null)`(ROADMAP D8 缺口③):今天**不可达**
329
+ // ——有建议显示就说明上次请求已结束、定时器早烧掉了——但「接受后继续续写」一加上,
330
+ // 那个漏掉的环立刻变成洞。走同一入口,洞就不存在了。
331
+ // (`onChange` 里另有一处内联的同样三步,**不共用**:那里要的是把 reqId 推成**新**的,
332
+ // 与这里的「作废」是两件事。)
328
333
  const clearSuggestion = () => {
329
334
  setSuggestion(null)
330
335
  suggestionReqIdRef.current++
@@ -411,7 +416,7 @@ export function InputBar({
411
416
  const next = valueRef.current + suggestion
412
417
  setValue(next)
413
418
  valueRef.current = next
414
- setSuggestion(null)
419
+ clearSuggestion()
415
420
  return
416
421
  }
417
422
  // Ctrl+P → toggle model picker
package/src/ui/picker.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useCallback } from 'react'
1
+ import React, { useCallback } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
3
  import { useI18n } from '../i18n-context'
4
4
  import { useKeyState } from './use-key-state'
@@ -34,7 +34,11 @@ export function ModelPicker({
34
34
  const providers = config.providers.filter((p) => p.status !== 'upcoming')
35
35
 
36
36
  // State
37
- const [activePanel, setActivePanel] = useState<Panel>('provider')
37
+ // 面板选择同样走 `useKeyState` —— 它与两个光标是**同一次按键里一起判读**的。
38
+ // 反例是「Tab ↓ Enter」一次刷进来:Ink 把 Tab 之后那段也拆成两拍,于是 ↓ 与
39
+ // Enter 都还在按**切换之前**那一拍算 —— ↓ 去动 provider 光标,Enter 走
40
+ // `goToProvider`(provider 面板上那条路)⇒ 一次都没选中。
41
+ const activePanel = useKeyState<Panel>('provider')
38
42
  // 光标走 `useKeyState`:一组按键可能在同一拍里到达(↓ 之后紧跟 Enter),判据必须
39
43
  // 读得到本次按键刚写下的那个索引,而不是上一张闭包里的。
40
44
  const providerIdx = useKeyState(() => {
@@ -51,7 +55,7 @@ export function ModelPicker({
51
55
  (idx: number) => {
52
56
  providerIdx.set(wrap(idx, providers.length))
53
57
  modelIdx.set(0)
54
- setActivePanel('model') // auto-switch to model panel
58
+ activePanel.set('model') // auto-switch to model panel
55
59
  },
56
60
  [providers.length, providerIdx, modelIdx],
57
61
  )
@@ -93,7 +97,7 @@ export function ModelPicker({
93
97
  }
94
98
 
95
99
  if (key.return) {
96
- if (activePanel === 'provider') {
100
+ if (activePanel.read() === 'provider') {
97
101
  goToProvider(providerIdx.read()) // switches to model panel
98
102
  } else {
99
103
  confirmSelection()
@@ -102,20 +106,20 @@ export function ModelPicker({
102
106
  }
103
107
 
104
108
  // Tab or right arrow → switch to model panel
105
- if (key.tab || (activePanel === 'provider' && input === 'l')) {
106
- setActivePanel('model')
109
+ if (key.tab || (activePanel.read() === 'provider' && input === 'l')) {
110
+ activePanel.set('model')
107
111
  return
108
112
  }
109
113
 
110
114
  // Left arrow → switch to provider panel
111
- if (key.leftArrow || (activePanel === 'model' && input === 'h')) {
112
- setActivePanel('provider')
115
+ if (key.leftArrow || (activePanel.read() === 'model' && input === 'h')) {
116
+ activePanel.set('provider')
113
117
  return
114
118
  }
115
119
 
116
120
  // Up/Down navigation
117
121
  if (key.upArrow) {
118
- if (activePanel === 'provider') {
122
+ if (activePanel.read() === 'provider') {
119
123
  providerIdx.set((prev) => wrap(prev - 1, providers.length))
120
124
  } else {
121
125
  stepModel(-1)
@@ -124,7 +128,7 @@ export function ModelPicker({
124
128
  }
125
129
 
126
130
  if (key.downArrow) {
127
- if (activePanel === 'provider') {
131
+ if (activePanel.read() === 'provider') {
128
132
  providerIdx.set((prev) => wrap(prev + 1, providers.length))
129
133
  } else {
130
134
  stepModel(1)
@@ -153,11 +157,11 @@ export function ModelPicker({
153
157
  flexDirection="column"
154
158
  width={28}
155
159
  borderStyle="single"
156
- borderColor={activePanel === 'provider' ? 'cyan' : 'gray'}
160
+ borderColor={activePanel.value === 'provider' ? 'cyan' : 'gray'}
157
161
  padding={1}
158
162
  >
159
163
  <Text bold underline dimColor>
160
- {t('ui.picker.provider_label')} {activePanel === 'provider' ? '◀' : ''}
164
+ {t('ui.picker.provider_label')} {activePanel.value === 'provider' ? '◀' : ''}
161
165
  </Text>
162
166
  {providers.map((p, i) => {
163
167
  const isCurrent = p.id === currentProvider
@@ -186,11 +190,11 @@ export function ModelPicker({
186
190
  flexDirection="column"
187
191
  width={42}
188
192
  borderStyle="single"
189
- borderColor={activePanel === 'model' ? 'cyan' : 'gray'}
193
+ borderColor={activePanel.value === 'model' ? 'cyan' : 'gray'}
190
194
  padding={1}
191
195
  >
192
196
  <Text bold underline dimColor>
193
- {t('ui.picker.models_label')} {activePanel === 'model' ? '◀' : ''}
197
+ {t('ui.picker.models_label')} {activePanel.value === 'model' ? '◀' : ''}
194
198
  {selectedProvider ? ` — ${selectedProvider.name}` : ''}
195
199
  </Text>
196
200
  {models.length === 0 && <Text dimColor> {t('ui.picker.no_active_models')}</Text>}
@@ -221,7 +225,7 @@ export function ModelPicker({
221
225
  {/* Footer hint */}
222
226
  <Box marginTop={1}>
223
227
  <Text dimColor>
224
- {activePanel === 'provider'
228
+ {activePanel.value === 'provider'
225
229
  ? t('ui.picker.select_provider_hint')
226
230
  : t('ui.picker.select_model_hint')}
227
231
  </Text>
@@ -46,13 +46,6 @@ export async function workflowAgent(
46
46
  opts: WorkflowAgentOpts = {},
47
47
  llm?: Llm,
48
48
  ): Promise<unknown> {
49
- // If provider override, switch temporarily
50
- if (opts.provider) {
51
- registry.switchProvider(opts.provider, opts.model)
52
- } else if (opts.model) {
53
- registry.switchProvider(registry.getActive().config.id, opts.model)
54
- }
55
-
56
49
  const maxRetries = opts.maxRetries ?? 2
57
50
 
58
51
  // ── Worktree isolation setup ──
@@ -93,7 +86,26 @@ export async function workflowAgent(
93
86
  let lastResult = ''
94
87
  let lastErrors: string[] = []
95
88
 
89
+ /** Set only when we actually switched — and then it is the way back. */
90
+ let restore: (() => void) | undefined
91
+
96
92
  try {
93
+ // ── Provider override, scoped to this call ──
94
+ // 「临时」必须是真的。registry 的 active 是**会话级**的:`SubAgent` 在
95
+ // `runExecution` 里读 `registry.getActiveModel()`、`registry.chat` 按 active 路由,
96
+ // 引擎的页脚与 `/model` 面板读的也是它 —— 覆盖不还原,等于一个 workflow 里的一次
97
+ // `agent(…, { provider: 'x' })` 把整台机器的会话换到 x 上。
98
+ //
99
+ // 切在这里而不是函数开头:上面那段建 worktree 会抛(`git worktree add` 非 0 即
100
+ // throw),抛在切走之前就不该已经切走。
101
+ if (opts.provider || opts.model) {
102
+ const prevProviderId = registry.getActive().config.id
103
+ const prevModelId = registry.getActiveModel()
104
+ // `opts.provider` 缺席时只换模型 —— provider 保持这一次调用开始时的那一个。
105
+ registry.switchProvider(opts.provider ?? prevProviderId, opts.model)
106
+ restore = () => registry.switchProvider(prevProviderId, prevModelId)
107
+ }
108
+
97
109
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
98
110
  const retryPrompt =
99
111
  attempt === 0
@@ -158,6 +170,10 @@ export async function workflowAgent(
158
170
 
159
171
  return result
160
172
  } finally {
173
+ // 还原排在 finally 的**第一件**:下面的 worktree 清理要 spawn git、还可能抛,
174
+ // 排在它之后就等于「清理顺利时才还原」—— 而那正是最不需要还原的场合。
175
+ restore?.()
176
+
161
177
  // ── Cleanup worktree ──
162
178
  if (worktreePath) {
163
179
  // Best-effort: auto-commit any changes