@miphamai/cli 0.62.0 → 0.63.0

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.62.0",
3
+ "version": "0.63.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -47,11 +47,7 @@ export class EffectivenessTracker {
47
47
  if (eff) eff.decisionId = decisionId
48
48
  }
49
49
 
50
- recordApplication(
51
- ruleId: string,
52
- success: boolean,
53
- opts?: { toolName?: string; error?: string },
54
- ): void {
50
+ recordApplication(ruleId: string, success: boolean, opts?: { error?: string }): void {
55
51
  let eff = this.data.get(ruleId)
56
52
  if (!eff) {
57
53
  eff = {
@@ -69,7 +65,7 @@ export class EffectivenessTracker {
69
65
  }
70
66
 
71
67
  // 可恢复/环境性失败:仍记录,但不进成功率分母(防误降级/误禁用能用的规则)。
72
- if (!success && opts?.error && isRecoverableToolFailure(opts.toolName ?? '', opts.error)) {
68
+ if (!success && opts?.error && isRecoverableToolFailure(opts.error)) {
73
69
  eff.recoverableCount = (eff.recoverableCount ?? 0) + 1
74
70
  return
75
71
  }
@@ -24,6 +24,16 @@ export interface AgentMessage {
24
24
  type: AgentMessageType
25
25
  }
26
26
 
27
+ /**
28
+ * Format an inbound message for injection into the conversation as a
29
+ * user-role notice. Collapsed to one line (`Message from @<sender>: <summary>`)
30
+ * so cross-session peer messages don't bloat the context; the full body stays
31
+ * in the bus for Ctrl+O expansion.
32
+ */
33
+ export function formatInboundMessage(msg: AgentMessage): string {
34
+ return `Message from @${msg.from}: ${msg.summary}`
35
+ }
36
+
27
37
  export class AgentMessageBus {
28
38
  private messages: AgentMessage[] = []
29
39
  private idCounter = 0
@@ -1,21 +1,27 @@
1
1
  /**
2
- * 判「可恢复/环境性」失败 vs 真缺陷(borrow opencrabs `is_recoverable_tool_failure` 思想)。
2
+ * 判「可恢复/环境性」失败 vs 真缺陷(borrow openprabs is_recoverable_tool_failure 思想)。
3
3
  *
4
- * 可恢复失败(网络超时、连接拒绝、缺文件、环境权限)不该进成功率分母,否则会把成功率
5
- * 拉低、诱导 EffectivenessTracker 禁用能用的规则(openprabs #236 的坑:stale-hash 重试
6
- * 被误判成工具缺陷,把能用的 hashline_edit 禁了)。
4
+ * 可恢复失败(网络超时、连接拒绝、DNS 解析失败、环境权限)不该进成功率分母,否则会把
5
+ * 成功率拉低、诱导 EffectivenessTracker 禁用能用的规则(openprabs #236 的坑:stale-hash
6
+ * 重试被误判成工具缺陷,把能用的 hashline_edit 禁了)。
7
7
  *
8
- * fail-closed:无 error、或非 Bash 工具,一律按真失败算(证不了可恢复就不排除)。
8
+ * 刻意排除 ENOENT(no such file or directory):缺文件/路径错更可能是规则本身指错了
9
+ * 路径(幻觉),而非环境瞬态,须计入分母(2026-08-27 review M1)。
10
+ *
11
+ * 不区分工具:这些错误模式(超时/连接/DNS/权限)本身即工具无关的环境信号。旧的
12
+ * `toolName !== 'Bash'` 守卫漏掉 Write/Grep 规则(2026-08-27 review M2),已移除。
13
+ *
14
+ * fail-closed:无 error 一律按真失败算(证不了可恢复就不排除)。
9
15
  */
10
16
  const RECOVERABLE_PATTERNS: RegExp[] = [
11
- /\btimed?\s?out\b/i, // timeout / timed out(网络慢 / 服务死)
12
- /(ECONNREFUSED|connection refused|not connected|network unreachable)/i, // 服务未起
13
- /(no such file or directory|ENOENT)/i, // 缺资源,非缺陷
17
+ /\btimed?\s?out\b/i, // timeout / timed out / timedout(网络慢 / 服务慢)
18
+ /(ECONNREFUSED|ECONNRESET|ETIMEDOUT|connection refused|not connected|network unreachable)/i, // 服务未起 / 网络超时
19
+ /(could not resolve host|name or service not known|getaddrinfo|ENOTFOUND)/i, // DNS 解析失败
14
20
  /(permission denied|EACCES)/i, // 环境权限
15
21
  ]
16
22
 
17
- export function isRecoverableToolFailure(toolName: string, error?: string): boolean {
18
- if (!error || toolName !== 'Bash') return false
23
+ export function isRecoverableToolFailure(error?: string): boolean {
24
+ if (!error) return false
19
25
  const e = error.toLowerCase()
20
26
  return RECOVERABLE_PATTERNS.some((re) => re.test(e))
21
27
  }
@@ -319,6 +319,7 @@ export class SubAgent {
319
319
 
320
320
  const chunks: string[] = []
321
321
  const MAX_TOOL_TURNS = options.maxTurns || 5
322
+ let hitMaxTurns = false
322
323
 
323
324
  let currentMessages = messages
324
325
  let currentSystemPrompt = systemPrompt
@@ -360,7 +361,7 @@ export class SubAgent {
360
361
  })
361
362
  }
362
363
  if (chunk.type === 'error') {
363
- throw new Error(`Sub-agent execution failed: ${chunk.error}`)
364
+ throw new Error(`Sub-agent execution failed (model ${finalModel}): ${chunk.error}`)
364
365
  }
365
366
  if (chunk.type === 'stop') {
366
367
  break
@@ -501,6 +502,9 @@ export class SubAgent {
501
502
 
502
503
  // Don't send system prompt on subsequent turns
503
504
  currentSystemPrompt = ''
505
+
506
+ // On the final permitted turn we still used tools → hit the cap.
507
+ if (turn === MAX_TOOL_TURNS - 1) hitMaxTurns = true
504
508
  }
505
509
  } catch (err) {
506
510
  if (err instanceof DOMException && err.name === 'AbortError') {
@@ -509,10 +513,18 @@ export class SubAgent {
509
513
  if (err instanceof Error && err.message.startsWith('Sub-agent')) {
510
514
  throw err
511
515
  }
512
- throw new Error(`Sub-agent execution failed: ${String(err)}`)
516
+ throw new Error(`Sub-agent execution failed (model ${finalModel}): ${String(err)}`)
513
517
  }
514
518
 
515
- return chunks.join('')
519
+ const result = chunks.join('')
520
+ if (hitMaxTurns) {
521
+ return (
522
+ `[partial result — sub-agent reached its ${MAX_TOOL_TURNS}-turn limit; ` +
523
+ 'task may be incomplete. Use SendMessage to continue this sub-agent.]\n\n' +
524
+ result
525
+ )
526
+ }
527
+ return result
516
528
  }
517
529
 
518
530
  /**
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { execSync } from 'node:child_process'
18
18
  import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
19
- import { join, resolve, sep } from 'node:path'
19
+ import { join, resolve, sep, posix } from 'node:path'
20
20
  import { tmpdir, homedir } from 'node:os'
21
21
  import { randomUUID } from 'node:crypto'
22
22
 
@@ -115,6 +115,12 @@ const PROTECTED_PATHS = [
115
115
  'apps/cli/src/core/crsi-sandbox.ts',
116
116
  'apps/cli/src/core/crsi-producer.ts',
117
117
  'apps/cli/src/core/proposal-guard.ts',
118
+ // 2026-08-27 review 补齐:运行时执行/评估器,改掉会削弱 grader 或安全边界
119
+ 'apps/cli/src/core/crsi-modify.ts',
120
+ 'apps/cli/src/core/rule-engine.ts',
121
+ 'apps/cli/src/core/red-team.ts',
122
+ 'apps/cli/src/core/error-signature-db.ts',
123
+ 'apps/cli/src/core/preflight-checker.ts',
118
124
  ]
119
125
 
120
126
  /** 是否命中只读边界。前缀匹配,目录条目以 `/` 结尾。 */
@@ -130,13 +136,24 @@ export function isProtectedPath(filePath: string): boolean {
130
136
  *
131
137
  * 返回错误字符串(拒绝理由),合法时返回 null。
132
138
  */
133
- export function validateBlastRadius(proposal: { blastRadius?: string[] }): string | null {
139
+ export function validateBlastRadius(proposal: {
140
+ filePath?: string
141
+ blastRadius?: string[]
142
+ }): string | null {
143
+ const filePath = proposal.filePath
134
144
  if (!proposal.blastRadius || proposal.blastRadius.length === 0) {
135
145
  return (
136
146
  'blast radius 未声明:自修改必须枚举触及的全部代码路径。' +
137
147
  '教训:两条渲染路径只接一条 = 局部正确全局遗漏。'
138
148
  )
139
149
  }
150
+ // 2026-08-27 review:blastRadius 不能只自证「非空」,必须覆盖被修改文件本身
151
+ // (前缀匹配,目录条目以 / 结尾),否则声明形同虚设。
152
+ if (filePath && !proposal.blastRadius.some((p) => filePath === p || filePath.startsWith(p))) {
153
+ return (
154
+ `blast radius 未覆盖目标文件 "${filePath}":` + '声明必须包含被修改文件本身(前缀匹配)。'
155
+ )
156
+ }
140
157
  return null
141
158
  }
142
159
 
@@ -225,7 +242,10 @@ export class CrsiSandbox {
225
242
  }
226
243
 
227
244
  const worktreeRoot = resolve(this.worktreePath)
228
- const targetPath = resolve(this.worktreePath, mod.filePath)
245
+ // Normalize "./" and "subdir/../" so the protected-path guard can't be evaded
246
+ // by a path that resolves back inside the worktree (raw prefix match would miss it).
247
+ const normalizedFilePath = posix.normalize(mod.filePath)
248
+ const targetPath = resolve(this.worktreePath, normalizedFilePath)
229
249
 
230
250
  // Path traversal guard: reject any target that resolves outside the sandbox worktree
231
251
  if (targetPath !== worktreeRoot && !targetPath.startsWith(worktreeRoot + sep)) {
@@ -237,7 +257,7 @@ export class CrsiSandbox {
237
257
 
238
258
  // Protected-path guard: the self-improvement loop must not modify the
239
259
  // constitution, eval harness, or improvement machinery itself.
240
- if (isProtectedPath(mod.filePath)) {
260
+ if (isProtectedPath(normalizedFilePath)) {
241
261
  result.error = `Protected path: "${mod.filePath}" is read-only to the self-improvement loop.`
242
262
  result.phase = 'failed'
243
263
  this.sessionReport.modifications.push(result)
@@ -275,7 +295,7 @@ export class CrsiSandbox {
275
295
 
276
296
  // Generate diff
277
297
  try {
278
- result.diff = execSync(`git diff -- "${mod.filePath}"`, {
298
+ result.diff = execSync(`git diff -- "${normalizedFilePath}"`, {
279
299
  cwd: this.worktreePath,
280
300
  timeout: 10_000,
281
301
  encoding: 'utf-8',
@@ -40,7 +40,12 @@ import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './infe
40
40
  import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
41
41
  import { registerWakeupHandler } from '../tools/scheduling/schedule-wakeup'
42
42
  import { accumulateGraftSavings } from '../shared/graft-savings'
43
- import { getMessageBus, type AgentMessage } from '../agent/message-bus'
43
+ import {
44
+ getMessageBus,
45
+ formatInboundMessage,
46
+ type AgentMessage,
47
+ type AgentMessageBus,
48
+ } from '../agent/message-bus'
44
49
  import { createT } from '../i18n-core/t'
45
50
  import enUS from '../i18n-core/locales/en-US.json'
46
51
  import zhCN from '../i18n-core/locales/zh-CN.json'
@@ -255,6 +260,28 @@ export class QueryEngine {
255
260
  }
256
261
  }
257
262
 
263
+ /**
264
+ * Drain unread inbound messages from the in-memory bus (addressed to this
265
+ * session or to "main") and inject them into the conversation as user-role
266
+ * notices. Returns the number of messages injected.
267
+ *
268
+ * The bus is the delivery channel for same-process SendMessage ("main",
269
+ * "bg-*", "sub-agent-*") and the re-post target of pollCrossSessionInbox;
270
+ * this is the missing "drain" step that surfaces those messages to the model.
271
+ */
272
+ drainInboundMessages(bus: AgentMessageBus = getMessageBus()): number {
273
+ const recipients = [...new Set([this.sessionId, 'main'])]
274
+ let injected = 0
275
+ for (const recipient of recipients) {
276
+ for (const msg of bus.poll(recipient)) {
277
+ this.context.addMessage({ role: 'user', content: formatInboundMessage(msg) })
278
+ injected++
279
+ }
280
+ bus.markAllRead(recipient)
281
+ }
282
+ return injected
283
+ }
284
+
258
285
  /** Register a hook engine for pre/post tool-use lifecycle events. */
259
286
  setHookEngine(hooks: HookEngine): void {
260
287
  this.hookEngine = hooks
@@ -474,6 +501,8 @@ export class QueryEngine {
474
501
 
475
502
  // Poll for cross-session messages before each turn
476
503
  await this.pollCrossSessionInbox()
504
+ // Drain in-memory bus messages (same-process SendMessage + cross-session re-posts)
505
+ this.drainInboundMessages()
477
506
 
478
507
  // Fire UserPromptSubmit hooks before processing
479
508
  if (this.hookEngine) {
@@ -1204,7 +1233,6 @@ export class QueryEngine {
1204
1233
  const ruleId = match?.[1]
1205
1234
  if (ruleId) {
1206
1235
  tracker.recordApplication(ruleId, result.success, {
1207
- toolName: name,
1208
1236
  error: result.error,
1209
1237
  })
1210
1238
  }
@@ -49,7 +49,8 @@ function executeCommand(cfg: HookConfig, ctx: HookContext): HookResult {
49
49
  }
50
50
 
51
51
  // Non-zero exit: check for block signal (exit code 2)
52
- const stderr = result.stderr?.toString() || ''
52
+ // 截断 stderr 防 MB 级 hook 输出溢出会话(对齐 HTTP hook 的 slice(0,2000))。
53
+ const stderr = (result.stderr?.toString() || '').slice(0, 2000)
53
54
 
54
55
  if (result.status === 2) {
55
56
  return {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Generate the session name (ID) used for cross-session tracking, inbox routing,
3
+ * and session persistence.
4
+ *
5
+ * Includes milliseconds so two sessions launched in the same second do not
6
+ * collide on the shared inbox directory / active-session registry. (2026-08-27
7
+ * review: the old `slice(0, 19)` dropped ms and let same-second launches share
8
+ * one inbox, leaking / misdelivering cross-session messages.)
9
+ */
10
+ export function generateSessionName(resume: string | undefined, now: Date = new Date()): string {
11
+ return resume || `session-${now.toISOString().replace(/[:.]/g, '-')}`
12
+ }
package/src/index.tsx CHANGED
@@ -25,6 +25,7 @@ import { loadSessionMemories, getMemoryManager } from './core/memory/memory-load
25
25
  import { ContextManager } from './core/context'
26
26
  import { PrefixCacheTracker } from './core/context-token'
27
27
  import { QueryEngine } from './core/engine'
28
+ import { generateSessionName } from './core/session-name'
28
29
  import { ExperienceRuleEngine } from './core/rule-engine.js'
29
30
  import { SessionLog } from './core/session-log'
30
31
  import { SessionStore } from './core/session-store'
@@ -40,6 +41,7 @@ import { mountConstitution, createConstitution } from './core/constitution-seam'
40
41
  import { ConstitutionLoader } from './core/constitution-loader'
41
42
  import { McpClient } from './mcp/client'
42
43
  import { registerMcpServerTools, syncMcpToolsOnChange } from './mcp/registry'
44
+ import { formatMcpConnectFailures, type McpConnectFailure } from './mcp/connect-failures'
43
45
  import { AgentRegistry } from './agent/agent-registry'
44
46
  import { HookEngine } from './core/hooks'
45
47
  import { ArtifactServer } from './artifacts/server'
@@ -202,8 +204,8 @@ function SetupGate(props: SetupGateProps) {
202
204
  async function connectMcpServers(
203
205
  mcpServers: McpServerConfig[],
204
206
  tools: ReturnType<typeof createToolRegistry>,
205
- ): Promise<void> {
206
- if (mcpServers.length === 0) return
207
+ ): Promise<McpConnectFailure[]> {
208
+ if (mcpServers.length === 0) return []
207
209
  const mcp = McpClient.getInstance()
208
210
  // Wire runtime tool-list changes before connect so mid-connect updates land.
209
211
  syncMcpToolsOnChange(mcp, tools)
@@ -216,14 +218,17 @@ async function connectMcpServers(
216
218
  }
217
219
  }),
218
220
  )
221
+ const failures: McpConnectFailure[] = []
219
222
  for (let i = 0; i < results.length; i++) {
220
223
  const result = results[i]!
221
224
  if (result.status === 'rejected') {
222
- process.stderr.write(
223
- `[mcp] Failed to connect "${mcpServers[i]!.name}": ${String(result.reason)}\n`,
224
- )
225
+ const name = mcpServers[i]!.name
226
+ const reason = String(result.reason)
227
+ failures.push({ name, reason })
228
+ process.stderr.write(`[mcp] Failed to connect "${name}": ${reason}\n`)
225
229
  }
226
230
  }
231
+ return failures
227
232
  }
228
233
 
229
234
  export async function runApp(options: RunOptions): Promise<void> {
@@ -325,8 +330,7 @@ export async function runApp(options: RunOptions): Promise<void> {
325
330
  const pluginManager = new PluginManager()
326
331
 
327
332
  // Generate session name for tracking (used by /cd to persist cwd)
328
- const sessionName =
329
- options.resume || `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
333
+ const sessionName = generateSessionName(options.resume)
330
334
 
331
335
  // Initialize context — restore saved session if available
332
336
  // Read the active model's context window for dynamic max-token sizing.
@@ -498,7 +502,7 @@ export async function runApp(options: RunOptions): Promise<void> {
498
502
  // Connect MCP servers lazily in the background: the UI renders immediately,
499
503
  // and each server's tools register into the shared registry as it connects.
500
504
  // A dead server (e.g. a 15s connect timeout) no longer blocks startup.
501
- void connectMcpServers(config.skills?.mcpServers ?? [], tools)
505
+ const mcpConnectPromise = connectMcpServers(config.skills?.mcpServers ?? [], tools)
502
506
 
503
507
  // Initialize hook engine — register skill-defined hooks
504
508
  const hookEngine = new HookEngine()
@@ -562,6 +566,14 @@ export async function runApp(options: RunOptions): Promise<void> {
562
566
 
563
567
  // ── Cross-session messaging: register session, heartbeat, shutdown ──
564
568
  engine.setSessionId(sessionName)
569
+
570
+ // Surface MCP connect failures to the model once they settle, so it knows
571
+ // those servers' tools are unavailable (rather than silently concluding the
572
+ // tools don't exist). Injected asynchronously — never blocks first paint.
573
+ void mcpConnectPromise.then((failures) => {
574
+ const notice = formatMcpConnectFailures(failures)
575
+ if (notice) engine.getContext().addMessage({ role: 'user', content: notice })
576
+ })
565
577
  // Human-readable, cross-session-addressable name. Defaults to the cwd basename
566
578
  // so `@mipham-code` can address this session; uniqueness is enforced against
567
579
  // other live sessions (suffix -2, -3, …). The id stays a stable `session-<ts>`.
@@ -0,0 +1,15 @@
1
+ export interface McpConnectFailure {
2
+ name: string
3
+ reason: string
4
+ }
5
+
6
+ /**
7
+ * Format a startup notice for MCP servers that failed to connect, so the model
8
+ * knows their tools are unavailable (instead of concluding the tools don't
9
+ * exist). Empty string when nothing failed.
10
+ */
11
+ export function formatMcpConnectFailures(failures: McpConnectFailure[]): string {
12
+ if (failures.length === 0) return ''
13
+ const list = failures.map((f) => `- ${f.name}: ${f.reason}`).join('\n')
14
+ return `[MCP] These MCP servers failed to connect — their tools are unavailable:\n${list}`
15
+ }
@@ -26,7 +26,7 @@ export function readClaudeManifest(dir: string): ClaudeManifest | null {
26
26
  const path = join(dir, '.claude-plugin', 'plugin.json')
27
27
  if (!existsSync(path)) return null
28
28
  try {
29
- return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeManifest
29
+ return JSON.parse(readFileSync(path, 'utf-8').replace(/^\uFEFF/, '')) as ClaudeManifest
30
30
  } catch {
31
31
  return null
32
32
  }
@@ -52,7 +52,7 @@ export function validatePlugin(dir: string): PluginValidation {
52
52
 
53
53
  const errors: string[] = []
54
54
  try {
55
- const raw = readFileSync(resolved.path, 'utf-8')
55
+ const raw = readFileSync(resolved.path, 'utf-8').replace(/^\uFEFF/, '')
56
56
  const manifest = JSON.parse(raw) as PluginManifest
57
57
 
58
58
  if (!manifest.name || !/^[a-z0-9-]+$/.test(manifest.name)) {
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.62.0' as const
12
+ export const PACKAGE_VERSION = '0.63.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const