@miphamai/cli 0.85.4 → 0.85.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.85.4",
3
+ "version": "0.85.5",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `mipham agents` 独立面板 —— 从 `index.tsx` 里抽出来的那段 JSX,好让它可测。
3
+ *
4
+ * 抽取的理由是**可测性**:`index.tsx` 是进程入口(`render` + `waitUntilExit`),
5
+ * 它里面传下去的东西在测试里够不着;而这条路上的 Enter 落点曾经整个缺席
6
+ * —— 页脚广告 attach,落点是 `() => {}`。
7
+ */
8
+
9
+ import React, { useState } from 'react'
10
+
11
+ import { AgentSessionView } from './session-view'
12
+ import { AgentViewDashboard } from './dashboard'
13
+ import type { AgentViewManager } from './agent-view-manager'
14
+
15
+ interface AgentsStandaloneProps {
16
+ manager: AgentViewManager
17
+ onExit: () => void
18
+ }
19
+
20
+ export function AgentsStandalone({ manager, onExit }: AgentsStandaloneProps) {
21
+ // 与 `app.tsx` 同形:attach 把会话交给只读视图,视图里的 Esc 再交还列表。
22
+ // 页脚无条件印着「Enter attach」—— 它必须有个落点,否则按键有反应而世界不变。
23
+ const [attachedSessionId, setAttachedSessionId] = useState<string | null>(null)
24
+
25
+ if (attachedSessionId) {
26
+ return (
27
+ <AgentSessionView
28
+ manager={manager}
29
+ sessionId={attachedSessionId}
30
+ onDetach={() => setAttachedSessionId(null)}
31
+ />
32
+ )
33
+ }
34
+
35
+ return (
36
+ <AgentViewDashboard
37
+ manager={manager}
38
+ onAttach={(session) => setAttachedSessionId(session.id)}
39
+ onExit={onExit}
40
+ />
41
+ )
42
+ }
@@ -8,6 +8,7 @@ import {
8
8
  deriveMessages,
9
9
  assertModelVisible,
10
10
  isAssertModelVisibleDebug,
11
+ closeInterruptedToolCalls,
11
12
  } from './session-log'
12
13
 
13
14
  export type Summarizer = (messages: Message[], heading: string) => Promise<string>
@@ -45,6 +46,12 @@ export class ContextManager {
45
46
  * `index.tsx` 把它接到 live `PermissionSystem.getMode()` 上,于是切一次档,下一次请求就变。
46
47
  */
47
48
  private permissionContextSource: (() => string) | null = null
49
+ /**
50
+ * 系统提示里的 **MCP instructions 段**同样是读时派生的,理由比权限段更硬:
51
+ * MCP server 是**启动后异步连上**的,而提示在 `setSystemPrompt()` 那一刻就建好了。
52
+ * 组装时烘进去的话,本次会话里后连上的 server 永远进不了提示 —— 用户只能重启。
53
+ */
54
+ private mcpInstructionsSource: (() => string) | null = null
48
55
  private estimatedTokens = 0
49
56
  private checkpoints: Checkpoint[] = []
50
57
  private checkpointCounter = 0
@@ -79,6 +86,12 @@ export class ContextManager {
79
86
  /** 从已持久化的日志恢复:设 log 为源,messages 为投影(不重复写通)。 */
80
87
  restoreLog(log: SessionLog): void {
81
88
  this.log = log
89
+ // 盘上那份历史可能以一条**没有结果**的调用收尾(写盘写到一半被打断,末尾那行半截
90
+ // 结果被 `open()` 静默丢掉 —— 引擎自己的日志顺序到不了这个形状,见下面那个函数的
91
+ // 注释)。挂着 `tool_calls` 却没有结果回应的请求会被 provider 整条拒收,用户看到的
92
+ // 是「恢复之后第一句话就报协议错」。恢复的这一刻把它补**进日志**(不是补进投影,
93
+ // 否则「模型看得见的必须已记录」这条不变量当场破),见 `closeInterruptedToolCalls`。
94
+ closeInterruptedToolCalls(log)
82
95
  this.messages = deriveMessages(log.events())
83
96
  this.reEstimateTokens()
84
97
  }
@@ -138,14 +151,28 @@ export class ContextManager {
138
151
  }
139
152
 
140
153
  /**
141
- * 存储的提示 + 读时派生的权限段。
154
+ * 接线点(`index.tsx`):把已连 MCP server 自带的 `instructions` 接到提示上。
142
155
  *
143
- * 段尾追加(而非插回原来的中段位置)是刻意的:只切档时**前缀保持不变**,
144
- * 提供方的 prefix cache 仍能命中到权限段之前的部分。
156
+ * 传 `null` 撤销。空串(无 server / 都没写 instructions)不产生空段。
157
+ */
158
+ setMcpInstructionsSource(fn: (() => string) | null): void {
159
+ this.mcpInstructionsSource = fn
160
+ }
161
+
162
+ /**
163
+ * 存储的提示 + 读时派生的段(权限 / MCP instructions)。
164
+ *
165
+ * 段尾追加(而非插回原来的中段位置)是刻意的:只切档、只连一个新 server 时
166
+ * **前缀保持不变**,提供方的 prefix cache 仍能命中到这些段之前的部分。
145
167
  */
146
168
  private composedSystemPrompt(): string {
147
- const block = this.permissionContextSource?.() ?? ''
148
- return block ? `${this.systemPrompt}\n\n---\n\n${block}` : this.systemPrompt
169
+ const blocks = [
170
+ this.permissionContextSource?.() ?? '',
171
+ this.mcpInstructionsSource?.() ?? '',
172
+ ].filter((b) => b !== '')
173
+ return blocks.length > 0
174
+ ? [this.systemPrompt, ...blocks].join('\n\n---\n\n')
175
+ : this.systemPrompt
149
176
  }
150
177
 
151
178
  getSystemPrompt(): string {
@@ -170,6 +197,30 @@ export class ContextManager {
170
197
  this.checkCompression()
171
198
  }
172
199
 
200
+ /**
201
+ * 注入一段**模型可见的上下文**(规则块 / compact 前后的 hook 提示 / stop hook 交回的话 /
202
+ * 后台 agent 的来件)。
203
+ *
204
+ * 为什么不复用 addMessage:那会把注入记成 `user/message`,在日志里与**用户真说过的话**
205
+ * 完全同形 —— 事后翻 session log 分不出哪一句是用户敲的、哪一句是我们塞进去的。记成
206
+ * `context/inject`(带 `source`)就带得出来源;而 `deriveMessages` 仍把它还原成同一个
207
+ * user 消息,所以**投影逐字节不变**,「model-visible means logged」照样成立。
208
+ */
209
+ injectContext(source: string, text: string): void {
210
+ this.messages.push({ role: 'user', content: text })
211
+ this.estimatedTokens += this.estimateTokens(text)
212
+
213
+ if (this.log) {
214
+ this.log.append({ type: 'context/inject', at: Date.now(), source, text })
215
+ }
216
+
217
+ if (this.log && isAssertModelVisibleDebug()) {
218
+ assertModelVisible(this.log.events(), this.messages)
219
+ }
220
+
221
+ this.checkCompression()
222
+ }
223
+
173
224
  /** 记录工具执行结果(全量 ToolResult 含 success/error)到日志,并写投影消息(不重复走 messageToEvents 拆分)。 */
174
225
  addToolResult(toolUseId: string, result: ToolResult): void {
175
226
  const content = result.success ? result.content : result.error || result.content
@@ -43,6 +43,7 @@ import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './infe
43
43
  import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
44
44
  import { registerWakeupHandler } from '../tools/scheduling/schedule-wakeup'
45
45
  import { accumulateGraftSavings } from '../shared/graft-savings'
46
+ import { deletedCwdSessionMessage, resolveExistingCwd } from '../shared/deleted-cwd'
46
47
  import {
47
48
  getMessageBus,
48
49
  formatInboundMessage,
@@ -60,6 +61,18 @@ const bundles: Record<string, TranslationMap> = {
60
61
  }
61
62
  const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
62
63
 
64
+ /**
65
+ * Tool-calling rounds one **turn** may spend, however it spends them. 20 was too
66
+ * low for real multi-step tasks — the model hit "max turns" and dropped pending
67
+ * tools mid-task. 100 stays bounded.
68
+ *
69
+ * A budget for the turn, not for one loop: a blocking Stop hook buys another round
70
+ * out of the same pool. The earlier shape re-granted a fresh 100 per loop
71
+ * invocation, so the cap bounded no turn at all — a hook that always asked for more
72
+ * kept the turn running until the process died.
73
+ */
74
+ const MAX_TOOL_TURNS = 100
75
+
63
76
  /**
64
77
  * Drop inbound messages whose timestamp is older than the dialog-expiry TTL.
65
78
  * A stale message's approval dialog is no longer relevant, so it's discarded
@@ -296,7 +309,7 @@ export class QueryEngine {
296
309
  let injected = 0
297
310
  for (const recipient of recipients) {
298
311
  for (const msg of bus.poll(recipient)) {
299
- this.context.addMessage({ role: 'user', content: formatInboundMessage(msg) })
312
+ this.context.injectContext(`agent:${msg.from}`, formatInboundMessage(msg))
300
313
  injected++
301
314
  }
302
315
  bus.markAllRead(recipient)
@@ -389,7 +402,7 @@ export class QueryEngine {
389
402
  const files = Array.from(this.touchedFiles)
390
403
  const block = this.rulesLoader.buildContextBlock(files)
391
404
  if (!block) return
392
- this.context.addMessage({ role: 'user', content: block })
405
+ this.context.injectContext('rules', block)
393
406
  this.touchedFiles.clear()
394
407
  }
395
408
 
@@ -789,14 +802,15 @@ export class QueryEngine {
789
802
  yield chunk
790
803
  }
791
804
 
792
- // If tools were executed, recursively continue the conversation
805
+ // If tools were executed, recursively continue the conversation — with what
806
+ // is left of the turn's budget after the round just spent here.
793
807
  if (toolUses.length > 0) {
794
- yield* this.continueWithTools(signal)
808
+ yield* this.continueWithTools(signal, MAX_TOOL_TURNS - 1)
795
809
  return
796
810
  }
797
811
 
798
812
  // Fire Stop hooks when AI finishes with no tool calls
799
- yield* this.checkStopHook(signal)
813
+ yield* this.checkStopHook(signal, MAX_TOOL_TURNS - 1)
800
814
 
801
815
  // Final drain of task notifications
802
816
  for (const chunk of this.drainTaskNotifications()) {
@@ -883,17 +897,17 @@ export class QueryEngine {
883
897
  }
884
898
  }
885
899
 
886
- private async *continueWithTools(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
887
- // Tool-calling round cap. 20 was too low for real multi-step tasks — the model
888
- // hit "max turns" and dropped pending tools mid-task. 100 stays bounded.
889
- const MAX_TURNS = 100
900
+ private async *continueWithTools(
901
+ signal?: AbortSignal,
902
+ roundsLeft: number = MAX_TOOL_TURNS,
903
+ ): AsyncGenerator<StreamChunk> {
890
904
  // Task-level stall guard: a turn that produces no text or tool result for
891
905
  // this long is considered stalled and stopped (prevents ~40-min idle spins).
892
906
  const TURN_TIMEOUT_MS = 15 * 60 * 1000
893
907
  let lastActivity = Date.now()
894
908
  const toolDefs = this.getToolDefinitions()
895
909
 
896
- for (let turn = 0; turn < MAX_TURNS; turn++) {
910
+ for (let turn = 0; turn < roundsLeft; turn++) {
897
911
  if (Date.now() - lastActivity > TURN_TIMEOUT_MS) {
898
912
  yield {
899
913
  type: 'warning',
@@ -1008,11 +1022,11 @@ export class QueryEngine {
1008
1022
  }
1009
1023
 
1010
1024
  // Safety: when max turns reached with pending tools, ask model to summarize
1011
- if (turn === MAX_TURNS - 1 && toolUses.length > 0) {
1025
+ if (turn === roundsLeft - 1 && toolUses.length > 0) {
1012
1026
  this.context.addMessage({
1013
1027
  role: 'user',
1014
1028
  content: t('errors.max_tool_turns_warning', {
1015
- max: String(MAX_TURNS),
1029
+ max: String(MAX_TOOL_TURNS),
1016
1030
  pending: String(toolUses.length),
1017
1031
  }),
1018
1032
  })
@@ -1033,7 +1047,7 @@ export class QueryEngine {
1033
1047
  } catch {
1034
1048
  yield {
1035
1049
  type: 'error',
1036
- error: t('errors.max_tool_turns', { max: String(MAX_TURNS) }),
1050
+ error: t('errors.max_tool_turns', { max: String(MAX_TOOL_TURNS) }),
1037
1051
  }
1038
1052
  }
1039
1053
  return
@@ -1041,7 +1055,7 @@ export class QueryEngine {
1041
1055
 
1042
1056
  // No more tool calls — fire Stop hook and potentially continue
1043
1057
  if (toolUses.length === 0) {
1044
- yield* this.checkStopHook(signal)
1058
+ yield* this.checkStopHook(signal, roundsLeft - turn - 1)
1045
1059
  return
1046
1060
  }
1047
1061
 
@@ -1228,8 +1242,24 @@ export class QueryEngine {
1228
1242
  }
1229
1243
 
1230
1244
  try {
1245
+ // Every tool is handed the session's working directory, and that directory
1246
+ // can be deleted while the session runs. Nothing upstream notices: Node
1247
+ // throws from whichever call site touches it first, Bun keeps handing back
1248
+ // the path it cached at startup, and the tool then fails at its first
1249
+ // syscall naming whatever it touched (`spawn /bin/sh ENOENT` blames the
1250
+ // shell). Refuse here, where the reason is still known.
1251
+ const cwd = resolveExistingCwd()
1252
+ if (!cwd) {
1253
+ // Both fields carry the message on purpose: a failed result is projected
1254
+ // as `error || content` (the two tool-result loops and the context log all
1255
+ // read it that way), so guidance parked in `content` alone would be
1256
+ // dropped exactly when it is needed.
1257
+ const reason = deletedCwdSessionMessage()
1258
+ return { success: false, content: reason, error: reason }
1259
+ }
1260
+
1231
1261
  const result = await tool.execute(effectiveParams, {
1232
- cwd: process.cwd(),
1262
+ cwd,
1233
1263
  sessionId: this.sessionId,
1234
1264
  provider: this.registry.getActive().config.id,
1235
1265
  model: this.registry.getActiveModel(),
@@ -1373,9 +1403,21 @@ export class QueryEngine {
1373
1403
  this.llm = llm
1374
1404
  }
1375
1405
 
1376
- /** 统一 chat 出口:优先走注入的 Llm 缝,否则回退 registry。 */
1406
+ /**
1407
+ * 统一 chat 出口:优先走注入的 Llm 缝,否则回退 registry。
1408
+ *
1409
+ * 被截断的一轮在这里点名。`truncated` 只有 provider 能判(被上限截断、连接
1410
+ * 被干净地关掉、正常写完,在字节流上是同一个形状),而这里是**每一处消费
1411
+ * chat 流的地方都必经的唯一出口** —— 转发的循环有两个(`process` 与
1412
+ * `continueWithTools`),各自补一次正是「两条渲染路径只接一条」的老毛病。
1413
+ */
1377
1414
  private async *llmChat(req: ChatRequest): AsyncGenerator<StreamChunk> {
1378
- yield* (this.llm ?? this.registry).chat(req)
1415
+ for await (const chunk of (this.llm ?? this.registry).chat(req)) {
1416
+ if (chunk.type === 'stop' && chunk.truncated) {
1417
+ yield { type: 'warning', content: t('errors.turn_truncated') }
1418
+ }
1419
+ yield chunk
1420
+ }
1379
1421
  }
1380
1422
 
1381
1423
  /**
@@ -1494,13 +1536,15 @@ export class QueryEngine {
1494
1536
  : t('errors.tool_denied_classifier', { name, reason })
1495
1537
  }
1496
1538
 
1497
- const { reason, rulePattern } = this.permission.explainDenial(tool, params)
1539
+ const { reason, rulePattern, target } = this.permission.explainDenial(tool, params)
1498
1540
  const mode = this.permission.getMode()
1499
1541
  switch (reason) {
1500
1542
  case 'deny-rule':
1501
1543
  return t('errors.tool_denied_deny_rule', { name, pattern: rulePattern ?? '?' })
1502
1544
  case 'ask-rule':
1503
1545
  return t('errors.tool_denied_ask_rule', { name, pattern: rulePattern ?? '?' })
1546
+ case 'dangerous-rm':
1547
+ return t('errors.tool_denied_dangerous_rm', { name, target: target ?? '?' })
1504
1548
  default:
1505
1549
  // mode-baseline / tool-default / legacy-rule / system-default — a mode
1506
1550
  // switch (or /permissions) resolves it.
@@ -1685,10 +1729,10 @@ export class QueryEngine {
1685
1729
  if (this.hookEngine) {
1686
1730
  const preResult = await this.hookEngine.executePreCompact(this.sessionId)
1687
1731
  if (preResult.additionalContext) {
1688
- this.context.addMessage({
1689
- role: 'user',
1690
- content: `[Pre-compact context]: ${preResult.additionalContext}`,
1691
- })
1732
+ this.context.injectContext(
1733
+ 'pre-compact',
1734
+ `[Pre-compact context]: ${preResult.additionalContext}`,
1735
+ )
1692
1736
  }
1693
1737
  }
1694
1738
 
@@ -1697,29 +1741,46 @@ export class QueryEngine {
1697
1741
  if (this.hookEngine) {
1698
1742
  const postResult = await this.hookEngine.executePostCompact(this.sessionId)
1699
1743
  if (postResult.additionalContext) {
1700
- this.context.addMessage({
1701
- role: 'user',
1702
- content: `[Post-compact context]: ${postResult.additionalContext}`,
1703
- })
1744
+ this.context.injectContext(
1745
+ 'post-compact',
1746
+ `[Post-compact context]: ${postResult.additionalContext}`,
1747
+ )
1704
1748
  }
1705
1749
  }
1706
1750
  }
1707
1751
 
1708
1752
  /** Fire Stop hook. If blocked, feed the reason back to the AI and continue. */
1709
- private async *checkStopHook(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
1753
+ private async *checkStopHook(
1754
+ signal?: AbortSignal,
1755
+ roundsLeft: number = MAX_TOOL_TURNS,
1756
+ ): AsyncGenerator<StreamChunk> {
1710
1757
  if (!this.hookEngine) return
1711
1758
 
1712
1759
  const stopResult = await this.hookEngine.executeStop(this.sessionId)
1713
- if (stopResult.decision === 'block') {
1714
- // Feed the block reason back to the AI and continue
1715
- this.context.addMessage({
1716
- role: 'user',
1717
- content: t('system.context.stop_blocked', {
1760
+ if (stopResult.decision !== 'block') return
1761
+
1762
+ // A block asks for more work, and more work means another round — which the
1763
+ // turn may have none of left. Saying so is the point: a hook quietly ignored
1764
+ // is worse than one told it has been overruled.
1765
+ if (roundsLeft <= 0) {
1766
+ yield {
1767
+ type: 'warning',
1768
+ content: t('errors.stop_hook_budget_spent', {
1769
+ max: String(MAX_TOOL_TURNS),
1718
1770
  reason: stopResult.reason || 'Continue working.',
1719
1771
  }),
1720
- })
1721
- yield* this.continueWithTools(signal)
1772
+ }
1773
+ return
1722
1774
  }
1775
+
1776
+ // Feed the block reason back to the AI and continue
1777
+ this.context.injectContext(
1778
+ 'stop-hook',
1779
+ t('system.context.stop_blocked', {
1780
+ reason: stopResult.reason || 'Continue working.',
1781
+ }),
1782
+ )
1783
+ yield* this.continueWithTools(signal, roundsLeft)
1723
1784
  }
1724
1785
  }
1725
1786
 
@@ -1,4 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process'
2
+ import { McpClient } from '../mcp/client'
3
+ import type { ToolCallResult } from '../mcp/types'
2
4
  import type { HookConfig, HookContext, HookResult } from '../shared/index.ts'
3
5
 
4
6
  /**
@@ -7,13 +9,17 @@ import type { HookConfig, HookContext, HookResult } from '../shared/index.ts'
7
9
  * Supported types:
8
10
  * - command: Execute a shell command. Exit code 0 = allow, 2 = block with stderr as reason.
9
11
  * - http: POST to a URL, response body becomes additionalContext.
10
- * - mcp_tool: Call an MCP tool (delegates to MCP client -- stub for now).
12
+ * - mcp_tool: Call the MCP tool the hook names; its answer is read as the hook's.
11
13
  * - code: No-op (handled inline by the handler function directly).
12
14
  */
13
- export async function executeHook(cfg: HookConfig, ctx: HookContext): Promise<HookResult> {
15
+ export async function executeHook(
16
+ cfg: HookConfig,
17
+ ctx: HookContext,
18
+ source?: string,
19
+ ): Promise<HookResult> {
14
20
  switch (cfg.type) {
15
21
  case 'command':
16
- return executeCommand(cfg, ctx)
22
+ return executeCommand(cfg, ctx, source)
17
23
  case 'http':
18
24
  return executeHttp(cfg, ctx)
19
25
  case 'mcp_tool':
@@ -155,7 +161,26 @@ function spawnFailureCause(
155
161
  return null
156
162
  }
157
163
 
158
- async function executeCommand(cfg: HookConfig, ctx: HookContext): Promise<HookResult> {
164
+ /**
165
+ * The handle a failure message points the operator at: the command, and — when the
166
+ * hook came from a plugin rather than from the operator's own settings — who
167
+ * declared it.
168
+ *
169
+ * The command alone does not answer "which plugin do I look at". A plugin hook is
170
+ * typically `sh`, `node`, or a path under the plugin's root; none of those is a
171
+ * name the operator can search for. Two arguments rather than a pre-joined string
172
+ * because the parentheses and the `from` clause have to stay one decision: a
173
+ * caller that built half the label would be free to print `from "undefined"`.
174
+ */
175
+ function failingLabel(command: string | undefined, source?: string): string {
176
+ return `(${command})${source ? ` from "${source}"` : ''}`
177
+ }
178
+
179
+ async function executeCommand(
180
+ cfg: HookConfig,
181
+ ctx: HookContext,
182
+ source?: string,
183
+ ): Promise<HookResult> {
159
184
  if (!cfg.command) return { allowed: true }
160
185
 
161
186
  try {
@@ -220,14 +245,14 @@ async function executeCommand(cfg: HookConfig, ctx: HookContext): Promise<HookRe
220
245
  if (failure) {
221
246
  return {
222
247
  allowed: true,
223
- additionalContext: `Hook error (${cfg.command}): ${failure}`,
248
+ additionalContext: `Hook error ${failingLabel(cfg.command, source)}: ${failure}`,
224
249
  }
225
250
  }
226
251
 
227
252
  // Other non-zero exit: don't block, log the error as context
228
253
  return {
229
254
  allowed: true,
230
- additionalContext: `Hook warning (${cfg.command}): ${stderr.trim()}`,
255
+ additionalContext: `Hook warning ${failingLabel(cfg.command, source)}: ${stderr.trim()}`,
231
256
  }
232
257
  } catch (err) {
233
258
  // Only reached when `spawnSync` itself throws — masking-policy load, env
@@ -237,7 +262,7 @@ async function executeCommand(cfg: HookConfig, ctx: HookContext): Promise<HookRe
237
262
 
238
263
  return {
239
264
  allowed: true,
240
- additionalContext: `Hook error (${cfg.command}): ${message}`,
265
+ additionalContext: `Hook error ${failingLabel(cfg.command, source)}: ${message}`,
241
266
  }
242
267
  }
243
268
  }
@@ -282,8 +307,60 @@ async function executeHttp(cfg: HookConfig, ctx: HookContext): Promise<HookResul
282
307
  }
283
308
  }
284
309
 
285
- async function executeMcpTool(_cfg: HookConfig, _ctx: HookContext): Promise<HookResult> {
286
- // Stub: MCP tool hook execution requires MCP client integration.
287
- // For now, return allow to not block execution.
288
- return { allowed: true }
310
+ /**
311
+ * An `mcp_tool` hook: call the tool the hook names, and read its answer as the
312
+ * hook's own.
313
+ *
314
+ * The answer is read by the same contract a command hook's stdout follows — a
315
+ * structured decision decides, plain prose is context — so a tool that guards a
316
+ * tool call can block it the way a script would. An `isError` result is *not* a
317
+ * decision: it means the call did not speak, and an unreachable server reports
318
+ * the same way, so its message is reported rather than read as a verdict.
319
+ */
320
+ async function executeMcpTool(cfg: HookConfig, ctx: HookContext): Promise<HookResult> {
321
+ if (!cfg.mcpServer || !cfg.mcpTool) return { allowed: true }
322
+
323
+ const client = McpClient.getInstance()
324
+
325
+ // Startup connects servers without blocking; this hook can arrive first.
326
+ if (!(await client.waitUntilReady(cfg.mcpServer))) {
327
+ return {
328
+ allowed: true,
329
+ additionalContext: `MCP hook (${cfg.mcpServer}/${cfg.mcpTool}): server "${cfg.mcpServer}" was still connecting — the tool was not called.`,
330
+ }
331
+ }
332
+
333
+ const result = await client.callTool(cfg.mcpServer, cfg.mcpTool, {
334
+ event: ctx.event,
335
+ toolName: ctx.toolName,
336
+ toolInput: ctx.toolInput,
337
+ sessionId: ctx.sessionId,
338
+ })
339
+ const body = mcpResultText(result)
340
+
341
+ if (result.isError) {
342
+ return {
343
+ allowed: true,
344
+ additionalContext: `MCP hook error (${cfg.mcpServer}/${cfg.mcpTool}): ${body.slice(0, 2000)}`,
345
+ }
346
+ }
347
+
348
+ const parsed = parseHookStdout(body, ctx)
349
+ const decided =
350
+ !parsed.allowed ||
351
+ parsed.additionalContext !== undefined ||
352
+ parsed.permissionDecision !== undefined ||
353
+ parsed.modifiedInput !== undefined
354
+
355
+ // Nothing in the hook contract matched, so the tool answered in prose: that
356
+ // answer is the context this hook contributes, not a silent no-op.
357
+ return decided ? parsed : { allowed: true, additionalContext: body.slice(0, 2000) || undefined }
358
+ }
359
+
360
+ /** The text an MCP tool call returned; non-text parts carry no message for a hook. */
361
+ function mcpResultText(result: ToolCallResult): string {
362
+ return result.content
363
+ .map((part) => part.text ?? '')
364
+ .filter(Boolean)
365
+ .join('\n')
289
366
  }
package/src/core/hooks.ts CHANGED
@@ -70,6 +70,18 @@ export class HookEngine {
70
70
  )
71
71
  }
72
72
 
73
+ /**
74
+ * Remove every hook a given source declared, and nothing else.
75
+ *
76
+ * `unregister(event)` matches on the event alone, so a caller that wanted to undo
77
+ * its own registrations took down every hook on those events — the operator's own
78
+ * from settings, and other plugins'. Scoping by source is the only removal that
79
+ * answers the question the caller is actually asking.
80
+ */
81
+ unregisterSource(source: string): void {
82
+ this.hooks = this.hooks.filter((h) => h.source !== source)
83
+ }
84
+
73
85
  // ── Existing event executors ──
74
86
 
75
87
  async executePreToolUse(
@@ -217,9 +229,21 @@ export class HookEngine {
217
229
 
218
230
  // ── Health & Resilience ──
219
231
 
220
- /** Get a hook health key for tracking. */
232
+ /**
233
+ * Get a hook health key for tracking.
234
+ *
235
+ * Health is per hook, and the key is what says which hook. It was the event (plus
236
+ * the tool name), which is a *class* of hooks rather than one of them: two hooks
237
+ * on the same event shared one failure counter and one disabled flag, so five
238
+ * failures from a plugin's broken hook could auto-disable an unrelated hook that
239
+ * had never failed. The source is what makes the key name one hook.
240
+ *
241
+ * A hook with no source keeps the string it has always had — `/hooks enable`
242
+ * takes these keys, and a key for a hook that has no plugin must not renumber.
243
+ */
221
244
  private healthKey(hook: HookDefinition): string {
222
- return hook.toolName ? `${hook.event}:${hook.toolName}` : hook.event
245
+ const base = hook.toolName ? `${hook.event}:${hook.toolName}` : hook.event
246
+ return hook.source ? `${hook.source}:${base}` : base
223
247
  }
224
248
 
225
249
  /** Check if a hook should be skipped due to repeated failures. */