@miphamai/cli 0.81.8 → 0.81.9
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/engine.ts +7 -2
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- 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 +160 -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'
|
|
@@ -56,6 +57,7 @@ import {
|
|
|
56
57
|
import { NPM_UPDATE_COMMAND, PACKAGE_VERSION, COAUTHOR_TRAILER } from '../shared/index.ts'
|
|
57
58
|
import { getPreference } from '../config/preferences'
|
|
58
59
|
import { loadCrossSessionConfig, tryRestoreFromBackup } from '../config/loader'
|
|
60
|
+
import { getWorkspaceTrust } from '../core/workspace-trust'
|
|
59
61
|
import { getMemoryManager } from '../core/memory/memory-loader'
|
|
60
62
|
import {
|
|
61
63
|
resolveTelemetry,
|
|
@@ -363,14 +365,18 @@ const contextCmd: CommandHandler = (ctx) => {
|
|
|
363
365
|
const tokens = c.getEstimatedTokens()
|
|
364
366
|
const msgs = c.getMessages()
|
|
365
367
|
const systemPromptLen = c.getSystemPrompt().length
|
|
368
|
+
// 窗口与阈值一律问引擎:模型注册表里 1M/256K/128K/32K 都有,写死 200K 会
|
|
369
|
+
// 让 1M 模型显示约 5 倍偏高的百分比,并把压缩点说成 90%(实际 95%)。
|
|
370
|
+
const maxTokens = c.getMaxTokens()
|
|
371
|
+
const threshold = c.getCompactionThreshold()
|
|
366
372
|
return {
|
|
367
373
|
content: stripIndent`
|
|
368
374
|
${t('commands.context.title')}
|
|
369
375
|
${t('commands.context.messages')} ${msgs.length}
|
|
370
|
-
${t('commands.context.estimated_tokens')} ${tokens.toLocaleString()} /
|
|
371
|
-
${t('commands.context.usage_pct')} ${((tokens /
|
|
376
|
+
${t('commands.context.estimated_tokens')} ${tokens.toLocaleString()} / ${maxTokens.toLocaleString()}
|
|
377
|
+
${t('commands.context.usage_pct')} ${((tokens / maxTokens) * 100).toFixed(1)}%
|
|
372
378
|
${t('commands.context.system_prompt')} ${systemPromptLen.toLocaleString()} chars (~${Math.ceil(systemPromptLen / 4).toLocaleString()} tokens)
|
|
373
|
-
${t('commands.context.compaction')} at
|
|
379
|
+
${t('commands.context.compaction')} at ${(threshold * 100).toFixed(0)}% (${Math.round(maxTokens * threshold).toLocaleString()} tokens)
|
|
374
380
|
`,
|
|
375
381
|
}
|
|
376
382
|
}
|
|
@@ -395,7 +401,7 @@ const statusCmd: CommandHandler = async (ctx) => {
|
|
|
395
401
|
${t('commands.status.provider')} ${ctx.providerId}
|
|
396
402
|
${t('commands.status.model')} ${ctx.modelId}
|
|
397
403
|
${t('commands.status.messages')} ${c.getMessages().length}
|
|
398
|
-
${t('commands.status.tokens')} ~${c.getEstimatedTokens().toLocaleString()} /
|
|
404
|
+
${t('commands.status.tokens')} ~${c.getEstimatedTokens().toLocaleString()} / ${c.getMaxTokens().toLocaleString()}
|
|
399
405
|
${t('commands.status.tools')} ${tools.size} ${t('commands.status.loaded')}
|
|
400
406
|
${t('commands.status.permission')} ${ctx.config.permission}
|
|
401
407
|
|
|
@@ -412,16 +418,18 @@ const statusCmd: CommandHandler = async (ctx) => {
|
|
|
412
418
|
|
|
413
419
|
const costCmd: CommandHandler = (ctx) => {
|
|
414
420
|
const t = resolveT(ctx)
|
|
415
|
-
const
|
|
416
|
-
const
|
|
421
|
+
const c = ctx.engine.getContext()
|
|
422
|
+
const tokens = c.getEstimatedTokens()
|
|
423
|
+
const cacheStatus = c.getCacheStatus()
|
|
417
424
|
const cachedTokens = cacheStatus.cachedTokens
|
|
418
425
|
const hitRatio = tokens > 0 ? Math.min(1, cachedTokens / tokens) : 0
|
|
419
426
|
const uncachedTokens = Math.max(0, tokens - cachedTokens)
|
|
427
|
+
const maxTokens = c.getMaxTokens()
|
|
420
428
|
return {
|
|
421
429
|
content: stripIndent`
|
|
422
430
|
${t('commands.context_tokens.title')}
|
|
423
|
-
${t('commands.context_tokens.context_tokens')} ~${tokens.toLocaleString()} /
|
|
424
|
-
${t('commands.context_tokens.usage')} ${((tokens /
|
|
431
|
+
${t('commands.context_tokens.context_tokens')} ~${tokens.toLocaleString()} / ${maxTokens.toLocaleString()}
|
|
432
|
+
${t('commands.context_tokens.usage')} ${((tokens / maxTokens) * 100).toFixed(1)}%
|
|
425
433
|
${t('commands.context_tokens.prompt_cache', {
|
|
426
434
|
cached: cachedTokens.toLocaleString(),
|
|
427
435
|
ratio: (hitRatio * 100).toFixed(1),
|
|
@@ -2257,7 +2265,7 @@ const usageCmd: CommandHandler = (ctx) => {
|
|
|
2257
2265
|
const c = ctx.engine.getContext()
|
|
2258
2266
|
const estTokens = c.getEstimatedTokens()
|
|
2259
2267
|
const msgs = c.getMessages()
|
|
2260
|
-
const maxTokens =
|
|
2268
|
+
const maxTokens = c.getMaxTokens()
|
|
2261
2269
|
const pct = ((estTokens / maxTokens) * 100).toFixed(1)
|
|
2262
2270
|
|
|
2263
2271
|
const tracker = ctx.engine.getUsageTracker()
|
|
@@ -3133,9 +3141,10 @@ const doctorCmd: CommandHandler = async (ctx) => {
|
|
|
3133
3141
|
const c = ctx.engine.getContext()
|
|
3134
3142
|
const msgs = c.getMessages()
|
|
3135
3143
|
const tokens = c.getEstimatedTokens()
|
|
3144
|
+
const maxTokens = c.getMaxTokens()
|
|
3136
3145
|
lines.push(`Messages ${msgs.length}`)
|
|
3137
3146
|
lines.push(
|
|
3138
|
-
`Tokens ~${tokens.toLocaleString()} /
|
|
3147
|
+
`Tokens ~${tokens.toLocaleString()} / ${maxTokens.toLocaleString()} (${((tokens / maxTokens) * 100).toFixed(1)}%)`,
|
|
3139
3148
|
)
|
|
3140
3149
|
lines.push(`Checkpoints ${c.getCheckpoints().length}`)
|
|
3141
3150
|
|
|
@@ -3637,13 +3646,16 @@ const statsCmd: CommandHandler = (ctx) => {
|
|
|
3637
3646
|
assistant: String(assistantMsgs),
|
|
3638
3647
|
system: String(systemMsgs),
|
|
3639
3648
|
}),
|
|
3640
|
-
t('commands.stats.tokens', {
|
|
3649
|
+
t('commands.stats.tokens', {
|
|
3650
|
+
tokens: tokens.toLocaleString(),
|
|
3651
|
+
max: c.getMaxTokens().toLocaleString(),
|
|
3652
|
+
}),
|
|
3641
3653
|
t('commands.stats.tools', { count: String(tools.size) }),
|
|
3642
3654
|
t('commands.stats.provider', { provider: ctx.providerId }),
|
|
3643
3655
|
t('commands.stats.model', { model: ctx.modelId }),
|
|
3644
3656
|
t('commands.stats.permission', { permission: ctx.config.permission }),
|
|
3645
3657
|
'',
|
|
3646
|
-
t('commands.stats.usage', { pct: ((tokens /
|
|
3658
|
+
t('commands.stats.usage', { pct: ((tokens / c.getMaxTokens()) * 100).toFixed(1) }),
|
|
3647
3659
|
]
|
|
3648
3660
|
|
|
3649
3661
|
// ── CRSI & SIS extensions ──
|
|
@@ -3780,18 +3792,42 @@ const cdCmd: CommandHandler = async (ctx, args) => {
|
|
|
3780
3792
|
const hooksCmd: CommandHandler = async (ctx) => {
|
|
3781
3793
|
const t = resolveT(ctx)
|
|
3782
3794
|
const { loadSettingsJson } = await import('../config/loader')
|
|
3783
|
-
const
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3795
|
+
const cwd = process.cwd()
|
|
3796
|
+
// This command *displays* the configured list, so it asks for the project file
|
|
3797
|
+
// explicitly — the loader's default drops project hooks (they are
|
|
3798
|
+
// repository-controlled code execution) and would hide hooks that really do
|
|
3799
|
+
// exist here.
|
|
3800
|
+
//
|
|
3801
|
+
// Two calls, one per source: the merged list cannot say which entry came from
|
|
3802
|
+
// which file, and the two are not governed alike — only the project file's
|
|
3803
|
+
// entries are gated on workspace trust.
|
|
3804
|
+
const userHooks = loadSettingsJson(cwd).hooks
|
|
3805
|
+
const projectHooks = loadSettingsJson(cwd, { includeProjectHooks: true }).projectHooks ?? {}
|
|
3806
|
+
|
|
3807
|
+
// Listing them is not the same claim as their running. Project hooks are
|
|
3808
|
+
// gated, and this command renders them even when the gate is shut — so the
|
|
3809
|
+
// display has to name the source and say which entries will not run.
|
|
3810
|
+
const gated = !getWorkspaceTrust().isTrusted(cwd)
|
|
3811
|
+
|
|
3812
|
+
type Entry = { matcher?: string; hooks: Array<{ type: string; command?: string }> }
|
|
3813
|
+
const sources: Array<{ project: boolean; hooks: Record<string, Entry[]> }> = [
|
|
3814
|
+
{ project: true, hooks: projectHooks as Record<string, Entry[]> },
|
|
3815
|
+
{ project: false, hooks: userHooks as Record<string, Entry[]> },
|
|
3816
|
+
]
|
|
3789
3817
|
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
)
|
|
3818
|
+
// Event → its entries, project first — the order the merged list produced, so
|
|
3819
|
+
// a trusted workspace's output is unchanged by the split.
|
|
3820
|
+
const byEvent = new Map<string, Array<{ entry: Entry; project: boolean }>>()
|
|
3821
|
+
for (const { project, hooks } of sources) {
|
|
3822
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
3823
|
+
if (!Array.isArray(entries) || entries.length === 0) continue
|
|
3824
|
+
const bucket = byEvent.get(eventName) ?? []
|
|
3825
|
+
for (const entry of entries) bucket.push({ entry, project })
|
|
3826
|
+
byEvent.set(eventName, bucket)
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3793
3829
|
|
|
3794
|
-
if (
|
|
3830
|
+
if (byEvent.size === 0) {
|
|
3795
3831
|
return { content: t('commands.hooks.no_hooks') }
|
|
3796
3832
|
}
|
|
3797
3833
|
|
|
@@ -3803,13 +3839,15 @@ const hooksCmd: CommandHandler = async (ctx) => {
|
|
|
3803
3839
|
]
|
|
3804
3840
|
|
|
3805
3841
|
let count = 0
|
|
3806
|
-
for (const [eventName,
|
|
3807
|
-
for (const entry of
|
|
3842
|
+
for (const [eventName, bucket] of byEvent) {
|
|
3843
|
+
for (const { entry, project } of bucket) {
|
|
3808
3844
|
for (const hook of entry.hooks) {
|
|
3809
3845
|
count++
|
|
3810
3846
|
const matcher = entry.matcher || '*'
|
|
3811
3847
|
const cmd = hook.type === 'command' && hook.command ? hook.command : hook.type
|
|
3812
|
-
|
|
3848
|
+
const source = t(project ? 'commands.hooks.source_project' : 'commands.hooks.source_user')
|
|
3849
|
+
const suffix = project && gated ? ` ${t('commands.hooks.gated')}` : ''
|
|
3850
|
+
lines.push(` [${source}] ${eventName} [${matcher}] → ${cmd}${suffix}`)
|
|
3813
3851
|
}
|
|
3814
3852
|
}
|
|
3815
3853
|
}
|
|
@@ -4092,7 +4130,12 @@ const resumeLastCmd: CommandHandler = async (ctx) => {
|
|
|
4092
4130
|
loaded: String(messages.length),
|
|
4093
4131
|
total: String(session.messages.length),
|
|
4094
4132
|
})
|
|
4095
|
-
: t('commands.resume.restored_full_footer', {
|
|
4133
|
+
: t('commands.resume.restored_full_footer', {
|
|
4134
|
+
loaded: String(messages.length),
|
|
4135
|
+
// 文案里的 `{name}` 必须真给:`t()` 把没给的占位符替换成**空串**
|
|
4136
|
+
// (`t.ts` 的 `params[k] ?? ''`),漏掉就静默印出 `--resume ""`。
|
|
4137
|
+
name: latest.name,
|
|
4138
|
+
}),
|
|
4096
4139
|
].join('\n'),
|
|
4097
4140
|
forwardedMessages: messages,
|
|
4098
4141
|
resumeWarning: true,
|
|
@@ -4319,6 +4362,16 @@ const upgradeCmd: CommandHandler = async (ctx) => {
|
|
|
4319
4362
|
|
|
4320
4363
|
const update = checkForUpdates()
|
|
4321
4364
|
|
|
4365
|
+
// The registry was never reached. "Already up to date" would be a claim we
|
|
4366
|
+
// cannot support: `checkForUpdates` leaves `latest === current` on failure, so
|
|
4367
|
+
// the old code printed "✓ Already up to date (v0.81.8 → v0.81.8)" while
|
|
4368
|
+
// offline and gave no hint that a check had even been attempted.
|
|
4369
|
+
// (`mipham update` on the CLI path already says "Could not determine latest
|
|
4370
|
+
// version" — this is the TUI's equivalent.)
|
|
4371
|
+
if (!update.checked) {
|
|
4372
|
+
return { content: t('commands.upgrade.check_failed', { current: update.current }) }
|
|
4373
|
+
}
|
|
4374
|
+
|
|
4322
4375
|
if (!update.available) {
|
|
4323
4376
|
return {
|
|
4324
4377
|
content: t('commands.upgrade.uptodate', { current: update.current, latest: update.latest }),
|
|
@@ -4688,18 +4741,51 @@ const mcpCmd: CommandHandler = async (ctx, args) => {
|
|
|
4688
4741
|
if (sub === 'disconnect') {
|
|
4689
4742
|
const name = args[1]
|
|
4690
4743
|
if (!name) return { content: 'Usage: /mcp disconnect <server-name>' }
|
|
4691
|
-
|
|
4744
|
+
client.disconnect(name)
|
|
4745
|
+
// Dropping the connection is not dropping the tools. They live in the
|
|
4746
|
+
// engine's registry — the map the model actually calls — and closing the
|
|
4747
|
+
// transport leaves every one of them registered and selectable, failing
|
|
4748
|
+
// only at call time. Count what the registry actually gave up, not what the
|
|
4749
|
+
// connection used to hold: those are different objects and only one of them
|
|
4750
|
+
// is what "removed" refers to.
|
|
4751
|
+
const toolsMap = ctx.engine.getTools()
|
|
4752
|
+
const before = toolsMap.size
|
|
4753
|
+
unregisterMcpServerTools(name, toolsMap)
|
|
4754
|
+
const removed = before - toolsMap.size
|
|
4692
4755
|
return {
|
|
4693
4756
|
content: [
|
|
4694
4757
|
`── MCP Disconnect: ${name} ──`,
|
|
4695
4758
|
'',
|
|
4696
|
-
|
|
4697
|
-
? `Disconnected. ${
|
|
4759
|
+
removed > 0
|
|
4760
|
+
? `Disconnected. ${removed} tool(s) removed.`
|
|
4698
4761
|
: 'Disconnected (no tools were registered).',
|
|
4699
4762
|
].join('\n'),
|
|
4700
4763
|
}
|
|
4701
4764
|
}
|
|
4702
4765
|
|
|
4766
|
+
// /mcp reconnect <name>
|
|
4767
|
+
if (sub === 'reconnect') {
|
|
4768
|
+
const name = args[1]
|
|
4769
|
+
if (!name) return { content: 'Usage: /mcp reconnect <server-name>' }
|
|
4770
|
+
try {
|
|
4771
|
+
await client.reconnect(name)
|
|
4772
|
+
const count = client.getTools(name).length
|
|
4773
|
+
return {
|
|
4774
|
+
content: `── MCP Reconnect: ${name} ──\n\nReconnected — ${count} tool(s) rediscovered.`,
|
|
4775
|
+
}
|
|
4776
|
+
} catch (err) {
|
|
4777
|
+
return {
|
|
4778
|
+
content: [
|
|
4779
|
+
`── MCP Reconnect: ${name} ──`,
|
|
4780
|
+
'',
|
|
4781
|
+
`Failed: ${String(err)}`,
|
|
4782
|
+
'',
|
|
4783
|
+
`The server stayed disconnected. Check that it is reachable, then retry — or use /mcp connect ${name}.`,
|
|
4784
|
+
].join('\n'),
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4703
4789
|
// /mcp reload
|
|
4704
4790
|
if (sub === 'reload') {
|
|
4705
4791
|
return {
|
|
@@ -4749,6 +4835,7 @@ const mcpCmd: CommandHandler = async (ctx, args) => {
|
|
|
4749
4835
|
lines.push('── Commands ──')
|
|
4750
4836
|
lines.push(' /mcp connect <name> Connect to a server (OAuth or stdio)')
|
|
4751
4837
|
lines.push(' /mcp disconnect <name> Disconnect from a server')
|
|
4838
|
+
lines.push(' /mcp reconnect <name> Reconnect a dropped server (with backoff)')
|
|
4752
4839
|
lines.push(' /mcp reload Disconnect all and reconnect')
|
|
4753
4840
|
lines.push('')
|
|
4754
4841
|
lines.push('── Protocol ──')
|
|
@@ -4994,7 +5081,7 @@ Or use the Agent tool in a conversation to launch a sub-agent.`,
|
|
|
4994
5081
|
return { content: lines.join('\n') }
|
|
4995
5082
|
}
|
|
4996
5083
|
|
|
4997
|
-
const bgCmd: CommandHandler = (ctx, args) => {
|
|
5084
|
+
const bgCmd: CommandHandler = async (ctx, args) => {
|
|
4998
5085
|
const prompt = args.join(' ')
|
|
4999
5086
|
if (!prompt.trim()) {
|
|
5000
5087
|
return {
|
|
@@ -5023,6 +5110,49 @@ Background agents appear in the Agent View dashboard (/agents).`,
|
|
|
5023
5110
|
|
|
5024
5111
|
agentViewManager.addMessage(session.id, { role: 'user', content: prompt })
|
|
5025
5112
|
agentViewManager.updateStatus(session.id, 'working')
|
|
5113
|
+
session.kind = 'unattended'
|
|
5114
|
+
|
|
5115
|
+
// A session row is not a task. Without the spawn below this command only ever
|
|
5116
|
+
// wrote a dashboard entry and reported success — the prompt was never handed
|
|
5117
|
+
// to a model, and the row sat at `working` forever.
|
|
5118
|
+
const bgReg = (await import('../agent/background-registry')).getBackgroundAgentRegistry()
|
|
5119
|
+
bgReg.spawn(
|
|
5120
|
+
prompt,
|
|
5121
|
+
'general',
|
|
5122
|
+
async (_signal) => {
|
|
5123
|
+
const { SubAgent } = await import('../agent/sub-agent')
|
|
5124
|
+
const sa = new SubAgent(
|
|
5125
|
+
ctx.engine.getRegistry(),
|
|
5126
|
+
ctx.engine.getTools(),
|
|
5127
|
+
ctx.engine.getPermission(),
|
|
5128
|
+
undefined,
|
|
5129
|
+
undefined,
|
|
5130
|
+
ctx.engine.getLlm(),
|
|
5131
|
+
)
|
|
5132
|
+
try {
|
|
5133
|
+
// The prompt came from the user typing `/bg …`, so it stays unframed —
|
|
5134
|
+
// unlike a workflow script's computed prompt.
|
|
5135
|
+
const result = await sa.execute(prompt, 'bg: ' + prompt.slice(0, 60), { type: 'general' })
|
|
5136
|
+
agentViewManager.addMessage(session.id, {
|
|
5137
|
+
role: 'assistant',
|
|
5138
|
+
content: result || '(no output)',
|
|
5139
|
+
})
|
|
5140
|
+
agentViewManager.updateStatus(session.id, 'completed')
|
|
5141
|
+
return result
|
|
5142
|
+
} catch (err) {
|
|
5143
|
+
// Leave the row honest on failure too — a session stuck at `working`
|
|
5144
|
+
// after its executor died is the same lie as never spawning at all.
|
|
5145
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
5146
|
+
agentViewManager.addMessage(session.id, {
|
|
5147
|
+
role: 'assistant',
|
|
5148
|
+
content: `Background agent failed: ${message}`,
|
|
5149
|
+
})
|
|
5150
|
+
agentViewManager.updateStatus(session.id, 'failed')
|
|
5151
|
+
throw err
|
|
5152
|
+
}
|
|
5153
|
+
},
|
|
5154
|
+
'unattended',
|
|
5155
|
+
)
|
|
5026
5156
|
|
|
5027
5157
|
return {
|
|
5028
5158
|
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
|