@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.
- package/bin/mipham.ts +35 -1
- package/package.json +1 -1
- package/src/agent/message-bus.ts +10 -3
- package/src/agent/sub-agent.ts +60 -12
- package/src/agent/types.ts +14 -1
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +7 -1
- package/src/config/loader.ts +202 -63
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/crsi-modify.ts +34 -1
- package/src/core/crsi-producer.ts +77 -7
- package/src/core/crsi-sandbox.ts +65 -0
- package/src/core/engine.ts +7 -2
- package/src/core/eval-harness.ts +77 -4
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- package/src/core/improvement-track.ts +41 -0
- package/src/core/paths.ts +44 -1
- package/src/core/permission-config.ts +146 -14
- package/src/core/permission-rules.ts +17 -2
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +5 -1
- package/src/core/workspace-trust.ts +42 -4
- package/src/daemon/auth.ts +15 -14
- package/src/daemon/engine-capabilities.ts +12 -2
- package/src/daemon/remote-engine.ts +9 -4
- package/src/daemon/server.ts +29 -1
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +44 -17
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +13 -2
- package/src/providers/anthropic.ts +48 -11
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +19 -1
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/exec/bash.ts +106 -6
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +76 -1
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +33 -3
- package/src/tools/index.ts +12 -4
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +205 -30
- 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) || ''
|
|
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) || ''
|
|
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)
|
|
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
|
-
|
|
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) {
|
package/src/ui/commands.ts
CHANGED
|
@@ -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()} /
|
|
371
|
-
${t('commands.context.usage_pct')} ${((tokens /
|
|
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
|
|
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()} /
|
|
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
|
|
416
|
-
const
|
|
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()} /
|
|
424
|
-
${t('commands.context_tokens.usage')} ${((tokens /
|
|
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 =
|
|
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()} /
|
|
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', {
|
|
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 /
|
|
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
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
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
|
-
|
|
3791
|
-
|
|
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 (
|
|
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,
|
|
3807
|
-
for (const entry of
|
|
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
|
-
|
|
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', {
|
|
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
|
-
|
|
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
|
-
|
|
4697
|
-
? `Disconnected. ${
|
|
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
|