@miphamai/cli 0.85.6 → 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.
- package/package.json +1 -1
- package/src/agent-view/dashboard.tsx +7 -0
- package/src/core/context.ts +75 -39
- package/src/core/session-log.ts +9 -0
- package/src/i18n-core/locales/en-US.json +0 -1
- package/src/i18n-core/locales/zh-CN.json +0 -1
- package/src/shared/package-info.ts +2 -2
- package/src/telemetry/consent.ts +50 -4
- package/src/telemetry/index.ts +27 -0
- package/src/tools/exec/task.ts +73 -61
- package/src/ui/commands.ts +21 -12
- package/src/ui/picker.tsx +19 -15
- package/src/workflow/primitives/agent.ts +23 -7
package/package.json
CHANGED
|
@@ -31,6 +31,13 @@ const STATUS_HEADERS: Record<string, { label: string; color: string }> = {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps) {
|
|
34
|
+
// 这里**刻意**不迁 `useKeyState`(picker / config-wizard / command-picker 都迁了)。
|
|
35
|
+
// 那些地方的病是「一次刷进来的按键作用在上一拍的行上」,而 Ink 只在 chunk **以转义
|
|
36
|
+
// 序列打头**时才把它拆成多个事件 —— 所以能撞上的是方向键(`\x1b[B` + `\r` 拆成两拍)。
|
|
37
|
+
// 本面板的键全是普通字符(j / k / space / Enter / Ctrl+X / Ctrl+R / Ctrl+T),一起到达
|
|
38
|
+
// 时会**合并成一个**事件(实测 `'j\r'` → 单个 `input="j\r"`、`key.return` 为 false),
|
|
39
|
+
// 两个分支都不匹配 ⇒ 症状是「这一拍什么也没发生」,不是「作用在上一行」。
|
|
40
|
+
// 换句话说这一格没有可复现的故障,迁过去只会让测试**改前改后都绿**。
|
|
34
41
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
35
42
|
const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
|
|
36
43
|
const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
|
package/src/core/context.ts
CHANGED
|
@@ -30,7 +30,6 @@ export interface CompactionStats {
|
|
|
30
30
|
interface Checkpoint {
|
|
31
31
|
id: number
|
|
32
32
|
messages: Message[]
|
|
33
|
-
estimatedTokens: number
|
|
34
33
|
timestamp: Date
|
|
35
34
|
label: string
|
|
36
35
|
}
|
|
@@ -52,7 +51,22 @@ export class ContextManager {
|
|
|
52
51
|
* 组装时烘进去的话,本次会话里后连上的 server 永远进不了提示 —— 用户只能重启。
|
|
53
52
|
*/
|
|
54
53
|
private mcpInstructionsSource: (() => string) | null = null
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* **消息那部分**的估值,增量累加(提示那部分见 `promptTokens()`,读时派生)。
|
|
56
|
+
*
|
|
57
|
+
* 两份分开是因为它们的变化条件不同:消息只在本类里变(每次 push 顺手加一笔就够了),
|
|
58
|
+
* 而提示里的两段是**读时闭包**、变点不在这条类里(见上面两个 source 的注释)。
|
|
59
|
+
* 把两者混在一个累加器里,闭包那半就必然滞后 —— 这正是本类此前的缺陷。
|
|
60
|
+
*/
|
|
61
|
+
private messageTokens = 0
|
|
62
|
+
/**
|
|
63
|
+
* `promptTokens()` 的记忆化。键**就是**那份拼好的提示本身,所以不存在失效问题:
|
|
64
|
+
* 「键没变而值该变」需要的恰恰是「输入没变而输出该变」,不可能发生。
|
|
65
|
+
*
|
|
66
|
+
* 记忆化只是别在每次 `addMessage` 里把同一份四万字符的系统提示重扫一遍
|
|
67
|
+
* (`checkCompression()` 会读估值,它在每条消息上都被调一次)。
|
|
68
|
+
*/
|
|
69
|
+
private promptTokensCache: { text: string; tokens: number } | null = null
|
|
56
70
|
private checkpoints: Checkpoint[] = []
|
|
57
71
|
private checkpointCounter = 0
|
|
58
72
|
private summarizer?: Summarizer
|
|
@@ -93,7 +107,7 @@ export class ContextManager {
|
|
|
93
107
|
// 否则「模型看得见的必须已记录」这条不变量当场破),见 `closeInterruptedToolCalls`。
|
|
94
108
|
closeInterruptedToolCalls(log)
|
|
95
109
|
this.messages = deriveMessages(log.events())
|
|
96
|
-
this.
|
|
110
|
+
this.recountMessageTokens()
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
/**
|
|
@@ -137,11 +151,13 @@ export class ContextManager {
|
|
|
137
151
|
|
|
138
152
|
setSystemPrompt(prompt: string): void {
|
|
139
153
|
this.systemPrompt = prompt
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
154
|
+
// 这里**故意什么都不算**。提示那部分由 `promptTokens()` 读时派生,消息那部分由
|
|
155
|
+
// `messageTokens` 自己带着 —— 所以「设提示」这件事对估值**没有可出错的空间**。
|
|
156
|
+
//
|
|
157
|
+
// 从前这里要重算一次,且必须记得「重算要含消息」:`--resume` 路径
|
|
158
|
+
// (`index.tsx:584-585`)先 `restoreLog()` 得出含消息的估值、紧接着设提示,只按
|
|
159
|
+
// 提示重算就会把它覆盖成偏低值。那是个**要靠注释守住的契约**;现在它不可能被违反
|
|
160
|
+
// —— 没有任何一条路径能在这里把消息那半丢掉。
|
|
145
161
|
}
|
|
146
162
|
|
|
147
163
|
/**
|
|
@@ -185,7 +201,7 @@ export class ContextManager {
|
|
|
185
201
|
|
|
186
202
|
addMessage(msg: Message): void {
|
|
187
203
|
this.messages.push(msg)
|
|
188
|
-
this.
|
|
204
|
+
this.messageTokens += this.estimateTokens(
|
|
189
205
|
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
190
206
|
)
|
|
191
207
|
|
|
@@ -212,7 +228,7 @@ export class ContextManager {
|
|
|
212
228
|
*/
|
|
213
229
|
injectContext(source: string, text: string): void {
|
|
214
230
|
this.messages.push({ role: 'user', content: text })
|
|
215
|
-
this.
|
|
231
|
+
this.messageTokens += this.estimateTokens(text)
|
|
216
232
|
|
|
217
233
|
if (this.log) {
|
|
218
234
|
this.log.append({ type: 'context/inject', at: Date.now(), source, text })
|
|
@@ -241,7 +257,7 @@ export class ContextManager {
|
|
|
241
257
|
],
|
|
242
258
|
}
|
|
243
259
|
this.messages.push(msg)
|
|
244
|
-
this.
|
|
260
|
+
this.messageTokens += this.estimateTokens(JSON.stringify(msg.content))
|
|
245
261
|
if (this.log) this.log.append({ type: 'tool/result', at: Date.now(), id: toolUseId, result })
|
|
246
262
|
this.checkCompression()
|
|
247
263
|
}
|
|
@@ -262,7 +278,7 @@ export class ContextManager {
|
|
|
262
278
|
if (this.log) {
|
|
263
279
|
for (const m of messages) for (const e of messageToEvents(m, Date.now())) this.log.append(e)
|
|
264
280
|
}
|
|
265
|
-
this.
|
|
281
|
+
this.recountMessageTokens()
|
|
266
282
|
|
|
267
283
|
if (this.log && isAssertModelVisibleDebug()) {
|
|
268
284
|
assertModelVisible(this.log.events(), this.messages)
|
|
@@ -274,11 +290,11 @@ export class ContextManager {
|
|
|
274
290
|
}
|
|
275
291
|
|
|
276
292
|
needsCompaction(): boolean {
|
|
277
|
-
return this.
|
|
293
|
+
return this.getEstimatedTokens() > this.config.maxTokens * this.config.compactionThreshold
|
|
278
294
|
}
|
|
279
295
|
|
|
280
296
|
async compact(heading: string): Promise<{ before: number; after: number }> {
|
|
281
|
-
const beforeTokens = this.
|
|
297
|
+
const beforeTokens = this.getEstimatedTokens()
|
|
282
298
|
|
|
283
299
|
if (this.messages.length <= 30) {
|
|
284
300
|
return { before: beforeTokens, after: beforeTokens }
|
|
@@ -322,26 +338,27 @@ export class ContextManager {
|
|
|
322
338
|
}
|
|
323
339
|
}
|
|
324
340
|
|
|
325
|
-
// Re-estimate
|
|
326
|
-
this.
|
|
327
|
-
for (const msg of this.messages) {
|
|
328
|
-
this.estimatedTokens += this.estimateTokens(
|
|
329
|
-
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
330
|
-
)
|
|
331
|
-
}
|
|
341
|
+
// Re-estimate the message half (the prompt half is derived on read).
|
|
342
|
+
this.recountMessageTokens()
|
|
332
343
|
|
|
333
|
-
return { before: beforeTokens, after: this.
|
|
344
|
+
return { before: beforeTokens, after: this.getEstimatedTokens() }
|
|
334
345
|
}
|
|
335
346
|
|
|
347
|
+
/**
|
|
348
|
+
* 当前会话的估算 token 数 = **消息那半(累加)+ 提示那半(读时派生)**。
|
|
349
|
+
*
|
|
350
|
+
* 提示那半必须在读数这一刻才拼:`systemPrompt`、权限段、MCP instructions 段三者任一
|
|
351
|
+
* 变了都该反映出来,而其中两段的变点在调用方(见字段注释)—— 派生就没有变点要枚举。
|
|
352
|
+
*/
|
|
336
353
|
getEstimatedTokens(): number {
|
|
337
|
-
return this.
|
|
354
|
+
return this.messageTokens + this.promptTokens()
|
|
338
355
|
}
|
|
339
356
|
|
|
340
357
|
clear(): void {
|
|
341
358
|
this.messages = []
|
|
342
359
|
this.checkpoints = []
|
|
343
360
|
this.checkpointCounter = 0
|
|
344
|
-
this.
|
|
361
|
+
this.messageTokens = 0
|
|
345
362
|
}
|
|
346
363
|
|
|
347
364
|
getMessageCount(): number {
|
|
@@ -356,13 +373,8 @@ export class ContextManager {
|
|
|
356
373
|
*/
|
|
357
374
|
replaceMessages(messages: Message[]): void {
|
|
358
375
|
this.messages = messages
|
|
359
|
-
// Re-estimate
|
|
360
|
-
this.
|
|
361
|
-
for (const msg of messages) {
|
|
362
|
-
this.estimatedTokens += this.estimateTokens(
|
|
363
|
-
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
364
|
-
)
|
|
365
|
-
}
|
|
376
|
+
// Re-estimate the message half (the prompt half is derived on read).
|
|
377
|
+
this.recountMessageTokens()
|
|
366
378
|
}
|
|
367
379
|
|
|
368
380
|
// ── Checkpoint / Rewind ──
|
|
@@ -372,7 +384,6 @@ export class ContextManager {
|
|
|
372
384
|
const checkpoint: Checkpoint = {
|
|
373
385
|
id: this.checkpointCounter,
|
|
374
386
|
messages: structuredClone(this.messages),
|
|
375
|
-
estimatedTokens: this.estimatedTokens,
|
|
376
387
|
timestamp: new Date(),
|
|
377
388
|
label,
|
|
378
389
|
}
|
|
@@ -399,7 +410,21 @@ export class ContextManager {
|
|
|
399
410
|
}
|
|
400
411
|
|
|
401
412
|
this.messages = structuredClone(target.messages)
|
|
402
|
-
|
|
413
|
+
// 估值从**刚恢复出来的这份消息**重算,而不是从快照里存的一个数还原:存下来的数
|
|
414
|
+
// 是「同一件事的第二份拷贝」,它会与消息各自漂移,而消息本身就是唯一真源。
|
|
415
|
+
this.recountMessageTokens()
|
|
416
|
+
// 回退改写的是**投影的整份内容**,所以它必须落成事件:日志是 `--resume` / `/resume`
|
|
417
|
+
// 重建历史的唯一来源,不记这一次改写,被回退掉的那一轮会在下次恢复时原样回来。
|
|
418
|
+
// 走与 `addMessage` 同一条写通路径(先入日志、再断言)—— 断言因此也从「前缀匹配」
|
|
419
|
+
// 变成「逐条相等」,回退不再是断言的一个盲区。
|
|
420
|
+
// 传快照副本:`append` 只按引用入 buf,序列化推迟到 `save()`,共用同一个数组会让
|
|
421
|
+
// 之后对 `this.messages` 的原地修改回写进已入队的事件里。
|
|
422
|
+
if (this.log) {
|
|
423
|
+
this.log.append({ type: 'rewind', at: Date.now(), messages: structuredClone(this.messages) })
|
|
424
|
+
if (isAssertModelVisibleDebug()) {
|
|
425
|
+
assertModelVisible(this.log.events(), this.messages)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
403
428
|
return { restored: true, messageCount: this.messages.length, label: target.label }
|
|
404
429
|
}
|
|
405
430
|
|
|
@@ -451,7 +476,7 @@ export class ContextManager {
|
|
|
451
476
|
private checkCompression(): void {
|
|
452
477
|
if (this.compressionPending) return
|
|
453
478
|
|
|
454
|
-
const usage = this.
|
|
479
|
+
const usage = this.getEstimatedTokens() / this.config.maxTokens
|
|
455
480
|
|
|
456
481
|
// Adaptive microcompact threshold: 200K→0.70, 500K→0.80, 1M→0.85
|
|
457
482
|
const microThreshold = this.config.contextWindow
|
|
@@ -497,19 +522,30 @@ export class ContextManager {
|
|
|
497
522
|
} else {
|
|
498
523
|
this.messages = compacted
|
|
499
524
|
}
|
|
500
|
-
this.
|
|
525
|
+
this.recountMessageTokens()
|
|
501
526
|
}
|
|
502
527
|
|
|
503
|
-
/**
|
|
504
|
-
private
|
|
505
|
-
this.
|
|
528
|
+
/** 从当前消息**重算消息那半**的估值(提示那半不在这里 —— 它是读时派生的)。 */
|
|
529
|
+
private recountMessageTokens(): void {
|
|
530
|
+
this.messageTokens = 0
|
|
506
531
|
for (const msg of this.messages) {
|
|
507
|
-
this.
|
|
532
|
+
this.messageTokens += this.estimateTokens(
|
|
508
533
|
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
509
534
|
)
|
|
510
535
|
}
|
|
511
536
|
}
|
|
512
537
|
|
|
538
|
+
/** 提示那半的估值 —— 每次读数现拼现算,见 `getEstimatedTokens()`。 */
|
|
539
|
+
private promptTokens(): number {
|
|
540
|
+
const text = this.composedSystemPrompt()
|
|
541
|
+
const cached = this.promptTokensCache
|
|
542
|
+
if (cached && cached.text === text) return cached.tokens
|
|
543
|
+
|
|
544
|
+
const tokens = this.estimateTokens(text)
|
|
545
|
+
this.promptTokensCache = { text, tokens }
|
|
546
|
+
return tokens
|
|
547
|
+
}
|
|
548
|
+
|
|
513
549
|
/**
|
|
514
550
|
* Estimate token count for a text string.
|
|
515
551
|
*
|
package/src/core/session-log.ts
CHANGED
|
@@ -23,6 +23,9 @@ export type SessionEvent =
|
|
|
23
23
|
| { type: 'context/inject'; at: number; source: string; text: string }
|
|
24
24
|
| { type: 'compaction/summary'; at: number; summary: string; replacedCount: number }
|
|
25
25
|
| { type: 'compaction/rewrite'; at: number; messages: Message[] }
|
|
26
|
+
// 回退(`/rewind`)。投影语义与 `compaction/rewrite` 相同(整份快照替换),但**不是压缩**:
|
|
27
|
+
// 合用一个类型会让「这份日志里哪些轮被摘要过」的答案变成错的。
|
|
28
|
+
| { type: 'rewind'; at: number; messages: Message[] }
|
|
26
29
|
| { type: 'checker/decision'; at: number; toolName: string; decision: CheckerDecision }
|
|
27
30
|
|
|
28
31
|
export function messageToEvents(msg: Message, at = 0): SessionEvent[] {
|
|
@@ -110,6 +113,9 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
|
|
|
110
113
|
} else if (e.type === 'compaction/rewrite') {
|
|
111
114
|
// 快照替换:整个投影重建(微压缩/截断等结构性编辑的字节级复现)
|
|
112
115
|
out = structuredClone(e.messages)
|
|
116
|
+
} else if (e.type === 'rewind') {
|
|
117
|
+
// 回退同样是快照替换:整份投影重建为回退到的那一点
|
|
118
|
+
out = structuredClone(e.messages)
|
|
113
119
|
}
|
|
114
120
|
// 'session/start' / 'checker/decision' → 无消息(决策仅记录证据,不进投影,保字节级可逆)
|
|
115
121
|
}
|
|
@@ -311,6 +317,9 @@ function isValidEvent(e: unknown): e is SessionEvent {
|
|
|
311
317
|
case 'compaction/rewrite':
|
|
312
318
|
// 快照替换:整份投影由它重建 ⇒ 元素形状与 message 事件同罪
|
|
313
319
|
return Array.isArray(ev.messages) && ev.messages.every(isValidMessage)
|
|
320
|
+
case 'rewind':
|
|
321
|
+
// 同上:回退也是整份投影替换,坏元素同样会让投影崩
|
|
322
|
+
return Array.isArray(ev.messages) && ev.messages.every(isValidMessage)
|
|
314
323
|
default:
|
|
315
324
|
return true
|
|
316
325
|
}
|
|
@@ -186,7 +186,6 @@
|
|
|
186
186
|
},
|
|
187
187
|
"task_list": {
|
|
188
188
|
"title": "── Background Tasks ──",
|
|
189
|
-
"detected": "{count} task operations detected in this session.\n\nUse the Task tool (action \"create\" / \"update\" / \"list\") to manage structured task tracking.",
|
|
190
189
|
"no_tasks": "No tasks tracked yet. Use the Task tool with action \"create\", \"update\", or \"list\" to manage structured tasks.",
|
|
191
190
|
"reference": "Quick reference:",
|
|
192
191
|
"legacy_hint": "Type /todos for the legacy task interface."
|
|
@@ -186,7 +186,6 @@
|
|
|
186
186
|
},
|
|
187
187
|
"task_list": {
|
|
188
188
|
"title": "── 后台任务 ──",
|
|
189
|
-
"detected": "在此会话中检测到 {count} 次任务操作。\n\n使用 Task 工具(action \"create\" / \"update\" / \"list\")管理结构化任务跟踪。",
|
|
190
189
|
"no_tasks": "尚未跟踪任何任务。使用 Task 工具的 action \"create\"、\"update\" 或 \"list\" 管理结构化任务。",
|
|
191
190
|
"reference": "快速参考:",
|
|
192
191
|
"legacy_hint": "输入 /todos 使用旧版任务界面。"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.85.
|
|
12
|
+
export const PACKAGE_VERSION = '0.85.7' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
|
@@ -90,4 +90,4 @@ export const TOOL_COUNT = 31 as const
|
|
|
90
90
|
* —— `test/e2e/full-pipeline.test.ts` 在 Linux 上整文件 skip、在 macOS 上跑 ——
|
|
91
91
|
* 但**总数相同**(两边都把被 skip 的算进去)。
|
|
92
92
|
*/
|
|
93
|
-
export const TEST_COUNT =
|
|
93
|
+
export const TEST_COUNT = 3535 as const
|
package/src/telemetry/consent.ts
CHANGED
|
@@ -31,6 +31,15 @@ export interface TelemetrySettings {
|
|
|
31
31
|
/** Why telemetry ended up on or off — surfaced by `/telemetry status`. */
|
|
32
32
|
export type ConsentSource = 'env-off' | 'project-veto' | 'user-optin' | 'default-off'
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* 项目级文件里**写了、但按设计不采纳**的键。
|
|
36
|
+
*
|
|
37
|
+
* 只有这两档词汇:`enabled: true`(项目不能授予同意)与非空 `endpoint`(项目不能选
|
|
38
|
+
* 目的地)。`installId` / `promptedAt` 不在内 —— 那两键由本工具写进**用户**文件,
|
|
39
|
+
* 出现在项目文件里是「键写错了」,报成「一档被拒绝」只会把人引偏。
|
|
40
|
+
*/
|
|
41
|
+
export type ProjectIgnoredKey = 'enabled' | 'endpoint'
|
|
42
|
+
|
|
34
43
|
export interface TelemetryConsent {
|
|
35
44
|
enabled: boolean
|
|
36
45
|
endpoint: string
|
|
@@ -43,6 +52,28 @@ export interface TelemetryConsent {
|
|
|
43
52
|
* user debugging an unexpected destination needs the second one.
|
|
44
53
|
*/
|
|
45
54
|
endpointSource: EndpointSource | 'off'
|
|
55
|
+
/**
|
|
56
|
+
* 项目级文件里声明了、而**没有生效**的键(按 `enabled` → `endpoint` 固定序)。
|
|
57
|
+
*
|
|
58
|
+
* 为什么要有这个名单:「按设计不采纳」与「压根没读你的文件」从外面看是**同一片
|
|
59
|
+
* 沉默**,而这是用户自己写下的字,就在他还能看见的那个文件里。兄弟形状见
|
|
60
|
+
* `config/loader.ts` 的 `projectModeSkipped` —— 同一条理由,同一句公告。
|
|
61
|
+
*
|
|
62
|
+
* 为什么不干脆采纳:项目这一档只许**收紧**(`enabled: false` 是否决)。采纳
|
|
63
|
+
* `endpoint` 等于克隆一个仓库就把用户已 opt-in 的遥测改道到仓库指定的收集器,
|
|
64
|
+
* 与「项目不能授予同意」是同一件事的两种写法。
|
|
65
|
+
*
|
|
66
|
+
* 空串不算声明 —— 名单要说的是「你写了 X 而它不生效」,不是「这个键存在过」。
|
|
67
|
+
*/
|
|
68
|
+
ignoredProjectKeys: ProjectIgnoredKey[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 项目文件里声明了这两档词汇的那些键(顺序固定,与文件里的书写顺序无关)。 */
|
|
72
|
+
function declaredProjectKeys(project: TelemetrySettings): ProjectIgnoredKey[] {
|
|
73
|
+
const keys: ProjectIgnoredKey[] = []
|
|
74
|
+
if (project.enabled === true) keys.push('enabled')
|
|
75
|
+
if (typeof project.endpoint === 'string' && project.endpoint !== '') keys.push('endpoint')
|
|
76
|
+
return keys
|
|
46
77
|
}
|
|
47
78
|
|
|
48
79
|
/**
|
|
@@ -93,6 +124,12 @@ export function readTelemetrySettings(
|
|
|
93
124
|
* consenting on the user's behalf. Same shape as the existing
|
|
94
125
|
* `permissionRestrictions` fail-closed downgrade.
|
|
95
126
|
*
|
|
127
|
+
* **The withholding is reported, not silent.** A project that declared
|
|
128
|
+
* `enabled: true` or a destination gets those keys named back to it in
|
|
129
|
+
* `ignoredProjectKeys` — from the outside, "ignored on purpose" and "never read
|
|
130
|
+
* your file" are the same silence, and the user is the one who wrote those
|
|
131
|
+
* lines, in a file they can still see.
|
|
132
|
+
*
|
|
96
133
|
* There is intentionally no env var that grants consent. Consent has to be a
|
|
97
134
|
* persistent, deliberate act (the first-run prompt or `/telemetry on`) — env
|
|
98
135
|
* vars are inherited by child processes and end up in CI logs, so they must not
|
|
@@ -102,21 +139,30 @@ export function resolveTelemetry(
|
|
|
102
139
|
cwd: string = process.cwd(),
|
|
103
140
|
env: NodeJS.ProcessEnv = process.env,
|
|
104
141
|
): TelemetryConsent {
|
|
142
|
+
// The kill switch is resolved first and reads no file at all — so there is no
|
|
143
|
+
// project-tier declaration to report here, not even an unread one.
|
|
105
144
|
if (isHardDisabled(env)) {
|
|
106
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
enabled: false,
|
|
147
|
+
endpoint: '',
|
|
148
|
+
source: 'env-off',
|
|
149
|
+
endpointSource: 'off',
|
|
150
|
+
ignoredProjectKeys: [],
|
|
151
|
+
}
|
|
107
152
|
}
|
|
108
153
|
|
|
109
154
|
const user = readTelemetrySettings('user', cwd)
|
|
110
155
|
const project = readTelemetrySettings('project', cwd)
|
|
111
156
|
const { endpoint, source: endpointSource } = resolveEndpoint(user.endpoint, env)
|
|
157
|
+
const ignoredProjectKeys = declaredProjectKeys(project)
|
|
112
158
|
|
|
113
159
|
if (project.enabled === false) {
|
|
114
|
-
return { enabled: false, endpoint, source: 'project-veto', endpointSource }
|
|
160
|
+
return { enabled: false, endpoint, source: 'project-veto', endpointSource, ignoredProjectKeys }
|
|
115
161
|
}
|
|
116
162
|
if (user.enabled === true) {
|
|
117
|
-
return { enabled: true, endpoint, source: 'user-optin', endpointSource }
|
|
163
|
+
return { enabled: true, endpoint, source: 'user-optin', endpointSource, ignoredProjectKeys }
|
|
118
164
|
}
|
|
119
|
-
return { enabled: false, endpoint, source: 'default-off', endpointSource }
|
|
165
|
+
return { enabled: false, endpoint, source: 'default-off', endpointSource, ignoredProjectKeys }
|
|
120
166
|
}
|
|
121
167
|
|
|
122
168
|
function patchUserTelemetry(patch: TelemetrySettings, cwd: string): void {
|
package/src/telemetry/index.ts
CHANGED
|
@@ -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
|
*
|
package/src/tools/exec/task.ts
CHANGED
|
@@ -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
|
-
|
|
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 ──
|
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
-
//
|
|
2925
|
-
//
|
|
2926
|
-
//
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
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
|
-
${
|
|
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
|
]
|
package/src/ui/picker.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|