@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.85.6",
3
+ "version": "0.85.7",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -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')
@@ -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
- private estimatedTokens = 0
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.reEstimateTokens()
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
- // 估值必须**含 messages** —— 走 `reEstimateTokens()` 这一条唯一推导,别在这儿手写
141
- // 第二份。`--resume` 路径(`index.tsx:584-585`)先 `restoreLog()` 算出含消息的完整
142
- // 估值,紧接着调这里;只按系统提示重算会把它**覆盖成偏低值** ⇒ `needsCompaction()`
143
- // 长期偏 false ⇒ 压缩迟触发(上下文越滚越大才动手)。
144
- this.reEstimateTokens()
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.estimatedTokens += this.estimateTokens(
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.estimatedTokens += this.estimateTokens(text)
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.estimatedTokens += this.estimateTokens(JSON.stringify(msg.content))
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.reEstimateTokens()
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.estimatedTokens > this.config.maxTokens * this.config.compactionThreshold
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.estimatedTokens
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 tokens
326
- this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
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.estimatedTokens }
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.estimatedTokens
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.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
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 tokens
360
- this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
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
- this.estimatedTokens = target.estimatedTokens
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.estimatedTokens / this.config.maxTokens
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.reEstimateTokens()
525
+ this.recountMessageTokens()
501
526
  }
502
527
 
503
- /** Re-estimate tokens from system prompt + current messages. */
504
- private reEstimateTokens(): void {
505
- this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
528
+ /** 从当前消息**重算消息那半**的估值(提示那半不在这里 —— 它是读时派生的)。 */
529
+ private recountMessageTokens(): void {
530
+ this.messageTokens = 0
506
531
  for (const msg of this.messages) {
507
- this.estimatedTokens += this.estimateTokens(
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
  *
@@ -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.6' as const
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 = 3506 as const
93
+ export const TEST_COUNT = 3535 as const
@@ -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 { enabled: false, endpoint: '', source: 'env-off', endpointSource: 'off' }
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 {
@@ -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 ──
@@ -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
  ]
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