@miphamai/cli 0.81.7 → 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/README.md +1 -1
- 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/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +14 -9
- package/src/config/loader.ts +202 -63
- package/src/config/preferences.ts +5 -2
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/cron-poller.ts +30 -6
- 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 +157 -6
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +49 -2
- package/src/core/session-store.ts +11 -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/daemon/session-worker.ts +15 -0
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +47 -19
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +30 -8
- package/src/providers/anthropic.ts +74 -13
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +25 -2
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +17 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +146 -24
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +83 -3
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +70 -16
- package/src/tools/file/read.ts +151 -45
- package/src/tools/index.ts +12 -4
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +6 -2
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +187 -41
- package/src/workflow/primitives/agent.ts +4 -0
- package/src/artifacts/versioning.ts +0 -127
|
@@ -311,27 +311,27 @@
|
|
|
311
311
|
},
|
|
312
312
|
"resume": {
|
|
313
313
|
"no_sessions": "No saved sessions.",
|
|
314
|
-
"restored": "─ Session
|
|
314
|
+
"restored": "─ Session History Loaded ─",
|
|
315
315
|
"not_found": "Session \"{name}\" not found.",
|
|
316
316
|
"saved_title": "─ Saved Sessions ─",
|
|
317
317
|
"found_title": "─ Session Found ─",
|
|
318
318
|
"not_found_title": "─ Session Not Found ─",
|
|
319
319
|
"load_failed": "─ Load Failed ─",
|
|
320
320
|
"restored_content": "Name: {name}\nMessages: {total} total{truncated}\nProvider: {provider} / {model}\nUpdated: {date}",
|
|
321
|
-
"restored_footer": "{loaded} of {total} messages loaded
|
|
322
|
-
"restored_full_footer": "{loaded} messages loaded.
|
|
321
|
+
"restored_footer": "{loaded} of {total} messages loaded into the view (older ones not shown).",
|
|
322
|
+
"restored_full_footer": "{loaded} messages loaded into the view. The model's context is unchanged — to continue this session, run: mipham --resume \"{name}\"",
|
|
323
323
|
"delete_usage": "Usage: /resume delete <session-name>\n\nDelete a saved session. Use /resume to list all sessions.",
|
|
324
324
|
"delete_confirmed": "✓ Session \"{name}\" deleted.",
|
|
325
325
|
"delete_not_found": "✗ Session \"{name}\" not found. Use /resume to list all sessions.",
|
|
326
326
|
"empty_footer": "Sessions are auto-saved to ~/.mipham/sessions/ when Mipham Code exits.\nStart a conversation — it will be saved automatically.",
|
|
327
327
|
"total_footer": "Total: {count} session(s) • Location: ~/.mipham/sessions/",
|
|
328
|
-
"resume_hint": "
|
|
329
|
-
"resume_last_hint": "
|
|
328
|
+
"resume_hint": "Session details: /resume <name>",
|
|
329
|
+
"resume_last_hint": "Load recent history: /resume last",
|
|
330
330
|
"delete_hint": "To delete a session: /resume delete <name>",
|
|
331
331
|
"cli_hint": "To resume from CLI: mipham --resume \"<name>\"",
|
|
332
|
-
"auto_save_hint": "Sessions are auto-saved on exit.
|
|
332
|
+
"auto_save_hint": "Sessions are auto-saved on exit. On restart only the most recent session's summary is injected as context — to reopen a session: mipham --resume \"<name>\"",
|
|
333
333
|
"not_found_content": "No session named \"{name}\".\n\nUse /resume without arguments to list all saved sessions.",
|
|
334
|
-
"found_content": "Name: {name}\nMessages: {messages}\nProvider: {provider} / {model}\nUpdated: {updated}\n\nTo
|
|
334
|
+
"found_content": "Name: {name}\nMessages: {messages}\nProvider: {provider} / {model}\nUpdated: {updated}\n\nTo continue this session, start a new process with:\n mipham --resume \"{target}\""
|
|
335
335
|
},
|
|
336
336
|
"help": { "title": "── Mipham Code Help ──" },
|
|
337
337
|
"lang": {
|
|
@@ -423,6 +423,7 @@
|
|
|
423
423
|
},
|
|
424
424
|
"upgrade": {
|
|
425
425
|
"uptodate": "── Upgrade Mipham Code ──\n\nCurrent version: v{current}\nLatest: v{latest}\n\n✓ Already up to date.\n\nTo check manually: https://www.npmjs.com/package/@miphamai/cli",
|
|
426
|
+
"check_failed": "── Upgrade Mipham Code ──\n\n✗ Could not reach the npm registry — unable to tell whether v{current} is the latest.\n Nothing was changed. Check your network, then retry: /upgrade",
|
|
426
427
|
"title": "── Upgrade Mipham Code ──",
|
|
427
428
|
"available": "Current version: v{current}\nLatest: v{latest}\n\n→ New version available! Updating...",
|
|
428
429
|
"updated": "✓ Updated to @miphamai/cli v{version}",
|
|
@@ -558,7 +559,7 @@
|
|
|
558
559
|
"stats": {
|
|
559
560
|
"title": "── Session Stats ──",
|
|
560
561
|
"messages": "Messages: {total} ({user} user, {assistant} AI, {system} system)",
|
|
561
|
-
"tokens": "Tokens: ~{tokens} /
|
|
562
|
+
"tokens": "Tokens: ~{tokens} / {max}",
|
|
562
563
|
"tools": "Tools available: {count}",
|
|
563
564
|
"provider": "Provider: {provider}",
|
|
564
565
|
"model": "Model: {model}",
|
|
@@ -585,6 +586,9 @@
|
|
|
585
586
|
"title": "── Hooks ──",
|
|
586
587
|
"no_hooks": "── Hooks ──\n\nNo hooks configured.\n\nConfigure hooks in .mipham/settings.json under the hooks section:\n PreToolUse — before each tool\n PostToolUse — after each tool\n Stop — on session end\n\nCommand hooks read JSON from stdin; exit 2 blocks (stderr as reason).\nUse /loop init to scaffold settings.json.",
|
|
587
588
|
"location": "Location: {path}",
|
|
589
|
+
"source_user": "user",
|
|
590
|
+
"source_project": "project",
|
|
591
|
+
"gated": "⚠️ workspace not trusted — will not run",
|
|
588
592
|
"found": "{count} hook(s) found.",
|
|
589
593
|
"error": "── Hooks ──\n\nCould not read hooks directory."
|
|
590
594
|
},
|
|
@@ -311,27 +311,27 @@
|
|
|
311
311
|
},
|
|
312
312
|
"resume": {
|
|
313
313
|
"no_sessions": "没有已保存的会话。",
|
|
314
|
-
"restored": "─
|
|
314
|
+
"restored": "─ 会话历史已载入 ─",
|
|
315
315
|
"not_found": "未找到会话 \"{name}\"。",
|
|
316
316
|
"saved_title": "─ 已保存的会话 ─",
|
|
317
317
|
"found_title": "─ 找到会话 ─",
|
|
318
318
|
"not_found_title": "─ 未找到会话 ─",
|
|
319
319
|
"load_failed": "─ 加载失败 ─",
|
|
320
320
|
"restored_content": "名称: {name}\n消息数: {total} 条{truncated}\n提供商: {provider} / {model}\n更新时间: {date}",
|
|
321
|
-
"restored_footer": "
|
|
322
|
-
"restored_full_footer": "
|
|
321
|
+
"restored_footer": "已载入视图 {loaded}/{total} 条消息(较早的未显示)。",
|
|
322
|
+
"restored_full_footer": "已载入视图 {loaded} 条消息;模型上下文未变 —— 要接着此会话继续,请运行: mipham --resume \"{name}\"",
|
|
323
323
|
"delete_usage": "用法: /resume delete <session-name>\n\n删除已保存的会话。使用 /resume 列出所有会话。",
|
|
324
324
|
"delete_confirmed": "✓ 会话 \"{name}\" 已删除。",
|
|
325
325
|
"delete_not_found": "✗ 未找到会话 \"{name}\"。使用 /resume 列出所有会话。",
|
|
326
326
|
"empty_footer": "会话在 Mipham Code 退出时自动保存到 ~/.mipham/sessions/。\n开始对话 — 它将自动保存。",
|
|
327
327
|
"total_footer": "总计: {count} 个会话 • 位置: ~/.mipham/sessions/",
|
|
328
|
-
"resume_hint": "
|
|
329
|
-
"resume_last_hint": "
|
|
328
|
+
"resume_hint": "查看会话详情: /resume <name>",
|
|
329
|
+
"resume_last_hint": "载入最近历史: /resume last",
|
|
330
330
|
"delete_hint": "删除会话: /resume delete <name>",
|
|
331
331
|
"cli_hint": "命令行恢复: mipham --resume \"<name>\"",
|
|
332
|
-
"auto_save_hint": "
|
|
332
|
+
"auto_save_hint": "会话在退出时自动保存。重启时仅注入最近会话的摘要作为上下文 —— 要重新打开某个会话: mipham --resume \"<name>\"",
|
|
333
333
|
"not_found_content": "未找到名为 \"{name}\" 的会话。\n\n不带参数运行 /resume 列出所有已保存的会话。",
|
|
334
|
-
"found_content": "名称: {name}\n消息数: {messages}\n提供商: {provider} / {model}\n更新时间: {updated}\n\n
|
|
334
|
+
"found_content": "名称: {name}\n消息数: {messages}\n提供商: {provider} / {model}\n更新时间: {updated}\n\n要接着此会话继续,请另起进程运行:\n mipham --resume \"{target}\""
|
|
335
335
|
},
|
|
336
336
|
"help": { "title": "── Mipham Code 帮助 ──" },
|
|
337
337
|
"lang": {
|
|
@@ -423,6 +423,7 @@
|
|
|
423
423
|
},
|
|
424
424
|
"upgrade": {
|
|
425
425
|
"uptodate": "── 升级 Mipham Code ──\n\n当前版本: v{current}\n最新版本: v{latest}\n\n✓ 已是最新版本。\n\n手动检查: https://www.npmjs.com/package/@miphamai/cli",
|
|
426
|
+
"check_failed": "── 升级 Mipham Code ──\n\n✗ 无法连接 npm registry —— 无法判断 v{current} 是否已是最新版本。\n 未做任何改动。请检查网络后重试: /upgrade",
|
|
426
427
|
"title": "── 升级 Mipham Code ──",
|
|
427
428
|
"available": "当前版本: v{current}\n最新版本: v{latest}\n\n→ 发现新版本!正在更新...",
|
|
428
429
|
"updated": "✓ 已更新至 @miphamai/cli v{version}",
|
|
@@ -558,7 +559,7 @@
|
|
|
558
559
|
"stats": {
|
|
559
560
|
"title": "── 会话统计 ──",
|
|
560
561
|
"messages": "消息: {total}({user} 用户, {assistant} AI, {system} 系统)",
|
|
561
|
-
"tokens": "Token: ~{tokens} /
|
|
562
|
+
"tokens": "Token: ~{tokens} / {max}",
|
|
562
563
|
"tools": "可用工具: {count}",
|
|
563
564
|
"provider": "提供商: {provider}",
|
|
564
565
|
"model": "模型: {model}",
|
|
@@ -585,6 +586,9 @@
|
|
|
585
586
|
"title": "── 钩子 ──",
|
|
586
587
|
"no_hooks": "── 钩子 ──\n\n未配置钩子。\n\n在 .mipham/settings.json 的 hooks 段配置钩子:\n PreToolUse — 工具执行前\n PostToolUse — 工具执行后\n Stop — 会话结束\n\n命令钩子从 stdin 读 JSON,exit 2 拦截(stderr 作理由)。\n使用 /loop init 初始化 settings.json。",
|
|
587
588
|
"location": "位置: {path}",
|
|
589
|
+
"source_user": "用户级",
|
|
590
|
+
"source_project": "项目级",
|
|
591
|
+
"gated": "⚠️ 工作区未受信任,不会运行",
|
|
588
592
|
"found": "找到 {count} 个钩子。",
|
|
589
593
|
"error": "── 钩子 ──\n\n无法读取钩子目录。"
|
|
590
594
|
},
|
package/src/index.tsx
CHANGED
|
@@ -32,6 +32,7 @@ import { ExperienceRuleEngine } from './core/rule-engine.js'
|
|
|
32
32
|
import { SessionLog } from './core/session-log'
|
|
33
33
|
import { SessionStore } from './core/session-store'
|
|
34
34
|
import type { PermissionLevel, MiphamConfig, McpServerConfig } from './shared/types'
|
|
35
|
+
import { PermissionSystem } from './core/permission'
|
|
35
36
|
import { SkillsLoader } from './skills/loader'
|
|
36
37
|
import { PluginManager } from './plugin/plugin-manager'
|
|
37
38
|
import { loadPlugins } from './plugin/plugin-loader'
|
|
@@ -52,8 +53,9 @@ import { getMetrics } from './core/metrics'
|
|
|
52
53
|
import { initTelemetry, enableTelemetryNow } from './telemetry/index'
|
|
53
54
|
import { wasPrompted, markPrompted, isInteractive, setTelemetryEnabled } from './telemetry/consent'
|
|
54
55
|
import { officialEndpointHost } from './telemetry/endpoint'
|
|
55
|
-
import { getWorkspaceTrust } from './core/workspace-trust'
|
|
56
|
-
import {
|
|
56
|
+
import { getWorkspaceTrust, warnProjectHooksSkipped } from './core/workspace-trust'
|
|
57
|
+
import { ARTIFACT_PORT } from './shared/constants'
|
|
58
|
+
import { artifactsRoot } from './artifacts/paths'
|
|
57
59
|
import { AgentViewManager } from './agent-view/agent-view-manager'
|
|
58
60
|
import { AgentViewDashboard } from './agent-view/dashboard'
|
|
59
61
|
import { createT } from './i18n-core/t'
|
|
@@ -374,6 +376,22 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
374
376
|
// Load configuration
|
|
375
377
|
const config = loadConfig()
|
|
376
378
|
|
|
379
|
+
// Permission policy is built **here**, before the system prompt exists, so the prompt
|
|
380
|
+
// can describe the mode the engine will actually run in. Building the prompt from
|
|
381
|
+
// `config.permission` handed the model a value that org restrictions can silently
|
|
382
|
+
// rewrite (and that may not even be a mode name — `bypass`/`auto`/`ask` inject nothing
|
|
383
|
+
// at all). The engine is created much later, after tools/hooks/vajra are mounted, so
|
|
384
|
+
// this instance is what gets handed to it rather than a second one built inside.
|
|
385
|
+
const permission = new PermissionSystem('default')
|
|
386
|
+
// Sync with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
|
|
387
|
+
if (config.permission) {
|
|
388
|
+
permission.setDefaultLevel(config.permission as PermissionLevel)
|
|
389
|
+
}
|
|
390
|
+
// Apply org-level permission restrictions (P0: bypassPermissions policy gap)
|
|
391
|
+
if (config.permissionRestrictions) {
|
|
392
|
+
permission.setRestrictions(config.permissionRestrictions)
|
|
393
|
+
}
|
|
394
|
+
|
|
377
395
|
// Detect locale and create translation function
|
|
378
396
|
const locale = detectLocale({ lang: options.lang })
|
|
379
397
|
const t = createT(localeBundles[locale] || enUS, enUS)
|
|
@@ -478,12 +496,12 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
478
496
|
}
|
|
479
497
|
}
|
|
480
498
|
context.restoreLog(log)
|
|
481
|
-
context.setSystemPrompt(instructions.buildSystemPrompt(
|
|
499
|
+
context.setSystemPrompt(instructions.buildSystemPrompt(permission.getMode()))
|
|
482
500
|
}
|
|
483
501
|
}
|
|
484
502
|
|
|
485
503
|
if (context.getMessageCount() === 0) {
|
|
486
|
-
const basePrompt = instructions.buildSystemPrompt(
|
|
504
|
+
const basePrompt = instructions.buildSystemPrompt(permission.getMode())
|
|
487
505
|
const memoryReminder = loadSessionMemories(basePrompt)
|
|
488
506
|
const skillsReminder = skillsLoader.buildSystemReminder(5000, config.skills?.reminder ?? 'full')
|
|
489
507
|
|
|
@@ -581,8 +599,10 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
581
599
|
// A dead server (e.g. a 15s connect timeout) no longer blocks startup.
|
|
582
600
|
const mcpConnectPromise = connectMcpServers(config.skills?.mcpServers ?? [], tools)
|
|
583
601
|
|
|
584
|
-
// Initialize hook engine — register skill-defined hooks
|
|
585
|
-
|
|
602
|
+
// Initialize hook engine — register skill-defined hooks.
|
|
603
|
+
// CLI 是一次性进程,会话 cwd 就是 `process.cwd()`;写出来是为了与 daemon 那处
|
|
604
|
+
// (`daemon/engine-capabilities.ts` 传会话 cwd)成对照,别让哪边看起来像漏了。
|
|
605
|
+
const hookEngine = new HookEngine(process.cwd())
|
|
586
606
|
for (const skill of skillsLoader.list()) {
|
|
587
607
|
if (skill.hooks) {
|
|
588
608
|
for (const hook of skill.hooks) {
|
|
@@ -591,19 +611,28 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
591
611
|
}
|
|
592
612
|
}
|
|
593
613
|
|
|
594
|
-
// Register settings.json hooks (Claude Code convention — additive across levels)
|
|
595
|
-
|
|
614
|
+
// Register settings.json hooks (Claude Code convention — additive across levels).
|
|
615
|
+
// Project-level hooks are repository-controlled shell commands, so they are only
|
|
616
|
+
// read once this workspace is trusted. On a TTY `checkWorkspaceTrust()` above has
|
|
617
|
+
// already asked — and exited the process on "no". With no TTY it *cannot* ask, so
|
|
618
|
+
// the answer is "no" and the hooks stay out rather than running unasked; that
|
|
619
|
+
// skip is announced, since silence would look the same as having passed.
|
|
620
|
+
const projectHooksTrusted = getWorkspaceTrust().isTrusted(process.cwd())
|
|
621
|
+
const settingsJson = loadSettingsJson(process.cwd(), {
|
|
622
|
+
includeProjectHooks: projectHooksTrusted,
|
|
623
|
+
})
|
|
624
|
+
if (settingsJson.projectHooksSkipped) warnProjectHooksSkipped(process.cwd())
|
|
596
625
|
for (const def of loadHookConfigs(settingsJson.hooks)) {
|
|
597
626
|
hookEngine.register(def)
|
|
598
627
|
}
|
|
599
628
|
|
|
600
629
|
// Start artifact server (lazy — first artifact creation triggers listening)
|
|
601
|
-
const artifactsDir =
|
|
630
|
+
const artifactsDir = artifactsRoot(process.cwd())
|
|
602
631
|
const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
|
|
603
632
|
|
|
604
633
|
// Create query engine
|
|
605
634
|
const ruleEngine = new ExperienceRuleEngine()
|
|
606
|
-
const engine = new QueryEngine(registry, context, tools,
|
|
635
|
+
const engine = new QueryEngine(registry, context, tools, permission, ruleEngine)
|
|
607
636
|
engine.setHookEngine(hookEngine)
|
|
608
637
|
engine.setArtifactServer(artifactServer)
|
|
609
638
|
engine.setAgentViewManager(agentViewManager)
|
|
@@ -618,15 +647,8 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
618
647
|
const inferenceHookConfig = loadInferenceHookConfig()
|
|
619
648
|
engine.setInferenceHookConfig(inferenceHookConfig)
|
|
620
649
|
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// Apply org-level permission restrictions (P0: bypassPermissions policy gap)
|
|
627
|
-
if (config.permissionRestrictions) {
|
|
628
|
-
engine.getPermission().setRestrictions(config.permissionRestrictions)
|
|
629
|
-
}
|
|
650
|
+
// `config.permission` / `permissionRestrictions` are applied up top, on the instance
|
|
651
|
+
// handed to `new QueryEngine(...)` — the same one whose mode the system prompt reads.
|
|
630
652
|
|
|
631
653
|
// Apply user-defined permission rules (allow/deny) — wire the rule system into runtime
|
|
632
654
|
if (config.permissionRules) {
|
|
@@ -644,6 +666,12 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
644
666
|
process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
|
|
645
667
|
}
|
|
646
668
|
|
|
669
|
+
// Same warning channel for malformed restrictions (P1) — a typo'd
|
|
670
|
+
// `forbiddenModes`/`maxAllowedMode` used to leave the policy silently inert.
|
|
671
|
+
for (const msg of engine.getPermission().getInvalidRestrictions()) {
|
|
672
|
+
process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
|
|
673
|
+
}
|
|
674
|
+
|
|
647
675
|
// Initialize agent registry and load plugin agents/skills/MCP/hooks
|
|
648
676
|
const agentRegistry = new AgentRegistry()
|
|
649
677
|
agentRegistry.loadUserAgents()
|
package/src/mcp/client.ts
CHANGED
|
@@ -187,6 +187,12 @@ export class McpClient {
|
|
|
187
187
|
connection.toolsRefreshInFlight = true
|
|
188
188
|
try {
|
|
189
189
|
await this.applyToolsChanged(name)
|
|
190
|
+
} catch {
|
|
191
|
+
// A refresh against a server that has gone away must not surface as an
|
|
192
|
+
// unhandled rejection out of a timer callback — record the lost
|
|
193
|
+
// connection instead.
|
|
194
|
+
const current = this.connections.get(name)
|
|
195
|
+
if (current) this.markIfTransportLost(name, current)
|
|
190
196
|
} finally {
|
|
191
197
|
// Re-read: the server may have been disconnected while we were awaiting.
|
|
192
198
|
const current = this.connections.get(name)
|
|
@@ -427,10 +433,28 @@ export class McpClient {
|
|
|
427
433
|
try {
|
|
428
434
|
return await conn.protocol.callTool(toolName, params)
|
|
429
435
|
} catch (err) {
|
|
436
|
+
this.markIfTransportLost(serverName, conn)
|
|
430
437
|
return {
|
|
431
438
|
content: [{ type: 'text', text: t('errors.mcp_tool_error', { error: String(err) }) }],
|
|
432
439
|
isError: true,
|
|
433
440
|
}
|
|
434
441
|
}
|
|
435
442
|
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Downgrade a connection whose transport has gone away.
|
|
446
|
+
*
|
|
447
|
+
* A tool call can fail because the *tool* failed or because the server did, and
|
|
448
|
+
* only the transport can tell those apart: a closed transport answers nothing
|
|
449
|
+
* from now on. Without this the connection stays 'connected' and `/mcp` keeps
|
|
450
|
+
* showing green for a server that is gone.
|
|
451
|
+
*/
|
|
452
|
+
private markIfTransportLost(name: string, connection: ActiveConnection): void {
|
|
453
|
+
if (connection.transport.isConnected()) return
|
|
454
|
+
if (connection.status === 'error') return
|
|
455
|
+
|
|
456
|
+
connection.status = 'error'
|
|
457
|
+
connection.error = 'Connection lost — the transport is no longer connected'
|
|
458
|
+
this.emit('disconnected', name, connection.error)
|
|
459
|
+
}
|
|
436
460
|
}
|
|
@@ -12,7 +12,10 @@ type FetchFn = (input: string, init?: RequestInit) => Promise<Response>
|
|
|
12
12
|
* result text (this is how Forge's `/mcp` streams progressive chunks). We
|
|
13
13
|
* reassemble those slices so callers see one complete result.
|
|
14
14
|
*/
|
|
15
|
-
function parseSseResponse(
|
|
15
|
+
function parseSseResponse(
|
|
16
|
+
body: string,
|
|
17
|
+
onNotification?: (notification: JsonRpcNotification) => void,
|
|
18
|
+
): unknown {
|
|
16
19
|
const messages: Array<{ id?: number; result?: unknown; error?: JsonRpcError }> = []
|
|
17
20
|
|
|
18
21
|
for (const block of body.split(/\n\n/)) {
|
|
@@ -21,7 +24,16 @@ function parseSseResponse(body: string): unknown {
|
|
|
21
24
|
const payload = line.slice('data:'.length).trim()
|
|
22
25
|
if (!payload) continue
|
|
23
26
|
try {
|
|
24
|
-
|
|
27
|
+
const message = JSON.parse(payload) as JsonRpcResponse & JsonRpcNotification
|
|
28
|
+
// Server-initiated notifications (e.g. tools/list_changed) arrive on the
|
|
29
|
+
// same stream as the response to the request in flight. They carry no id
|
|
30
|
+
// and are not part of that response, so hand them off rather than letting
|
|
31
|
+
// them count as a result — or as a chunk of one.
|
|
32
|
+
if (message.method !== undefined && message.id === undefined) {
|
|
33
|
+
onNotification?.(message as JsonRpcNotification)
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
messages.push(message)
|
|
25
37
|
} catch {
|
|
26
38
|
// Skip unparseable event lines
|
|
27
39
|
}
|
|
@@ -111,6 +123,10 @@ export class HttpTransport implements Transport {
|
|
|
111
123
|
const controller = new AbortController()
|
|
112
124
|
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs)
|
|
113
125
|
|
|
126
|
+
// Did an HTTP response come back at all? A rejected fetch or a timeout means
|
|
127
|
+
// the endpoint is unreachable; an error *status* only means it said no.
|
|
128
|
+
let reachable = false
|
|
129
|
+
|
|
114
130
|
try {
|
|
115
131
|
const response = await this.fetchImpl(this.url, {
|
|
116
132
|
method: 'POST',
|
|
@@ -123,6 +139,7 @@ export class HttpTransport implements Transport {
|
|
|
123
139
|
signal: controller.signal,
|
|
124
140
|
redirect: 'manual',
|
|
125
141
|
})
|
|
142
|
+
reachable = true
|
|
126
143
|
|
|
127
144
|
if (!response.ok) {
|
|
128
145
|
let detail = `HTTP ${response.status}`
|
|
@@ -136,7 +153,7 @@ export class HttpTransport implements Transport {
|
|
|
136
153
|
|
|
137
154
|
const contentType = response.headers.get('content-type') || ''
|
|
138
155
|
if (contentType.includes('text/event-stream')) {
|
|
139
|
-
return parseSseResponse(await response.text())
|
|
156
|
+
return parseSseResponse(await response.text(), (n) => this.dispatchNotification(n))
|
|
140
157
|
}
|
|
141
158
|
|
|
142
159
|
const json = (await response.json()) as JsonRpcResponse
|
|
@@ -146,8 +163,16 @@ export class HttpTransport implements Transport {
|
|
|
146
163
|
return json.result
|
|
147
164
|
} catch (err) {
|
|
148
165
|
if (controller.signal.aborted) {
|
|
166
|
+
// The endpoint never answered. Every later request would hang the same
|
|
167
|
+
// way, so the transport reports itself disconnected — the same state a
|
|
168
|
+
// stdio transport reaches when its process exits — until reconnect()
|
|
169
|
+
// replaces it.
|
|
170
|
+
this.closed = true
|
|
149
171
|
throw requestTimeoutError(method, this.requestTimeoutMs)
|
|
150
172
|
}
|
|
173
|
+
if (!reachable) {
|
|
174
|
+
this.closed = true
|
|
175
|
+
}
|
|
151
176
|
throw err
|
|
152
177
|
} finally {
|
|
153
178
|
clearTimeout(timer)
|
|
@@ -182,6 +207,13 @@ export class HttpTransport implements Transport {
|
|
|
182
207
|
this.notificationHandlers.push(handler)
|
|
183
208
|
}
|
|
184
209
|
|
|
210
|
+
/** Hand a server-initiated notification to every registered handler. */
|
|
211
|
+
private dispatchNotification(notification: JsonRpcNotification): void {
|
|
212
|
+
for (const handler of this.notificationHandlers) {
|
|
213
|
+
handler(notification)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
185
217
|
async close(): Promise<void> {
|
|
186
218
|
this.closed = true
|
|
187
219
|
this.url = null
|
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
} from 'node:fs'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { homedir } from 'node:os'
|
|
12
|
-
import {
|
|
12
|
+
import { execFileSync } from 'node:child_process'
|
|
13
13
|
import { validatePlugin } from './plugin-validator'
|
|
14
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
14
15
|
|
|
15
16
|
const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
|
|
16
17
|
|
|
@@ -110,11 +111,22 @@ export class PluginManager {
|
|
|
110
111
|
'utf-8',
|
|
111
112
|
)
|
|
112
113
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
// `--ignore-scripts`: without it, installing a plugin runs that package's
|
|
115
|
+
// preinstall/install/postinstall hooks as the current user — arbitrary
|
|
116
|
+
// code execution from any npm package, reachable via `/install-plugin`.
|
|
117
|
+
// A plugin only needs to *be* files on disk; it never needs a build step
|
|
118
|
+
// of its own to be loaded from here. `execFileSync` (argv array, no
|
|
119
|
+
// shell) keeps the command line independent of packageName, so package
|
|
120
|
+
// name validation is not the only thing standing between us and a shell.
|
|
121
|
+
execFileSync(
|
|
122
|
+
'npm',
|
|
123
|
+
['install', packageName, '--prefix', stagingDir, '--no-save', '--ignore-scripts'],
|
|
124
|
+
{
|
|
125
|
+
encoding: 'utf-8',
|
|
126
|
+
stdio: 'pipe',
|
|
127
|
+
timeout: 60_000,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
118
130
|
|
|
119
131
|
// npm installs the package into <stagingDir>/node_modules/<packageName>/ —
|
|
120
132
|
// validate there, then flatten it to the plugin dir root so its layout
|
|
@@ -276,7 +288,12 @@ export class PluginManager {
|
|
|
276
288
|
private loadState(): void {
|
|
277
289
|
try {
|
|
278
290
|
if (existsSync(this.statePath)) {
|
|
279
|
-
|
|
291
|
+
const parsed: unknown = JSON.parse(readFileSync(this.statePath, 'utf-8'))
|
|
292
|
+
// Parsing is not validating: `{}` / `null` / `"x"` are all valid JSON and
|
|
293
|
+
// all make the readers below throw (`.find`/`.filter`/`.map` on a
|
|
294
|
+
// non-array), so a file that is merely *shaped* wrong took down every
|
|
295
|
+
// plugin command instead of starting empty.
|
|
296
|
+
this.plugins = Array.isArray(parsed) ? (parsed as InstalledPlugin[]) : []
|
|
280
297
|
}
|
|
281
298
|
} catch {
|
|
282
299
|
this.plugins = []
|
|
@@ -284,6 +301,11 @@ export class PluginManager {
|
|
|
284
301
|
}
|
|
285
302
|
|
|
286
303
|
private saveState(): void {
|
|
287
|
-
writeFileSync
|
|
304
|
+
// Was a bare writeFileSync: an interrupted write left unparseable JSON, and
|
|
305
|
+
// `loadState` swallows that into `[]` ⇒ the installed-plugin list silently
|
|
306
|
+
// vanished. Atomic write means a reader sees the old list or the new one.
|
|
307
|
+
atomicWriteFileSync(this.statePath, JSON.stringify(this.plugins, null, 2) + '\n', {
|
|
308
|
+
mode: 0o600,
|
|
309
|
+
})
|
|
288
310
|
}
|
|
289
311
|
}
|
|
@@ -33,11 +33,33 @@ interface AnthropicSSEEvent {
|
|
|
33
33
|
text?: string
|
|
34
34
|
thinking?: string
|
|
35
35
|
partial_json?: string
|
|
36
|
+
stop_reason?: string | null
|
|
36
37
|
}
|
|
37
38
|
error?: { type: string; message: string }
|
|
38
39
|
usage?: { input_tokens: number; output_tokens: number }
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Stand-in for a tool result that has no content of its own.
|
|
44
|
+
*
|
|
45
|
+
* `tool_result.content` is normalized to a text block server-side, and an empty
|
|
46
|
+
* one is rejected with a 400 — which fails the *whole* request, history
|
|
47
|
+
* included. Multiple paths produce one: a tool that reports success with no
|
|
48
|
+
* output (`content: ''`), a failed tool with no message, and a log projection
|
|
49
|
+
* whose `content` never got written (`undefined`). All of them mean the same
|
|
50
|
+
* thing to the model, so they get the same words.
|
|
51
|
+
*/
|
|
52
|
+
const NO_TOOL_OUTPUT = '(no output)'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* An empty text block is not a stylistic wart — Anthropic rejects it with a 400,
|
|
56
|
+
* and since every message in the request is history, one such block makes the
|
|
57
|
+
* conversation permanently unsendable (every later turn re-sends it).
|
|
58
|
+
*/
|
|
59
|
+
function isEmptyTextBlock(block: Record<string, unknown>): boolean {
|
|
60
|
+
return block.type === 'text' && block.text === ''
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
export class AnthropicProvider implements ProviderInstance {
|
|
42
64
|
private baseUrl = 'https://api.anthropic.com/v1'
|
|
43
65
|
private anthropicVersion = '2023-06-01'
|
|
@@ -52,12 +74,21 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
52
74
|
let currentToolId = ''
|
|
53
75
|
let accumulatedToolInput = ''
|
|
54
76
|
|
|
77
|
+
// Set when the provider reports `stop_reason: 'max_tokens'` — the turn was cut
|
|
78
|
+
// off at the output ceiling rather than ended by the model.
|
|
79
|
+
let truncated = false
|
|
80
|
+
|
|
55
81
|
const messages = this.convertMessages(req.messages)
|
|
56
82
|
this.markPrefixCacheBreakpoint(messages)
|
|
57
83
|
|
|
84
|
+
// Priority: explicit request override (summarizer / sub-agent call sites) >
|
|
85
|
+
// the model's declared ceiling > 4096. The fallback stays: a model id that
|
|
86
|
+
// isn't in `config.models` has no known ceiling.
|
|
87
|
+
const declaredMaxOutput = this.config.models.find((m) => m.id === req.model)?.maxOutput
|
|
88
|
+
|
|
58
89
|
const body: Record<string, unknown> = {
|
|
59
90
|
model: req.model,
|
|
60
|
-
max_tokens: req.maxTokens || 4096,
|
|
91
|
+
max_tokens: req.maxTokens || declaredMaxOutput || 4096,
|
|
61
92
|
stream: true,
|
|
62
93
|
messages,
|
|
63
94
|
}
|
|
@@ -184,6 +215,13 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
184
215
|
}
|
|
185
216
|
|
|
186
217
|
case 'content_block_stop': {
|
|
218
|
+
// 此刻还无从得知本轮是否被截断 —— `stop_reason` 要到后面的
|
|
219
|
+
// `message_delta` 才到(见下方同名分支)。所以被截断的 `tool_use`
|
|
220
|
+
// 在这里已经发出去了;openai-compat 那条路上「截断即丢弃未完成的
|
|
221
|
+
// tool_call」的处置,这里结构上做不到(它的 finish_reason 与
|
|
222
|
+
// tool_calls 落在同一个响应体里)。**这是有意的不对称,不是漏做**:
|
|
223
|
+
// 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
|
|
224
|
+
// 那是一次行为变更,不属本次范围。
|
|
187
225
|
if (currentToolId && currentToolName && accumulatedToolInput) {
|
|
188
226
|
let parsedInput: Record<string, unknown> = {}
|
|
189
227
|
try {
|
|
@@ -223,11 +261,18 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
223
261
|
if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
|
|
224
262
|
accumulatedToolInput += event.delta.partial_json
|
|
225
263
|
}
|
|
264
|
+
// `max_tokens` means the turn hit the output ceiling. Without this the
|
|
265
|
+
// truncation is indistinguishable from `end_turn`: both arrive here and
|
|
266
|
+
// the terminal stop below looks the same either way.
|
|
267
|
+
const stopReason = event.delta?.stop_reason
|
|
268
|
+
if (stopReason === 'max_tokens') {
|
|
269
|
+
truncated = true
|
|
270
|
+
}
|
|
226
271
|
break
|
|
227
272
|
}
|
|
228
273
|
|
|
229
274
|
case 'message_stop': {
|
|
230
|
-
yield { type: 'stop' }
|
|
275
|
+
yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
|
|
231
276
|
return
|
|
232
277
|
}
|
|
233
278
|
|
|
@@ -278,17 +323,23 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
278
323
|
if (msg.role === 'system') continue
|
|
279
324
|
|
|
280
325
|
if (typeof msg.content === 'string') {
|
|
281
|
-
const content: unknown[] = [
|
|
326
|
+
const content: unknown[] = []
|
|
327
|
+
// 空串不产出 text 块(见 isEmptyTextBlock):用户发了个空消息、或工具返回
|
|
328
|
+
// 空内容时会走到这里,而它会让之后每一轮都 400。
|
|
329
|
+
if (msg.content !== '') content.push({ type: 'text', text: msg.content })
|
|
282
330
|
// DeepSeek V4 thinking mode via Anthropic endpoint: every assistant
|
|
283
331
|
// message must contain a thinking block if any message in history does.
|
|
284
332
|
if (msg.role === 'assistant') {
|
|
285
333
|
const thinkingText = (msg as any).reasoning_content || ''
|
|
286
334
|
content.unshift({ type: 'thinking', thinking: thinkingText })
|
|
287
335
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
336
|
+
// 全部内容都被滤掉的消息**整条**不下发 —— 空 content 数组同样被 API 拒。
|
|
337
|
+
if (content.length > 0) {
|
|
338
|
+
result.push({
|
|
339
|
+
role: msg.role,
|
|
340
|
+
content,
|
|
341
|
+
})
|
|
342
|
+
}
|
|
292
343
|
} else {
|
|
293
344
|
const blocks = (msg.content as ContentBlock[]).map((block) => {
|
|
294
345
|
switch (block.type) {
|
|
@@ -338,22 +389,32 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
338
389
|
return {
|
|
339
390
|
type: 'tool_result',
|
|
340
391
|
tool_use_id: block.tool_use_id,
|
|
341
|
-
content
|
|
392
|
+
// 空 content 同样整条请求被拒(见 NO_TOOL_OUTPUT)。`||` 而非 `=== ''`:
|
|
393
|
+
// 投影层缺失该字段时这里是 `undefined`,空数组同理。
|
|
394
|
+
content: block.content || NO_TOOL_OUTPUT,
|
|
342
395
|
// 只在失败时下发 —— 成功请求体与改动前逐字节相同,不引入 prompt-cache 前缀抖动
|
|
343
396
|
...(block.is_error === true ? { is_error: true } : {}),
|
|
344
397
|
}
|
|
345
398
|
|
|
346
399
|
default:
|
|
347
|
-
|
|
400
|
+
// 未知块类型**无法**原样表达,而 `{type:'text',text:''}` 是一个必然被拒
|
|
401
|
+
// 的载荷 —— 改成丢这一个块(下面的 filter),而不是拿它毒掉整条请求。
|
|
402
|
+
return null
|
|
348
403
|
}
|
|
349
|
-
})
|
|
404
|
+
}) as (Record<string, unknown> | null)[]
|
|
405
|
+
|
|
406
|
+
// 丢掉表达不出来的块(未知类型)与空的 text 块。
|
|
407
|
+
const kept = blocks.filter(
|
|
408
|
+
(b): b is Record<string, unknown> => b !== null && !isEmptyTextBlock(b),
|
|
409
|
+
)
|
|
350
410
|
|
|
351
411
|
// DeepSeek V4 thinking mode via Anthropic endpoint: every assistant
|
|
352
412
|
// message must contain a thinking block if any message in history does.
|
|
353
|
-
if (msg.role === 'assistant' && !
|
|
354
|
-
|
|
413
|
+
if (msg.role === 'assistant' && !kept.some((b) => b.type === 'thinking')) {
|
|
414
|
+
kept.unshift({ type: 'thinking', thinking: '' })
|
|
355
415
|
}
|
|
356
|
-
|
|
416
|
+
if (kept.length === 0) continue
|
|
417
|
+
result.push({ role: msg.role, content: kept })
|
|
357
418
|
}
|
|
358
419
|
}
|
|
359
420
|
|
|
@@ -17,11 +17,16 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
17
17
|
) || 'https://api.openai.com/v1'
|
|
18
18
|
const apiKey = this.resolveApiKey(this.config.apiKey)
|
|
19
19
|
|
|
20
|
+
// Priority: explicit request override (summarizer / sub-agent call sites) >
|
|
21
|
+
// the model's declared ceiling > 8192. The fallback stays: local `ollama`
|
|
22
|
+
// model ids are not in `config.models`, so their real ceiling is unknown.
|
|
23
|
+
const declaredMaxOutput = this.config.models.find((m) => m.id === req.model)?.maxOutput
|
|
24
|
+
|
|
20
25
|
const body = {
|
|
21
26
|
model: req.model,
|
|
22
27
|
messages: this.convertMessages(req.messages, req.systemPrompt),
|
|
23
28
|
stream: true,
|
|
24
|
-
max_tokens: req.maxTokens || 8192,
|
|
29
|
+
max_tokens: req.maxTokens || declaredMaxOutput || 8192,
|
|
25
30
|
temperature: req.temperature,
|
|
26
31
|
tools: req.tools?.map((t) => ({ type: 'function', function: t })),
|
|
27
32
|
}
|
|
@@ -173,6 +178,14 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
173
178
|
if (choice.finish_reason === 'stop') {
|
|
174
179
|
yield { type: 'stop', reasoning_content: reasoningContent }
|
|
175
180
|
}
|
|
181
|
+
|
|
182
|
+
if (choice.finish_reason === 'length') {
|
|
183
|
+
// Truncated: the accumulated tool calls were cut off mid-arguments, so
|
|
184
|
+
// their JSON is incomplete. Drop them rather than dispatching a broken
|
|
185
|
+
// call, and clear the map so the `[DONE]` handler can't emit them either.
|
|
186
|
+
pendingToolCalls.clear()
|
|
187
|
+
yield { type: 'stop', reasoning_content: reasoningContent, truncated: true }
|
|
188
|
+
}
|
|
176
189
|
} catch {
|
|
177
190
|
// skip unparseable chunks
|
|
178
191
|
}
|