@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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/bin/mipham.ts +35 -1
  3. package/package.json +1 -1
  4. package/src/agent/message-bus.ts +10 -3
  5. package/src/agent/sub-agent.ts +60 -12
  6. package/src/agent/types.ts +14 -1
  7. package/src/artifacts/manifest.ts +90 -34
  8. package/src/artifacts/paths.ts +19 -0
  9. package/src/artifacts/server.ts +48 -8
  10. package/src/config/credential-crypto.ts +28 -5
  11. package/src/config/defaults.ts +18 -10
  12. package/src/config/keys-manager.ts +14 -9
  13. package/src/config/loader.ts +202 -63
  14. package/src/config/preferences.ts +5 -2
  15. package/src/core/credential-masker/output-scrub.ts +16 -2
  16. package/src/core/cron-poller.ts +30 -6
  17. package/src/core/engine.ts +7 -2
  18. package/src/core/hooks-executor.ts +30 -2
  19. package/src/core/hooks.ts +51 -4
  20. package/src/core/paths.ts +44 -1
  21. package/src/core/permission-config.ts +146 -14
  22. package/src/core/permission-rules.ts +157 -6
  23. package/src/core/permission.ts +81 -13
  24. package/src/core/rules-loader.ts +35 -5
  25. package/src/core/session-log.ts +49 -2
  26. package/src/core/session-store.ts +11 -1
  27. package/src/core/workspace-trust.ts +42 -4
  28. package/src/daemon/auth.ts +15 -14
  29. package/src/daemon/engine-capabilities.ts +12 -2
  30. package/src/daemon/remote-engine.ts +9 -4
  31. package/src/daemon/server.ts +29 -1
  32. package/src/daemon/session-worker.ts +15 -0
  33. package/src/i18n-core/locales/en-US.json +12 -8
  34. package/src/i18n-core/locales/zh-CN.json +12 -8
  35. package/src/index.tsx +47 -19
  36. package/src/mcp/client.ts +24 -0
  37. package/src/mcp/http-transport.ts +35 -3
  38. package/src/plugin/plugin-manager.ts +30 -8
  39. package/src/providers/anthropic.ts +74 -13
  40. package/src/providers/openai-compat.ts +14 -1
  41. package/src/security/gate.ts +18 -0
  42. package/src/security/path.ts +25 -2
  43. package/src/shared/arg-validation.ts +37 -2
  44. package/src/shared/atomic-write.ts +28 -5
  45. package/src/shared/package-info.ts +1 -1
  46. package/src/shared/sanitize.ts +27 -2
  47. package/src/shared/types.ts +17 -0
  48. package/src/shared/update.ts +22 -5
  49. package/src/tools/agent/agent.ts +3 -0
  50. package/src/tools/artifact/artifact.ts +14 -4
  51. package/src/tools/exec/bash.ts +146 -24
  52. package/src/tools/exec/enter-worktree.ts +9 -3
  53. package/src/tools/exec/exit-worktree.ts +6 -3
  54. package/src/tools/exec/git.ts +83 -3
  55. package/src/tools/file/glob.ts +19 -3
  56. package/src/tools/file/grep.ts +70 -16
  57. package/src/tools/file/read.ts +151 -45
  58. package/src/tools/index.ts +12 -4
  59. package/src/tools/scheduling/cron.ts +34 -5
  60. package/src/tools/system/config.ts +6 -2
  61. package/src/ui/app.tsx +47 -11
  62. package/src/ui/commands.ts +187 -41
  63. package/src/workflow/primitives/agent.ts +4 -0
  64. 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 Restored ─",
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. Older context summarized above.",
322
- "restored_full_footer": "{loaded} messages loaded. Context has been restored.",
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": "To resume a session: /resume <name>",
329
- "resume_last_hint": "To resume most recent: /resume last",
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. The most recent session loads automatically on restart.",
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 resume this session:\n /resume last — restore the most recent session\n mipham --resume \"{target}\"\n\nOr restart Mipham Code — the most recent session loads automatically."
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} / 200,000",
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": "已加载 {loaded}/{total} 条消息。较早的上下文已概括。",
322
- "restored_full_footer": "已加载 {loaded} 条消息。上下文已恢复。",
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": "恢复会话: /resume <name>",
329
- "resume_last_hint": "恢复最近: /resume last",
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恢复此会话:\n /resume last — 恢复最近的会话\n mipham --resume \"{target}\"\n\n或重启 Mipham Code — 最近会话将自动加载。"
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} / 200,000",
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 { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
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(config.permission as string))
499
+ context.setSystemPrompt(instructions.buildSystemPrompt(permission.getMode()))
482
500
  }
483
501
  }
484
502
 
485
503
  if (context.getMessageCount() === 0) {
486
- const basePrompt = instructions.buildSystemPrompt(config.permission as string)
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
- const hookEngine = new HookEngine()
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
- const settingsJson = loadSettingsJson(process.cwd())
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 = join(process.cwd(), MIPHAM_DIR, ARTIFACTS_DIR)
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, undefined, ruleEngine)
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
- // Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
622
- if (config.permission) {
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(body: string): unknown {
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
- messages.push(JSON.parse(payload))
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 { execSync } from 'node:child_process'
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
- execSync(`npm install ${packageName} --prefix "${stagingDir}" --no-save`, {
114
- encoding: 'utf-8',
115
- stdio: 'pipe',
116
- timeout: 60_000,
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
- this.plugins = JSON.parse(readFileSync(this.statePath, 'utf-8'))
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(this.statePath, JSON.stringify(this.plugins, null, 2), 'utf-8')
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[] = [{ type: 'text', text: msg.content }]
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
- result.push({
289
- role: msg.role,
290
- content,
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: block.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
- return { type: 'text', text: '' }
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' && !blocks.some((b: any) => b.type === 'thinking')) {
354
- blocks.unshift({ type: 'thinking', thinking: '' })
413
+ if (msg.role === 'assistant' && !kept.some((b) => b.type === 'thinking')) {
414
+ kept.unshift({ type: 'thinking', thinking: '' })
355
415
  }
356
- result.push({ role: msg.role, content: blocks })
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
  }