@miphamai/cli 0.85.4 → 0.85.6

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.
Files changed (52) hide show
  1. package/bin/mipham.ts +48 -7
  2. package/package.json +2 -2
  3. package/src/agent/agent-context.ts +8 -1
  4. package/src/agent/effectiveness-tracker.ts +16 -2
  5. package/src/agent/sub-agent.ts +25 -17
  6. package/src/agent-view/agents-standalone.tsx +42 -0
  7. package/src/core/autocomplete.ts +30 -2
  8. package/src/core/context.ts +61 -6
  9. package/src/core/dream-engine.ts +17 -2
  10. package/src/core/engine.ts +95 -34
  11. package/src/core/error-signature-db.ts +7 -2
  12. package/src/core/hooks-executor.ts +88 -11
  13. package/src/core/hooks.ts +26 -2
  14. package/src/core/instructions.ts +105 -17
  15. package/src/core/memory/memory-manager.ts +14 -6
  16. package/src/core/permission-classifier.ts +17 -5
  17. package/src/core/permission-rules.ts +1 -1
  18. package/src/core/permission.ts +50 -1
  19. package/src/core/rule-engine.ts +27 -2
  20. package/src/core/self-critique.ts +15 -3
  21. package/src/core/session-log.ts +60 -0
  22. package/src/daemon/index.ts +2 -5
  23. package/src/daemon/launch.ts +69 -2
  24. package/src/daemon/server.ts +19 -14
  25. package/src/i18n-core/locales/en-US.json +86 -143
  26. package/src/i18n-core/locales/zh-CN.json +86 -143
  27. package/src/index.tsx +17 -7
  28. package/src/mcp/client.ts +61 -0
  29. package/src/mcp/instructions.ts +49 -0
  30. package/src/mcp/types.ts +7 -0
  31. package/src/plugin/claude-plugin.ts +12 -2
  32. package/src/plugin/plugin-loader.ts +32 -14
  33. package/src/plugin/plugin-manager.ts +16 -2
  34. package/src/plugin/plugin-validator.ts +183 -1
  35. package/src/providers/anthropic.ts +159 -124
  36. package/src/providers/fetch-utils.ts +65 -34
  37. package/src/providers/openai-compat.ts +121 -106
  38. package/src/security/dangerous-rm.ts +192 -0
  39. package/src/shared/arg-validation.ts +11 -1
  40. package/src/shared/constants.ts +18 -0
  41. package/src/shared/deleted-cwd.ts +46 -1
  42. package/src/shared/package-info.ts +36 -1
  43. package/src/shared/types.ts +8 -0
  44. package/src/shared/update.ts +290 -146
  45. package/src/ui/command-picker.tsx +18 -10
  46. package/src/ui/commands.ts +20 -6
  47. package/src/ui/config-wizard.tsx +22 -19
  48. package/src/ui/graft-status.tsx +35 -6
  49. package/src/ui/input.tsx +7 -2
  50. package/src/ui/picker.tsx +38 -27
  51. package/src/ui/use-key-state.ts +55 -0
  52. package/src/daemon/message-bus.ts +0 -84
package/bin/mipham.ts CHANGED
@@ -293,13 +293,26 @@ async function runUpdate(): Promise<boolean> {
293
293
  if (!result.ok) {
294
294
  console.log()
295
295
  console.log(`✗ Update failed: ${result.reason ?? 'unknown error'}`)
296
- if (result.rolledBack) {
297
- console.log(
298
- ` Your previous install (v${currentVersion}) has been restored — mipham still works.`,
299
- )
300
- } else {
301
- console.log(' ⚠ The previous install could not be restored.')
302
- console.log(` Reinstall with: npm install -g ${PACKAGE}@${currentVersion}`)
296
+ // 这句必须只说**我们知道的**:staging 之后最常见的失败(装在旁边那步挂了)压根没碰过
297
+ // 旧树,此时沿用「未能恢复」就是在讲一件没发生的事。
298
+ switch (result.installState) {
299
+ case 'untouched':
300
+ console.log(
301
+ ` Your previous install (v${currentVersion}) was not touched — mipham still works.`,
302
+ )
303
+ break
304
+ case 'restored':
305
+ console.log(
306
+ ` Your previous install (v${currentVersion}) has been restored — mipham still works.`,
307
+ )
308
+ break
309
+ case 'unknown':
310
+ console.log(' ⚠ Could not locate the global install path — unable to tell.')
311
+ console.log(` Check with: mipham --version`)
312
+ break
313
+ default:
314
+ console.log(' ⚠ The previous install could not be restored.')
315
+ console.log(` Reinstall with: npm install -g ${PACKAGE}@${currentVersion}`)
303
316
  }
304
317
  if (backupPath && existsSync(backupPath)) {
305
318
  console.log(` Your config backup is at: ${backupPath}`)
@@ -1249,6 +1262,8 @@ Flags:
1249
1262
  --resume <name> Open a saved session (see /resume for names)
1250
1263
  --permission <mode> Start in this mode: ${ALL_MODES.join('|')}
1251
1264
  (also accepted by 'mipham attach'; the daemon may clamp it)
1265
+ --provider <id> Start on this provider (overrides config.yml)
1266
+ --model <id> Start on this model (overrides config.yml)
1252
1267
  --version, -v, -V Print version
1253
1268
 
1254
1269
  Docs: https://mipham.ai/code
@@ -1384,12 +1399,38 @@ npm: https://www.npmjs.com/package/@miphamai/cli`)
1384
1399
  process.exit(1)
1385
1400
  }
1386
1401
 
1402
+ // Parse --provider <id> / --model <id>: the provider and model the session starts
1403
+ // on. Same shape as `--resume`/`--permission` — `RunOptions` declared both fields all
1404
+ // along, `index.tsx` already reads them *ahead of* the merged config, and **nothing
1405
+ // ever passed them**. That third instance cost more than the first two: both shipped
1406
+ // IDE integrations build this exact command from their settings
1407
+ // (`infrastructure/vscode/extension.js` `buildFlags()`, `MiphamAction.kt`
1408
+ // `buildCommand()`), so a user who set a provider there got
1409
+ // `Unknown command: mipham deepseek` and no CLI at all. Their values are open — a
1410
+ // provider may be user-defined — so the value is forwarded as typed and an unknown id
1411
+ // is refused by the registry (`ProviderRegistry.getActive()` throws with the id named),
1412
+ // exactly as the same value already was when it came from `config.yml`.
1413
+ const flagValue = (name: string): string | undefined => {
1414
+ const at = process.argv.indexOf(name)
1415
+ if (at === -1) return undefined
1416
+ const value = process.argv[at + 1]
1417
+ if (!value || value.startsWith('-')) {
1418
+ console.error(`Usage: mipham ${name} <id>`)
1419
+ process.exit(1)
1420
+ }
1421
+ return value
1422
+ }
1423
+ const providerFlag = flagValue('--provider')
1424
+ const modelFlag = flagValue('--model')
1425
+
1387
1426
  try {
1388
1427
  const { runApp } = await import('../src/index')
1389
1428
  await runApp({
1390
1429
  version: APP_VERSION,
1391
1430
  resume: resumeName,
1392
1431
  permission: permissionFlag.kind === 'ok' ? permissionFlag.mode : undefined,
1432
+ provider: providerFlag,
1433
+ model: modelFlag,
1393
1434
  })
1394
1435
  } catch (err: unknown) {
1395
1436
  const msg = err instanceof Error ? err.message : String(err)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.85.4",
3
+ "version": "0.85.6",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -40,7 +40,7 @@
40
40
  "typecheck": "tsc --noEmit",
41
41
  "test": "vitest run",
42
42
  "knip": "knip --production --no-progress --no-exit-code",
43
- "coverage": "vitest run --coverage",
43
+ "coverage": "vitest run --coverage --reporter=default --reporter=json --outputFile.json=coverage/vitest-report.json",
44
44
  "mutate": "stryker run"
45
45
  },
46
46
  "dependencies": {
@@ -13,6 +13,13 @@ import { MIPHAM_DIR } from '../shared/constants.ts'
13
13
  export interface AgentContextResult {
14
14
  context: ContextManager
15
15
  allowedTools: ToolDefinition[]
16
+ /**
17
+ * 组装好的系统提示(**含** agent memory)。
18
+ *
19
+ * 必须由这里交出去、而不是让调用方自己再拼一份:memory 的拼接规则只此一处,调用方
20
+ * 拿不到它就等于重新推导一遍(漏掉的那一遍正是缺陷本身 —— 请求里从来没有记忆)。
21
+ */
22
+ systemPrompt: string
16
23
  }
17
24
 
18
25
  /**
@@ -145,5 +152,5 @@ export function createAgentContext(
145
152
  allowedTools = allowedTools.filter((t) => !denySet.has(t.name))
146
153
  }
147
154
 
148
- return { context, allowedTools }
155
+ return { context, allowedTools, systemPrompt }
149
156
  }
@@ -181,8 +181,22 @@ export class EffectivenessTracker {
181
181
  load(): void {
182
182
  if (!existsSync(this.storePath)) return
183
183
  try {
184
- const raw = JSON.parse(readFileSync(this.storePath, 'utf-8'))
185
- this.data = new Map(Object.entries(raw))
184
+ const parsed: unknown = JSON.parse(readFileSync(this.storePath, 'utf-8'))
185
+ // 形状门:要的是「**以规则 id 为键的对象**」。`Object.entries` 对数组会拿**下标**
186
+ // 当键(`{"0":"x"}` ⇒ 一条 ruleId 为 undefined 的记录),而 `new Map(...)` 照收,
187
+ // 于是 `allRules` 把非规则对象当规则报出去。
188
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
189
+ this.data = new Map()
190
+ return
191
+ }
192
+ const data = new Map<string, RuleEffectiveness>()
193
+ for (const [k, v] of Object.entries(parsed)) {
194
+ if (!v || typeof v !== 'object' || Array.isArray(v)) continue
195
+ const rec = v as { ruleId?: unknown }
196
+ if (typeof rec.ruleId !== 'string') continue
197
+ data.set(k, v as RuleEffectiveness)
198
+ }
199
+ this.data = data
186
200
  } catch {
187
201
  // Corrupt file — start fresh
188
202
  this.data = new Map()
@@ -339,23 +339,31 @@ export class SubAgent {
339
339
  }
340
340
 
341
341
  // Create isolated context with tool scoping, sized to the resolved model.
342
- const resolvedDef: AgentDefinition = agentDef || {
343
- name: agentType,
344
- description: '',
345
- systemPrompt,
346
- model: options.modelOverride || 'inherit',
347
- permissionMode: 'inherit',
348
- background: false,
349
- source: 'builtin',
350
- }
342
+ // `systemPrompt` 是运行时**解析出来的那一份**(定义 > 调用方传入 > 内建类型),
343
+ // 定义自己没写时就落到解析结果上 —— 否则 `createAgentContext()` 拿到的是空串,
344
+ // 记忆会拼在一个空的基座上。
345
+ const resolvedDef: AgentDefinition = agentDef
346
+ ? { ...agentDef, systemPrompt: agentDef.systemPrompt || systemPrompt }
347
+ : {
348
+ name: agentType,
349
+ description: '',
350
+ systemPrompt,
351
+ model: options.modelOverride || 'inherit',
352
+ permissionMode: 'inherit',
353
+ background: false,
354
+ source: 'builtin',
355
+ }
351
356
  const contextWindow = this.registry.findModel(finalModel)?.contextWindow
352
- const { context, allowedTools } = createAgentContext(
353
- resolvedDef,
354
- this.toolRegistry,
355
- contextWindow,
356
- )
357
+ // 这一份**已含 agent memory**,是发出去的那个提示的唯一来源。
358
+ const {
359
+ context,
360
+ allowedTools,
361
+ systemPrompt: composedSystemPrompt,
362
+ } = createAgentContext(resolvedDef, this.toolRegistry, contextWindow)
357
363
 
358
- context.setSystemPrompt(systemPrompt)
364
+ // 别再 `context.setSystemPrompt(systemPrompt)`:`createAgentContext` 已经拿**含记忆**的
365
+ // 那一份设过上下文,这里用裸提示再设一遍只会把记忆从上下文里抹掉,而请求读的又不是
366
+ // 上下文 —— 两处各设一次的结果是「上下文里没有、请求里也没有」。
359
367
 
360
368
  // Seed inherited parent conversation (fork inheritance) as a byte-identical
361
369
  // prefix so the provider prompt cache is reused.
@@ -405,8 +413,8 @@ export class SubAgent {
405
413
  // 权限段随**唯一**带提示的那一轮走,而不是每轮派生。
406
414
  const permissionBlock = buildPermissionBlock(gate.getMode())
407
415
  let currentSystemPrompt = permissionBlock
408
- ? `${systemPrompt}\n\n---\n\n${permissionBlock}`
409
- : systemPrompt
416
+ ? `${composedSystemPrompt}\n\n---\n\n${permissionBlock}`
417
+ : composedSystemPrompt
410
418
  let totalTokens = 0
411
419
 
412
420
  // Context for the sub-agent's own tool calls. Built once per run: the caller's
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `mipham agents` 独立面板 —— 从 `index.tsx` 里抽出来的那段 JSX,好让它可测。
3
+ *
4
+ * 抽取的理由是**可测性**:`index.tsx` 是进程入口(`render` + `waitUntilExit`),
5
+ * 它里面传下去的东西在测试里够不着;而这条路上的 Enter 落点曾经整个缺席
6
+ * —— 页脚广告 attach,落点是 `() => {}`。
7
+ */
8
+
9
+ import React, { useState } from 'react'
10
+
11
+ import { AgentSessionView } from './session-view'
12
+ import { AgentViewDashboard } from './dashboard'
13
+ import type { AgentViewManager } from './agent-view-manager'
14
+
15
+ interface AgentsStandaloneProps {
16
+ manager: AgentViewManager
17
+ onExit: () => void
18
+ }
19
+
20
+ export function AgentsStandalone({ manager, onExit }: AgentsStandaloneProps) {
21
+ // 与 `app.tsx` 同形:attach 把会话交给只读视图,视图里的 Esc 再交还列表。
22
+ // 页脚无条件印着「Enter attach」—— 它必须有个落点,否则按键有反应而世界不变。
23
+ const [attachedSessionId, setAttachedSessionId] = useState<string | null>(null)
24
+
25
+ if (attachedSessionId) {
26
+ return (
27
+ <AgentSessionView
28
+ manager={manager}
29
+ sessionId={attachedSessionId}
30
+ onDetach={() => setAttachedSessionId(null)}
31
+ />
32
+ )
33
+ }
34
+
35
+ return (
36
+ <AgentViewDashboard
37
+ manager={manager}
38
+ onAttach={(session) => setAttachedSessionId(session.id)}
39
+ onExit={onExit}
40
+ />
41
+ )
42
+ }
@@ -7,16 +7,39 @@ export const AUTOCOMPLETE_SYSTEM_PROMPT =
7
7
  /** 带上最近几条对话(含待续写输入),供续写贴合上下文。 */
8
8
  export const AUTOCOMPLETE_MAX_CONTEXT = 6
9
9
 
10
+ /**
11
+ * 每条上下文消息最多带这么多**字符**(保留尾部)。
12
+ *
13
+ * 上面那个常数限的是**条数**,而一条 `content` 可以任意长 —— 贴进来一个文件、
14
+ * 或一条长回复,6 条就是上万 token,而用户每次 >400ms 的停顿都要买一次。
15
+ * 续写要看的是「刚说到哪儿」,所以砍头留尾;加 `…` 是免得把片段读成消息开头。
16
+ * 每条封顶 + 条数封顶,总量就是封死的,不需要再维护第二个预算常数。
17
+ */
18
+ export const AUTOCOMPLETE_MAX_CHARS_PER_MESSAGE = 2000
19
+
10
20
  export interface RecentMessage {
11
21
  role: 'user' | 'assistant'
12
22
  content: string
13
23
  }
14
24
 
15
- /** 拼续写请求:systemPrompt + 最近 N 条 + 当前输入作为待续写消息。 */
25
+ function tailOf(content: string): string {
26
+ return content.length <= AUTOCOMPLETE_MAX_CHARS_PER_MESSAGE
27
+ ? content
28
+ : '…' + content.slice(-AUTOCOMPLETE_MAX_CHARS_PER_MESSAGE)
29
+ }
30
+
31
+ /** 拼续写请求:systemPrompt + 最近 N 条(每条限长)+ 当前输入作为待续写消息。 */
16
32
  export function buildAutocompleteRequest(recent: RecentMessage[], input: string): ChatRequest {
17
33
  return {
18
34
  model: '', // falsy → registry 回退 active model
19
- messages: [...recent.slice(-AUTOCOMPLETE_MAX_CONTEXT), { role: 'user', content: input }],
35
+ // 待续写的当前输入**不截断**:它是被续写的那条本身,且 extractCompletion 的
36
+ // 判据依赖它的完整值。上限落在历史消息上。
37
+ messages: [
38
+ ...recent
39
+ .slice(-AUTOCOMPLETE_MAX_CONTEXT)
40
+ .map((m) => ({ role: m.role, content: tailOf(m.content) })),
41
+ { role: 'user', content: input },
42
+ ],
20
43
  systemPrompt: AUTOCOMPLETE_SYSTEM_PROMPT,
21
44
  temperature: 0,
22
45
  maxTokens: 64,
@@ -57,6 +80,11 @@ export async function requestSuggestion(
57
80
  const req = buildAutocompleteRequest(recent, input)
58
81
  let text = ''
59
82
  for await (const chunk of llm.chat(req)) {
83
+ // 用户又敲了一下 ⇒ 这条请求已经过期,当场走人。`break` 不只是「不再读」:
84
+ // 它触发生成器的 `.return()` ⇒ provider 的 `finally` ⇒ `reader.cancel()`,
85
+ // 连接当场释放。若把这一判挪到循环外,就等于**先把整条流读完**再丢掉结果 ——
86
+ // 那正是「取消不掉」:每次 >400ms 的停顿都买一个完整 completion。
87
+ if (isStale()) break
60
88
  if (chunk.type === 'text' && chunk.content) text += chunk.content
61
89
  }
62
90
  if (isStale()) return null
@@ -8,6 +8,7 @@ import {
8
8
  deriveMessages,
9
9
  assertModelVisible,
10
10
  isAssertModelVisibleDebug,
11
+ closeInterruptedToolCalls,
11
12
  } from './session-log'
12
13
 
13
14
  export type Summarizer = (messages: Message[], heading: string) => Promise<string>
@@ -45,6 +46,12 @@ export class ContextManager {
45
46
  * `index.tsx` 把它接到 live `PermissionSystem.getMode()` 上,于是切一次档,下一次请求就变。
46
47
  */
47
48
  private permissionContextSource: (() => string) | null = null
49
+ /**
50
+ * 系统提示里的 **MCP instructions 段**同样是读时派生的,理由比权限段更硬:
51
+ * MCP server 是**启动后异步连上**的,而提示在 `setSystemPrompt()` 那一刻就建好了。
52
+ * 组装时烘进去的话,本次会话里后连上的 server 永远进不了提示 —— 用户只能重启。
53
+ */
54
+ private mcpInstructionsSource: (() => string) | null = null
48
55
  private estimatedTokens = 0
49
56
  private checkpoints: Checkpoint[] = []
50
57
  private checkpointCounter = 0
@@ -79,6 +86,12 @@ export class ContextManager {
79
86
  /** 从已持久化的日志恢复:设 log 为源,messages 为投影(不重复写通)。 */
80
87
  restoreLog(log: SessionLog): void {
81
88
  this.log = log
89
+ // 盘上那份历史可能以一条**没有结果**的调用收尾(写盘写到一半被打断,末尾那行半截
90
+ // 结果被 `open()` 静默丢掉 —— 引擎自己的日志顺序到不了这个形状,见下面那个函数的
91
+ // 注释)。挂着 `tool_calls` 却没有结果回应的请求会被 provider 整条拒收,用户看到的
92
+ // 是「恢复之后第一句话就报协议错」。恢复的这一刻把它补**进日志**(不是补进投影,
93
+ // 否则「模型看得见的必须已记录」这条不变量当场破),见 `closeInterruptedToolCalls`。
94
+ closeInterruptedToolCalls(log)
82
95
  this.messages = deriveMessages(log.events())
83
96
  this.reEstimateTokens()
84
97
  }
@@ -124,7 +137,11 @@ export class ContextManager {
124
137
 
125
138
  setSystemPrompt(prompt: string): void {
126
139
  this.systemPrompt = prompt
127
- this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
140
+ // 估值必须**含 messages** —— 走 `reEstimateTokens()` 这一条唯一推导,别在这儿手写
141
+ // 第二份。`--resume` 路径(`index.tsx:584-585`)先 `restoreLog()` 算出含消息的完整
142
+ // 估值,紧接着调这里;只按系统提示重算会把它**覆盖成偏低值** ⇒ `needsCompaction()`
143
+ // 长期偏 false ⇒ 压缩迟触发(上下文越滚越大才动手)。
144
+ this.reEstimateTokens()
128
145
  }
129
146
 
130
147
  /**
@@ -138,14 +155,28 @@ export class ContextManager {
138
155
  }
139
156
 
140
157
  /**
141
- * 存储的提示 + 读时派生的权限段。
158
+ * 接线点(`index.tsx`):把已连 MCP server 自带的 `instructions` 接到提示上。
159
+ *
160
+ * 传 `null` 撤销。空串(无 server / 都没写 instructions)不产生空段。
161
+ */
162
+ setMcpInstructionsSource(fn: (() => string) | null): void {
163
+ this.mcpInstructionsSource = fn
164
+ }
165
+
166
+ /**
167
+ * 存储的提示 + 读时派生的段(权限 / MCP instructions)。
142
168
  *
143
- * 段尾追加(而非插回原来的中段位置)是刻意的:只切档时**前缀保持不变**,
144
- * 提供方的 prefix cache 仍能命中到权限段之前的部分。
169
+ * 段尾追加(而非插回原来的中段位置)是刻意的:只切档、只连一个新 server 时
170
+ * **前缀保持不变**,提供方的 prefix cache 仍能命中到这些段之前的部分。
145
171
  */
146
172
  private composedSystemPrompt(): string {
147
- const block = this.permissionContextSource?.() ?? ''
148
- return block ? `${this.systemPrompt}\n\n---\n\n${block}` : this.systemPrompt
173
+ const blocks = [
174
+ this.permissionContextSource?.() ?? '',
175
+ this.mcpInstructionsSource?.() ?? '',
176
+ ].filter((b) => b !== '')
177
+ return blocks.length > 0
178
+ ? [this.systemPrompt, ...blocks].join('\n\n---\n\n')
179
+ : this.systemPrompt
149
180
  }
150
181
 
151
182
  getSystemPrompt(): string {
@@ -170,6 +201,30 @@ export class ContextManager {
170
201
  this.checkCompression()
171
202
  }
172
203
 
204
+ /**
205
+ * 注入一段**模型可见的上下文**(规则块 / compact 前后的 hook 提示 / stop hook 交回的话 /
206
+ * 后台 agent 的来件)。
207
+ *
208
+ * 为什么不复用 addMessage:那会把注入记成 `user/message`,在日志里与**用户真说过的话**
209
+ * 完全同形 —— 事后翻 session log 分不出哪一句是用户敲的、哪一句是我们塞进去的。记成
210
+ * `context/inject`(带 `source`)就带得出来源;而 `deriveMessages` 仍把它还原成同一个
211
+ * user 消息,所以**投影逐字节不变**,「model-visible means logged」照样成立。
212
+ */
213
+ injectContext(source: string, text: string): void {
214
+ this.messages.push({ role: 'user', content: text })
215
+ this.estimatedTokens += this.estimateTokens(text)
216
+
217
+ if (this.log) {
218
+ this.log.append({ type: 'context/inject', at: Date.now(), source, text })
219
+ }
220
+
221
+ if (this.log && isAssertModelVisibleDebug()) {
222
+ assertModelVisible(this.log.events(), this.messages)
223
+ }
224
+
225
+ this.checkCompression()
226
+ }
227
+
173
228
  /** 记录工具执行结果(全量 ToolResult 含 success/error)到日志,并写投影消息(不重复走 messageToEvents 拆分)。 */
174
229
  addToolResult(toolUseId: string, result: ToolResult): void {
175
230
  const content = result.success ? result.content : result.error || result.content
@@ -521,7 +521,11 @@ export class DreamEngine {
521
521
 
522
522
  let log: Array<{ timestamp: string; actions: DreamAction[] }> = []
523
523
  if (existsSync(this.dreamLog)) {
524
- log = JSON.parse(readFileSync(this.dreamLog, 'utf-8'))
524
+ // 形状门:`{"a":1}` 是**合法 JSON**,不抛 —— 而 `log.unshift` 会抛,被下面的
525
+ // `catch` 吞掉 ⇒ 这一轮梦白做、且用户看不到任何提示。退成空表继续,
526
+ // 让这一轮活下来,别让它给一个坏文件陪葬。
527
+ const parsed: unknown = JSON.parse(readFileSync(this.dreamLog, 'utf-8'))
528
+ if (Array.isArray(parsed)) log = parsed
525
529
  }
526
530
  log.unshift({ timestamp, actions })
527
531
  // Keep last 10 dream cycles
@@ -536,7 +540,18 @@ export class DreamEngine {
536
540
  getDreamHistory(): Array<{ timestamp: string; actions: DreamAction[] }> {
537
541
  try {
538
542
  if (!existsSync(this.dreamLog)) return []
539
- return JSON.parse(readFileSync(this.dreamLog, 'utf-8'))
543
+ const parsed: unknown = JSON.parse(readFileSync(this.dreamLog, 'utf-8'))
544
+ // 声明返回数组,就必须真的是数组:`{"a":1}` 合法且不抛,而调用方
545
+ // (`ui/commands.ts` 的 `/dream --status`)紧接着 `.length` / `.slice` /
546
+ // `entry.actions.length` ⇒ `try` 护不住调用点,TypeError 直接冒到 UI。
547
+ if (!Array.isArray(parsed)) return []
548
+ return parsed.filter(
549
+ (e): e is { timestamp: string; actions: DreamAction[] } =>
550
+ !!e &&
551
+ typeof e === 'object' &&
552
+ typeof e.timestamp === 'string' &&
553
+ Array.isArray(e.actions),
554
+ )
540
555
  } catch {
541
556
  return []
542
557
  }