@miphamai/cli 0.81.8 → 0.82.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.
Files changed (54) hide show
  1. package/bin/mipham.ts +35 -1
  2. package/package.json +1 -1
  3. package/src/agent/message-bus.ts +10 -3
  4. package/src/agent/sub-agent.ts +60 -12
  5. package/src/agent/types.ts +14 -1
  6. package/src/config/credential-crypto.ts +28 -5
  7. package/src/config/defaults.ts +18 -10
  8. package/src/config/keys-manager.ts +7 -1
  9. package/src/config/loader.ts +202 -63
  10. package/src/core/credential-masker/output-scrub.ts +16 -2
  11. package/src/core/crsi-modify.ts +34 -1
  12. package/src/core/crsi-producer.ts +77 -7
  13. package/src/core/crsi-sandbox.ts +65 -0
  14. package/src/core/engine.ts +7 -2
  15. package/src/core/eval-harness.ts +77 -4
  16. package/src/core/hooks-executor.ts +30 -2
  17. package/src/core/hooks.ts +51 -4
  18. package/src/core/improvement-track.ts +41 -0
  19. package/src/core/paths.ts +44 -1
  20. package/src/core/permission-config.ts +146 -14
  21. package/src/core/permission-rules.ts +17 -2
  22. package/src/core/permission.ts +81 -13
  23. package/src/core/rules-loader.ts +35 -5
  24. package/src/core/session-log.ts +5 -1
  25. package/src/core/workspace-trust.ts +42 -4
  26. package/src/daemon/auth.ts +15 -14
  27. package/src/daemon/engine-capabilities.ts +12 -2
  28. package/src/daemon/remote-engine.ts +9 -4
  29. package/src/daemon/server.ts +29 -1
  30. package/src/i18n-core/locales/en-US.json +12 -8
  31. package/src/i18n-core/locales/zh-CN.json +12 -8
  32. package/src/index.tsx +44 -17
  33. package/src/mcp/client.ts +24 -0
  34. package/src/mcp/http-transport.ts +35 -3
  35. package/src/plugin/plugin-manager.ts +13 -2
  36. package/src/providers/anthropic.ts +48 -11
  37. package/src/security/gate.ts +18 -0
  38. package/src/security/path.ts +19 -1
  39. package/src/shared/arg-validation.ts +37 -2
  40. package/src/shared/package-info.ts +1 -1
  41. package/src/shared/sanitize.ts +27 -2
  42. package/src/shared/types.ts +8 -0
  43. package/src/shared/update.ts +22 -5
  44. package/src/tools/agent/agent.ts +3 -0
  45. package/src/tools/exec/bash.ts +106 -6
  46. package/src/tools/exec/enter-worktree.ts +9 -3
  47. package/src/tools/exec/exit-worktree.ts +6 -3
  48. package/src/tools/exec/git.ts +76 -1
  49. package/src/tools/file/glob.ts +19 -3
  50. package/src/tools/file/grep.ts +33 -3
  51. package/src/tools/index.ts +12 -4
  52. package/src/ui/app.tsx +47 -11
  53. package/src/ui/commands.ts +205 -30
  54. package/src/workflow/primitives/agent.ts +4 -0
package/src/ui/app.tsx CHANGED
@@ -114,9 +114,46 @@ const PERMISSION_COLORS: Record<PermissionMode, string> = {
114
114
  bypassPermissions: 'red',
115
115
  }
116
116
 
117
+ /** 页脚读模式所需的最小面 —— `QueryEngine` 与 `RemoteEngine` 都满足。 */
118
+ export type PermissionSource = {
119
+ setMode(mode: PermissionMode): void
120
+ getMode(): PermissionMode
121
+ }
122
+
123
+ /**
124
+ * 页脚那一行是**执行的镜像,不是本地猜的值**。
125
+ *
126
+ * 组织级限制(`maxAllowedMode` / `forbiddenModes`)会在 `setMode` 里**静默改写**你请求的
127
+ * 那一档,所以页脚存「请求值」就会报出一个引擎不会给你的权限 —— 说放行、实际审批。
128
+ * 初始值与每次 Shift+Tab 都从这里回读。
129
+ */
130
+ export function livePermissionMode(permission: PermissionSource): PermissionMode {
131
+ return permission.getMode()
132
+ }
133
+
134
+ /** 走一档 Shift+Tab 循环,然后**回读**真正生效的模式(上限可能把这一档压回去)。 */
135
+ export function cyclePermissionMode(
136
+ permission: PermissionSource,
137
+ current: PermissionMode,
138
+ ): PermissionMode {
139
+ const idx = PERMISSION_MODES.indexOf(current)
140
+ const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
141
+ permission.setMode(next)
142
+ return permission.getMode()
143
+ }
144
+
145
+ /** 预览截断按 UTF-16 码元计数,落点若正好夹在一个代理对中间,切完就留下
146
+ * **半个** emoji(终端渲染成 U+FFFD)。落点是高代理时后退一个码元即可。 */
147
+ export function truncateForDisplay(text: string, max: number): string {
148
+ if (text.length <= max) return text
149
+ const last = text.charCodeAt(max - 1)
150
+ const end = last >= 0xd800 && last <= 0xdbff ? max - 1 : max
151
+ return text.slice(0, end)
152
+ }
153
+
117
154
  /** Format a tool's input parameters into a compact one-line detail string.
118
155
  * Tool display names follow Claude Code convention: Write/Edit → Update. */
119
- function formatToolDetail(name: string, input: Record<string, unknown>): string {
156
+ export function formatToolDetail(name: string, input: Record<string, unknown>): string {
120
157
  switch (name) {
121
158
  case 'Bash':
122
159
  return sanitizeForDisplay((input.command as string) || '')
@@ -125,13 +162,13 @@ function formatToolDetail(name: string, input: Record<string, unknown>): string
125
162
  case 'Write':
126
163
  return (input.file_path as string) || '' // displayed as "Update" in chat
127
164
  case 'Edit':
128
- return `${(input.file_path as string) || ''}: ${((input.old_string as string) || '').slice(0, 60)}` // displayed as "Update" in chat
165
+ return `${(input.file_path as string) || ''}: ${truncateForDisplay((input.old_string as string) || '', 60)}` // displayed as "Update" in chat
129
166
  case 'Grep':
130
167
  return (input.pattern as string) || ''
131
168
  case 'Glob':
132
169
  return (input.pattern as string) || ''
133
170
  case 'Agent':
134
- return `${(input.subagent_type as string) || 'general'}, "${((input.description as string) || (input.prompt as string) || '').slice(0, 80)}"`
171
+ return `${(input.subagent_type as string) || 'general'}, "${truncateForDisplay((input.description as string) || (input.prompt as string) || '', 80)}"`
135
172
  case 'WebSearch':
136
173
  return (input.query as string) || ''
137
174
  case 'WebFetch':
@@ -139,7 +176,7 @@ function formatToolDetail(name: string, input: Record<string, unknown>): string
139
176
  case 'Task':
140
177
  return `"${(input.subject as string) || ''}"`
141
178
  default:
142
- return JSON.stringify(input).slice(0, 80)
179
+ return truncateForDisplay(JSON.stringify(input), 80)
143
180
  }
144
181
  }
145
182
 
@@ -251,7 +288,11 @@ export function App({
251
288
  const [focusMode, setFocusMode] = useState(false)
252
289
  const [_ultracodeMode, setUltracodeMode] = useState(false)
253
290
  const [goalText, setGoalText] = useState('')
254
- const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
291
+ // 初始值取自 live 权限系统 —— 启动时 config 的模式可能已被组织级限制钳到别处
292
+ // (页脚写死 'default' 时会与执行不一致,且偏差方向是「报得比实际宽」)。
293
+ const [permissionMode, setPermissionMode] = useState<PermissionMode>(() =>
294
+ livePermissionMode(engine.getPermission()),
295
+ )
255
296
  const abortRef = useRef<AbortController | null>(null)
256
297
  // Monotonic turn id — lets a stale turn's finally() skip resetting shared UI
257
298
  // state (isLoading/abortRef/progress) after a newer turn has already started.
@@ -1239,12 +1280,7 @@ export function App({
1239
1280
  })
1240
1281
  }}
1241
1282
  onCyclePermission={() => {
1242
- setPermissionMode((prev) => {
1243
- const idx = PERMISSION_MODES.indexOf(prev)
1244
- const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
1245
- engine.getPermission().setMode(next)
1246
- return next
1247
- })
1283
+ setPermissionMode((prev) => cyclePermissionMode(engine.getPermission(), prev))
1248
1284
  }}
1249
1285
  onCancel={() => {
1250
1286
  if (abortRef.current) {
@@ -13,6 +13,7 @@ import type { PluginManager } from '../plugin/plugin-manager'
13
13
  import type { Message } from '../shared/types.js'
14
14
  import type { UpdateStatus } from '../shared/update'
15
15
  import { McpClient } from '../mcp/client'
16
+ import { unregisterMcpServerTools } from '../mcp/registry'
16
17
  import { buildCapabilityReport } from '../core/capability-inventory'
17
18
  import { InstructionsLoader } from '../core/instructions'
18
19
  import { findDerivableSections, DERIVABLE_HINTS } from '../core/claude-md-audit'
@@ -49,6 +50,7 @@ import {
49
50
  appendImprovement,
50
51
  readImprovements,
51
52
  improvementRate,
53
+ predictionHitRate,
52
54
  setPendingVerdict,
53
55
  getPendingVerdict,
54
56
  shouldBlockApproval,
@@ -56,6 +58,7 @@ import {
56
58
  import { NPM_UPDATE_COMMAND, PACKAGE_VERSION, COAUTHOR_TRAILER } from '../shared/index.ts'
57
59
  import { getPreference } from '../config/preferences'
58
60
  import { loadCrossSessionConfig, tryRestoreFromBackup } from '../config/loader'
61
+ import { getWorkspaceTrust } from '../core/workspace-trust'
59
62
  import { getMemoryManager } from '../core/memory/memory-loader'
60
63
  import {
61
64
  resolveTelemetry,
@@ -363,14 +366,18 @@ const contextCmd: CommandHandler = (ctx) => {
363
366
  const tokens = c.getEstimatedTokens()
364
367
  const msgs = c.getMessages()
365
368
  const systemPromptLen = c.getSystemPrompt().length
369
+ // 窗口与阈值一律问引擎:模型注册表里 1M/256K/128K/32K 都有,写死 200K 会
370
+ // 让 1M 模型显示约 5 倍偏高的百分比,并把压缩点说成 90%(实际 95%)。
371
+ const maxTokens = c.getMaxTokens()
372
+ const threshold = c.getCompactionThreshold()
366
373
  return {
367
374
  content: stripIndent`
368
375
  ${t('commands.context.title')}
369
376
  ${t('commands.context.messages')} ${msgs.length}
370
- ${t('commands.context.estimated_tokens')} ${tokens.toLocaleString()} / 200,000
371
- ${t('commands.context.usage_pct')} ${((tokens / 200_000) * 100).toFixed(1)}%
377
+ ${t('commands.context.estimated_tokens')} ${tokens.toLocaleString()} / ${maxTokens.toLocaleString()}
378
+ ${t('commands.context.usage_pct')} ${((tokens / maxTokens) * 100).toFixed(1)}%
372
379
  ${t('commands.context.system_prompt')} ${systemPromptLen.toLocaleString()} chars (~${Math.ceil(systemPromptLen / 4).toLocaleString()} tokens)
373
- ${t('commands.context.compaction')} at 90% (${(200_000 * 0.9).toLocaleString()} tokens)
380
+ ${t('commands.context.compaction')} at ${(threshold * 100).toFixed(0)}% (${Math.round(maxTokens * threshold).toLocaleString()} tokens)
374
381
  `,
375
382
  }
376
383
  }
@@ -395,7 +402,7 @@ const statusCmd: CommandHandler = async (ctx) => {
395
402
  ${t('commands.status.provider')} ${ctx.providerId}
396
403
  ${t('commands.status.model')} ${ctx.modelId}
397
404
  ${t('commands.status.messages')} ${c.getMessages().length}
398
- ${t('commands.status.tokens')} ~${c.getEstimatedTokens().toLocaleString()} / 200,000
405
+ ${t('commands.status.tokens')} ~${c.getEstimatedTokens().toLocaleString()} / ${c.getMaxTokens().toLocaleString()}
399
406
  ${t('commands.status.tools')} ${tools.size} ${t('commands.status.loaded')}
400
407
  ${t('commands.status.permission')} ${ctx.config.permission}
401
408
 
@@ -412,16 +419,18 @@ const statusCmd: CommandHandler = async (ctx) => {
412
419
 
413
420
  const costCmd: CommandHandler = (ctx) => {
414
421
  const t = resolveT(ctx)
415
- const tokens = ctx.engine.getContext().getEstimatedTokens()
416
- const cacheStatus = ctx.engine.getContext().getCacheStatus()
422
+ const c = ctx.engine.getContext()
423
+ const tokens = c.getEstimatedTokens()
424
+ const cacheStatus = c.getCacheStatus()
417
425
  const cachedTokens = cacheStatus.cachedTokens
418
426
  const hitRatio = tokens > 0 ? Math.min(1, cachedTokens / tokens) : 0
419
427
  const uncachedTokens = Math.max(0, tokens - cachedTokens)
428
+ const maxTokens = c.getMaxTokens()
420
429
  return {
421
430
  content: stripIndent`
422
431
  ${t('commands.context_tokens.title')}
423
- ${t('commands.context_tokens.context_tokens')} ~${tokens.toLocaleString()} / 200,000
424
- ${t('commands.context_tokens.usage')} ${((tokens / 200_000) * 100).toFixed(1)}%
432
+ ${t('commands.context_tokens.context_tokens')} ~${tokens.toLocaleString()} / ${maxTokens.toLocaleString()}
433
+ ${t('commands.context_tokens.usage')} ${((tokens / maxTokens) * 100).toFixed(1)}%
425
434
  ${t('commands.context_tokens.prompt_cache', {
426
435
  cached: cachedTokens.toLocaleString(),
427
436
  ratio: (hitRatio * 100).toFixed(1),
@@ -796,6 +805,25 @@ const crsiStatsCmd: CommandHandler = async (ctx) => {
796
805
  }
797
806
  }
798
807
 
808
+ // ── ε 预测命中(prose 路径) ──
809
+ // 作废条款:样本不足就不下结论;台账攒够 20 条而判定样本仍 < 5 ⇒ 明写机制失效。
810
+ // 只打印、不写台账:本命令是只读的,而该结论每次都能从同一份 improvements.jsonl 重算出来。
811
+ const records = readImprovements()
812
+ const pred = predictionHitRate(records)
813
+ lines.push('')
814
+ lines.push('### ε 预测命中(prose 路径)')
815
+ if (pred.total < 5) {
816
+ lines.push(`样本不足(判定记录 ${pred.total} 条,需 ≥ 5)—— 不下结论。`)
817
+ if (records.length >= 20) {
818
+ lines.push('⚠️ ε 机制失效:prose 路径使用率过低(记录总数已达 20 而判定样本仍 < 5)。')
819
+ }
820
+ } else {
821
+ lines.push(
822
+ `命中率: ${pred.hits}/${pred.total} (${(pred.rate * 100).toFixed(0)}%, ` +
823
+ `Wilson 95% [${(pred.lo * 100).toFixed(0)}%, ${(pred.hi * 100).toFixed(0)}%])`,
824
+ )
825
+ }
826
+
799
827
  return { content: lines.join('\n') }
800
828
  }
801
829
 
@@ -962,11 +990,36 @@ const crsiProposeCmd: CommandHandler = async (ctx, args) => {
962
990
  return { content: `❌ 生成失败(phase: ${result.phase})。\n${result.error ?? ''}` }
963
991
  }
964
992
 
993
+ // ε 预登记落地:prose 路径此前**不测量**(measureSkillDeltaRepeated 全仓库只有手工路径一个
994
+ // 调用点)⇒ ε 曾在 A 流程登记、判定侧在 B 流程,两端永不相遇。这里补上测量。
995
+ // 成本:每次提案多 6 次 LLM 调用(同手工路径 :867 的注释)。
996
+ let predictionLine = ''
997
+ try {
998
+ const sample = await measureSkillDeltaRepeated(llm, {
999
+ filePath: proposal.filePath,
1000
+ originalContent: proposal.originalContent,
1001
+ newContent: proposal.newContent,
1002
+ })
1003
+ if (sample) {
1004
+ const report = buildImprovementReport(sample, [proposal.filePath], proposal.expectedEffect)
1005
+ setPendingVerdict(report.verdict)
1006
+ appendImprovement({ ...report, id: randomUUID(), timestamp: new Date().toISOString() })
1007
+ if (report.predictionHit !== undefined) {
1008
+ predictionLine =
1009
+ `\n🎯 ε 预测命中: ${report.predictionHit ? '命中 ✅' : '未命中 ⚠️'}` +
1010
+ `(预测 ${report.predictedDelta},实际 delta ${report.deltaMean.toFixed(1)})`
1011
+ }
1012
+ }
1013
+ } catch {
1014
+ // 测量失败(LLM 不可用等)不阻断提案流程 —— 与手工路径 :889 的处置一致。
1015
+ }
1016
+
965
1017
  appendProseProposal({ id, filePath: proposal.filePath, timestamp: new Date().toISOString() })
966
1018
 
967
1019
  return {
968
1020
  content:
969
1021
  `✅ 已生成散文提议并跑过测试。审阅 diff:\n\n${result.diff}\n\n` +
1022
+ predictionLine +
970
1023
  '/crsi modify --approve 合并 | /crsi modify --reject 丢弃',
971
1024
  }
972
1025
  }
@@ -2257,7 +2310,7 @@ const usageCmd: CommandHandler = (ctx) => {
2257
2310
  const c = ctx.engine.getContext()
2258
2311
  const estTokens = c.getEstimatedTokens()
2259
2312
  const msgs = c.getMessages()
2260
- const maxTokens = 200_000
2313
+ const maxTokens = c.getMaxTokens()
2261
2314
  const pct = ((estTokens / maxTokens) * 100).toFixed(1)
2262
2315
 
2263
2316
  const tracker = ctx.engine.getUsageTracker()
@@ -3133,9 +3186,10 @@ const doctorCmd: CommandHandler = async (ctx) => {
3133
3186
  const c = ctx.engine.getContext()
3134
3187
  const msgs = c.getMessages()
3135
3188
  const tokens = c.getEstimatedTokens()
3189
+ const maxTokens = c.getMaxTokens()
3136
3190
  lines.push(`Messages ${msgs.length}`)
3137
3191
  lines.push(
3138
- `Tokens ~${tokens.toLocaleString()} / 200,000 (${((tokens / 200_000) * 100).toFixed(1)}%)`,
3192
+ `Tokens ~${tokens.toLocaleString()} / ${maxTokens.toLocaleString()} (${((tokens / maxTokens) * 100).toFixed(1)}%)`,
3139
3193
  )
3140
3194
  lines.push(`Checkpoints ${c.getCheckpoints().length}`)
3141
3195
 
@@ -3637,13 +3691,16 @@ const statsCmd: CommandHandler = (ctx) => {
3637
3691
  assistant: String(assistantMsgs),
3638
3692
  system: String(systemMsgs),
3639
3693
  }),
3640
- t('commands.stats.tokens', { tokens: tokens.toLocaleString() }),
3694
+ t('commands.stats.tokens', {
3695
+ tokens: tokens.toLocaleString(),
3696
+ max: c.getMaxTokens().toLocaleString(),
3697
+ }),
3641
3698
  t('commands.stats.tools', { count: String(tools.size) }),
3642
3699
  t('commands.stats.provider', { provider: ctx.providerId }),
3643
3700
  t('commands.stats.model', { model: ctx.modelId }),
3644
3701
  t('commands.stats.permission', { permission: ctx.config.permission }),
3645
3702
  '',
3646
- t('commands.stats.usage', { pct: ((tokens / 200_000) * 100).toFixed(1) }),
3703
+ t('commands.stats.usage', { pct: ((tokens / c.getMaxTokens()) * 100).toFixed(1) }),
3647
3704
  ]
3648
3705
 
3649
3706
  // ── CRSI & SIS extensions ──
@@ -3780,18 +3837,42 @@ const cdCmd: CommandHandler = async (ctx, args) => {
3780
3837
  const hooksCmd: CommandHandler = async (ctx) => {
3781
3838
  const t = resolveT(ctx)
3782
3839
  const { loadSettingsJson } = await import('../config/loader')
3783
- const settingsJson = loadSettingsJson(process.cwd())
3784
-
3785
- const hooks = settingsJson.hooks as Record<
3786
- string,
3787
- Array<{ matcher?: string; hooks: Array<{ type: string; command?: string }> }>
3788
- >
3840
+ const cwd = process.cwd()
3841
+ // This command *displays* the configured list, so it asks for the project file
3842
+ // explicitly — the loader's default drops project hooks (they are
3843
+ // repository-controlled code execution) and would hide hooks that really do
3844
+ // exist here.
3845
+ //
3846
+ // Two calls, one per source: the merged list cannot say which entry came from
3847
+ // which file, and the two are not governed alike — only the project file's
3848
+ // entries are gated on workspace trust.
3849
+ const userHooks = loadSettingsJson(cwd).hooks
3850
+ const projectHooks = loadSettingsJson(cwd, { includeProjectHooks: true }).projectHooks ?? {}
3851
+
3852
+ // Listing them is not the same claim as their running. Project hooks are
3853
+ // gated, and this command renders them even when the gate is shut — so the
3854
+ // display has to name the source and say which entries will not run.
3855
+ const gated = !getWorkspaceTrust().isTrusted(cwd)
3856
+
3857
+ type Entry = { matcher?: string; hooks: Array<{ type: string; command?: string }> }
3858
+ const sources: Array<{ project: boolean; hooks: Record<string, Entry[]> }> = [
3859
+ { project: true, hooks: projectHooks as Record<string, Entry[]> },
3860
+ { project: false, hooks: userHooks as Record<string, Entry[]> },
3861
+ ]
3789
3862
 
3790
- const configured = Object.entries(hooks).filter(
3791
- ([, entries]) => Array.isArray(entries) && entries.length > 0,
3792
- )
3863
+ // Event → its entries, project first — the order the merged list produced, so
3864
+ // a trusted workspace's output is unchanged by the split.
3865
+ const byEvent = new Map<string, Array<{ entry: Entry; project: boolean }>>()
3866
+ for (const { project, hooks } of sources) {
3867
+ for (const [eventName, entries] of Object.entries(hooks)) {
3868
+ if (!Array.isArray(entries) || entries.length === 0) continue
3869
+ const bucket = byEvent.get(eventName) ?? []
3870
+ for (const entry of entries) bucket.push({ entry, project })
3871
+ byEvent.set(eventName, bucket)
3872
+ }
3873
+ }
3793
3874
 
3794
- if (configured.length === 0) {
3875
+ if (byEvent.size === 0) {
3795
3876
  return { content: t('commands.hooks.no_hooks') }
3796
3877
  }
3797
3878
 
@@ -3803,13 +3884,15 @@ const hooksCmd: CommandHandler = async (ctx) => {
3803
3884
  ]
3804
3885
 
3805
3886
  let count = 0
3806
- for (const [eventName, entries] of configured) {
3807
- for (const entry of entries) {
3887
+ for (const [eventName, bucket] of byEvent) {
3888
+ for (const { entry, project } of bucket) {
3808
3889
  for (const hook of entry.hooks) {
3809
3890
  count++
3810
3891
  const matcher = entry.matcher || '*'
3811
3892
  const cmd = hook.type === 'command' && hook.command ? hook.command : hook.type
3812
- lines.push(` ${eventName} [${matcher}] → ${cmd}`)
3893
+ const source = t(project ? 'commands.hooks.source_project' : 'commands.hooks.source_user')
3894
+ const suffix = project && gated ? ` ${t('commands.hooks.gated')}` : ''
3895
+ lines.push(` [${source}] ${eventName} [${matcher}] → ${cmd}${suffix}`)
3813
3896
  }
3814
3897
  }
3815
3898
  }
@@ -4092,7 +4175,12 @@ const resumeLastCmd: CommandHandler = async (ctx) => {
4092
4175
  loaded: String(messages.length),
4093
4176
  total: String(session.messages.length),
4094
4177
  })
4095
- : t('commands.resume.restored_full_footer', { loaded: String(messages.length) }),
4178
+ : t('commands.resume.restored_full_footer', {
4179
+ loaded: String(messages.length),
4180
+ // 文案里的 `{name}` 必须真给:`t()` 把没给的占位符替换成**空串**
4181
+ // (`t.ts` 的 `params[k] ?? ''`),漏掉就静默印出 `--resume ""`。
4182
+ name: latest.name,
4183
+ }),
4096
4184
  ].join('\n'),
4097
4185
  forwardedMessages: messages,
4098
4186
  resumeWarning: true,
@@ -4319,6 +4407,16 @@ const upgradeCmd: CommandHandler = async (ctx) => {
4319
4407
 
4320
4408
  const update = checkForUpdates()
4321
4409
 
4410
+ // The registry was never reached. "Already up to date" would be a claim we
4411
+ // cannot support: `checkForUpdates` leaves `latest === current` on failure, so
4412
+ // the old code printed "✓ Already up to date (v0.81.8 → v0.81.8)" while
4413
+ // offline and gave no hint that a check had even been attempted.
4414
+ // (`mipham update` on the CLI path already says "Could not determine latest
4415
+ // version" — this is the TUI's equivalent.)
4416
+ if (!update.checked) {
4417
+ return { content: t('commands.upgrade.check_failed', { current: update.current }) }
4418
+ }
4419
+
4322
4420
  if (!update.available) {
4323
4421
  return {
4324
4422
  content: t('commands.upgrade.uptodate', { current: update.current, latest: update.latest }),
@@ -4688,18 +4786,51 @@ const mcpCmd: CommandHandler = async (ctx, args) => {
4688
4786
  if (sub === 'disconnect') {
4689
4787
  const name = args[1]
4690
4788
  if (!name) return { content: 'Usage: /mcp disconnect <server-name>' }
4691
- const tools = client.disconnect(name)
4789
+ client.disconnect(name)
4790
+ // Dropping the connection is not dropping the tools. They live in the
4791
+ // engine's registry — the map the model actually calls — and closing the
4792
+ // transport leaves every one of them registered and selectable, failing
4793
+ // only at call time. Count what the registry actually gave up, not what the
4794
+ // connection used to hold: those are different objects and only one of them
4795
+ // is what "removed" refers to.
4796
+ const toolsMap = ctx.engine.getTools()
4797
+ const before = toolsMap.size
4798
+ unregisterMcpServerTools(name, toolsMap)
4799
+ const removed = before - toolsMap.size
4692
4800
  return {
4693
4801
  content: [
4694
4802
  `── MCP Disconnect: ${name} ──`,
4695
4803
  '',
4696
- tools.length > 0
4697
- ? `Disconnected. ${tools.length} tool(s) removed.`
4804
+ removed > 0
4805
+ ? `Disconnected. ${removed} tool(s) removed.`
4698
4806
  : 'Disconnected (no tools were registered).',
4699
4807
  ].join('\n'),
4700
4808
  }
4701
4809
  }
4702
4810
 
4811
+ // /mcp reconnect <name>
4812
+ if (sub === 'reconnect') {
4813
+ const name = args[1]
4814
+ if (!name) return { content: 'Usage: /mcp reconnect <server-name>' }
4815
+ try {
4816
+ await client.reconnect(name)
4817
+ const count = client.getTools(name).length
4818
+ return {
4819
+ content: `── MCP Reconnect: ${name} ──\n\nReconnected — ${count} tool(s) rediscovered.`,
4820
+ }
4821
+ } catch (err) {
4822
+ return {
4823
+ content: [
4824
+ `── MCP Reconnect: ${name} ──`,
4825
+ '',
4826
+ `Failed: ${String(err)}`,
4827
+ '',
4828
+ `The server stayed disconnected. Check that it is reachable, then retry — or use /mcp connect ${name}.`,
4829
+ ].join('\n'),
4830
+ }
4831
+ }
4832
+ }
4833
+
4703
4834
  // /mcp reload
4704
4835
  if (sub === 'reload') {
4705
4836
  return {
@@ -4749,6 +4880,7 @@ const mcpCmd: CommandHandler = async (ctx, args) => {
4749
4880
  lines.push('── Commands ──')
4750
4881
  lines.push(' /mcp connect <name> Connect to a server (OAuth or stdio)')
4751
4882
  lines.push(' /mcp disconnect <name> Disconnect from a server')
4883
+ lines.push(' /mcp reconnect <name> Reconnect a dropped server (with backoff)')
4752
4884
  lines.push(' /mcp reload Disconnect all and reconnect')
4753
4885
  lines.push('')
4754
4886
  lines.push('── Protocol ──')
@@ -4994,7 +5126,7 @@ Or use the Agent tool in a conversation to launch a sub-agent.`,
4994
5126
  return { content: lines.join('\n') }
4995
5127
  }
4996
5128
 
4997
- const bgCmd: CommandHandler = (ctx, args) => {
5129
+ const bgCmd: CommandHandler = async (ctx, args) => {
4998
5130
  const prompt = args.join(' ')
4999
5131
  if (!prompt.trim()) {
5000
5132
  return {
@@ -5023,6 +5155,49 @@ Background agents appear in the Agent View dashboard (/agents).`,
5023
5155
 
5024
5156
  agentViewManager.addMessage(session.id, { role: 'user', content: prompt })
5025
5157
  agentViewManager.updateStatus(session.id, 'working')
5158
+ session.kind = 'unattended'
5159
+
5160
+ // A session row is not a task. Without the spawn below this command only ever
5161
+ // wrote a dashboard entry and reported success — the prompt was never handed
5162
+ // to a model, and the row sat at `working` forever.
5163
+ const bgReg = (await import('../agent/background-registry')).getBackgroundAgentRegistry()
5164
+ bgReg.spawn(
5165
+ prompt,
5166
+ 'general',
5167
+ async (_signal) => {
5168
+ const { SubAgent } = await import('../agent/sub-agent')
5169
+ const sa = new SubAgent(
5170
+ ctx.engine.getRegistry(),
5171
+ ctx.engine.getTools(),
5172
+ ctx.engine.getPermission(),
5173
+ undefined,
5174
+ undefined,
5175
+ ctx.engine.getLlm(),
5176
+ )
5177
+ try {
5178
+ // The prompt came from the user typing `/bg …`, so it stays unframed —
5179
+ // unlike a workflow script's computed prompt.
5180
+ const result = await sa.execute(prompt, 'bg: ' + prompt.slice(0, 60), { type: 'general' })
5181
+ agentViewManager.addMessage(session.id, {
5182
+ role: 'assistant',
5183
+ content: result || '(no output)',
5184
+ })
5185
+ agentViewManager.updateStatus(session.id, 'completed')
5186
+ return result
5187
+ } catch (err) {
5188
+ // Leave the row honest on failure too — a session stuck at `working`
5189
+ // after its executor died is the same lie as never spawning at all.
5190
+ const message = err instanceof Error ? err.message : String(err)
5191
+ agentViewManager.addMessage(session.id, {
5192
+ role: 'assistant',
5193
+ content: `Background agent failed: ${message}`,
5194
+ })
5195
+ agentViewManager.updateStatus(session.id, 'failed')
5196
+ throw err
5197
+ }
5198
+ },
5199
+ 'unattended',
5200
+ )
5026
5201
 
5027
5202
  return {
5028
5203
  content: `✓ Background agent spawned: ${session.id}
@@ -108,6 +108,10 @@ export async function workflowAgent(
108
108
  modelOverride: opts.model,
109
109
  allowedTools: undefined, // use all tools by default
110
110
  worktreePath,
111
+ // The prompt is computed by the script, not typed by the user — say so,
112
+ // so text the script relayed from a file or another agent cannot pass
113
+ // as the user's own opening instruction.
114
+ promptOrigin: 'script',
111
115
  })
112
116
 
113
117
  lastResult = textResult