@miphamai/cli 0.85.4 → 0.85.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mipham.ts +48 -7
- package/package.json +2 -2
- package/src/agent/agent-context.ts +8 -1
- package/src/agent/effectiveness-tracker.ts +16 -2
- package/src/agent/sub-agent.ts +25 -17
- package/src/agent-view/agents-standalone.tsx +42 -0
- package/src/core/autocomplete.ts +30 -2
- package/src/core/context.ts +61 -6
- package/src/core/dream-engine.ts +17 -2
- package/src/core/engine.ts +95 -34
- package/src/core/error-signature-db.ts +7 -2
- package/src/core/hooks-executor.ts +88 -11
- package/src/core/hooks.ts +26 -2
- package/src/core/instructions.ts +105 -17
- package/src/core/memory/memory-manager.ts +14 -6
- package/src/core/permission-classifier.ts +17 -5
- package/src/core/permission-rules.ts +1 -1
- package/src/core/permission.ts +50 -1
- package/src/core/rule-engine.ts +27 -2
- package/src/core/self-critique.ts +15 -3
- package/src/core/session-log.ts +60 -0
- package/src/daemon/index.ts +2 -5
- package/src/daemon/launch.ts +69 -2
- package/src/daemon/server.ts +19 -14
- package/src/i18n-core/locales/en-US.json +86 -143
- package/src/i18n-core/locales/zh-CN.json +86 -143
- package/src/index.tsx +17 -7
- package/src/mcp/client.ts +61 -0
- package/src/mcp/instructions.ts +49 -0
- package/src/mcp/types.ts +7 -0
- package/src/plugin/claude-plugin.ts +12 -2
- package/src/plugin/plugin-loader.ts +32 -14
- package/src/plugin/plugin-manager.ts +16 -2
- package/src/plugin/plugin-validator.ts +183 -1
- package/src/providers/anthropic.ts +159 -124
- package/src/providers/fetch-utils.ts +65 -34
- package/src/providers/openai-compat.ts +121 -106
- package/src/security/dangerous-rm.ts +192 -0
- package/src/shared/arg-validation.ts +11 -1
- package/src/shared/constants.ts +18 -0
- package/src/shared/deleted-cwd.ts +46 -1
- package/src/shared/package-info.ts +36 -1
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +290 -146
- package/src/ui/command-picker.tsx +18 -10
- package/src/ui/commands.ts +20 -6
- package/src/ui/config-wizard.tsx +22 -19
- package/src/ui/graft-status.tsx +35 -6
- package/src/ui/input.tsx +7 -2
- package/src/ui/picker.tsx +38 -27
- package/src/ui/use-key-state.ts +55 -0
- package/src/daemon/message-bus.ts +0 -84
package/src/core/engine.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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(
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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 <
|
|
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 ===
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
1689
|
-
|
|
1690
|
-
|
|
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.
|
|
1701
|
-
|
|
1702
|
-
|
|
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(
|
|
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
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -87,8 +87,13 @@ export class ErrorSignatureDB {
|
|
|
87
87
|
try {
|
|
88
88
|
if (!existsSync(this.storePath)) return
|
|
89
89
|
const raw = readFileSync(this.storePath, 'utf-8')
|
|
90
|
-
const
|
|
91
|
-
for
|
|
90
|
+
const parsed: unknown = JSON.parse(raw)
|
|
91
|
+
// 形状门:`["x"]` 是合法 JSON、`for…of` 也照收 —— `sig.id` 是 `undefined`,
|
|
92
|
+
// 于是库里躺着一个**没有 id 的成员**(后面 `get(id)` 永远找不到它,
|
|
93
|
+
// 而 `getStats()` 的分母把它算进去)。形状不验 = 垃圾静默入库。
|
|
94
|
+
if (!Array.isArray(parsed)) return
|
|
95
|
+
for (const sig of parsed) {
|
|
96
|
+
if (!sig || typeof sig !== 'object' || typeof sig.id !== 'string') continue
|
|
92
97
|
this.signatures.set(sig.id, sig)
|
|
93
98
|
}
|
|
94
99
|
} catch {
|
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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. */
|
package/src/core/instructions.ts
CHANGED
|
@@ -156,12 +156,86 @@ export function buildPermissionBlock(mode: string): string {
|
|
|
156
156
|
return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode.${escape} If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch modes with Shift+Tab or add an allow rule (/permissions), then wait for the user's answer. Note that Shift+Tab's wheel does not reach bypassPermissions — that mode is set in config, so do not offer it as a keypress.`
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/** Level label used in each prompt part's provenance comment. */
|
|
160
|
+
const LEVEL_LABELS: Record<string, string> = {
|
|
161
|
+
group: 'Group Policy',
|
|
162
|
+
company: 'Company Policy',
|
|
163
|
+
project: 'Project Rules',
|
|
164
|
+
directory: 'Directory Rules',
|
|
165
|
+
user: 'User Preferences',
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The text one loaded file contributes to the system prompt — `prompt-exclude`
|
|
170
|
+
* sections stripped, `privacy: private` files omitted (`null`).
|
|
171
|
+
*
|
|
172
|
+
* Single source for the prompt **and** the size report. A report that measured
|
|
173
|
+
* the file on disk instead would overcount exactly the files this repository
|
|
174
|
+
* writes (its own `prompt-exclude` hides tens of thousands of characters), and
|
|
175
|
+
* the two numbers would drift apart with nothing saying which one is sent.
|
|
176
|
+
*/
|
|
177
|
+
function instructionPartText(inst: InstructionFile): string | null {
|
|
178
|
+
if (inst.privacy === 'private') return null
|
|
179
|
+
const content = stripSections(
|
|
180
|
+
inst.content,
|
|
181
|
+
parsePromptExclude(inst.frontmatter['prompt-exclude']),
|
|
182
|
+
)
|
|
183
|
+
return `<!-- ${LEVEL_LABELS[inst.level] || inst.level} (${inst.path}) -->\n${content}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One file's share of the instruction payload. */
|
|
187
|
+
export interface InstructionSize {
|
|
188
|
+
path: string
|
|
189
|
+
chars: number
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface InstructionSizeReport {
|
|
193
|
+
totalChars: number
|
|
194
|
+
/** Descending by size — the largest contributor first. */
|
|
195
|
+
files: InstructionSize[]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Characters of file-derived instruction text a session sends with **every**
|
|
200
|
+
* request, before the conversation starts. 40,000 is the budget this
|
|
201
|
+
* organisation already writes a single governance file against (the parent
|
|
202
|
+
* `CLAUDE.md`), so the notice fires when everything loaded together has grown
|
|
203
|
+
* past one such file.
|
|
204
|
+
*/
|
|
205
|
+
export const INSTRUCTION_BUDGET_CHARS = 40_000
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The startup notice, or `null` while the payload is within budget.
|
|
209
|
+
*
|
|
210
|
+
* The **total** is the point: no file has to be large for the instruction
|
|
211
|
+
* payload to crowd out the work, so a per-file check cannot see a dozen
|
|
212
|
+
* mid-sized rule files and a lessons block adding up. Naming the largest few
|
|
213
|
+
* is what makes the number actionable.
|
|
214
|
+
*/
|
|
215
|
+
export function formatInstructionSizeNotice(
|
|
216
|
+
report: InstructionSizeReport,
|
|
217
|
+
budget: number = INSTRUCTION_BUDGET_CHARS,
|
|
218
|
+
): string | null {
|
|
219
|
+
if (report.totalChars <= budget) return null
|
|
220
|
+
const num = (n: number) => n.toLocaleString('en-US')
|
|
221
|
+
const shown = report.files.slice(0, 3).map((f) => `${f.path} — ${num(f.chars)}`)
|
|
222
|
+
if (report.files.length > shown.length) shown.push(`+${report.files.length - shown.length} more`)
|
|
223
|
+
return (
|
|
224
|
+
`⚠ Instruction files total ${num(report.totalChars)} characters (budget ${num(budget)}), ` +
|
|
225
|
+
`sent with every request.\n` +
|
|
226
|
+
` Largest: ${shown.join(' · ')}\n` +
|
|
227
|
+
` Trim them, or move doc-only sections under a \`prompt-exclude\` frontmatter key.`
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
159
231
|
export class InstructionsLoader {
|
|
160
232
|
private instructions: InstructionFile[] = []
|
|
161
233
|
private crsiLessonSummaries: CrsiLessonSummary[] = []
|
|
234
|
+
private lessonsPath: string | null = null
|
|
162
235
|
|
|
163
236
|
loadAll(cwd: string): void {
|
|
164
237
|
this.instructions = []
|
|
238
|
+
this.lessonsPath = null
|
|
165
239
|
const root = gitRoot(cwd)
|
|
166
240
|
|
|
167
241
|
// Tier 1: 集团/公司策略(锚定仓库根,从任意子目录启动都正确;不读 AGENTS.md)
|
|
@@ -198,23 +272,12 @@ export class InstructionsLoader {
|
|
|
198
272
|
const parts: string[] = []
|
|
199
273
|
|
|
200
274
|
for (const inst of this.instructions) {
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const levelLabel: Record<string, string> = {
|
|
205
|
-
group: 'Group Policy',
|
|
206
|
-
company: 'Company Policy',
|
|
207
|
-
project: 'Project Rules',
|
|
208
|
-
directory: 'Directory Rules',
|
|
209
|
-
user: 'User Preferences',
|
|
210
|
-
}
|
|
211
|
-
// Strip doc-only sections declared via `prompt-exclude` frontmatter
|
|
275
|
+
// `instructionPartText` honors `privacy: private` (never sent) and strips
|
|
276
|
+
// doc-only sections declared via `prompt-exclude` frontmatter
|
|
212
277
|
// (changelog/roadmap/catalog are human-facing, not machine rules).
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
)
|
|
217
|
-
parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${content}`)
|
|
278
|
+
const text = instructionPartText(inst)
|
|
279
|
+
if (text === null) continue
|
|
280
|
+
parts.push(text)
|
|
218
281
|
}
|
|
219
282
|
|
|
220
283
|
// P2-2 的权限段**不在**这里 —— 见 `buildPermissionBlock` 与
|
|
@@ -371,8 +434,11 @@ Never omit it or present the work as purely human-authored.`)
|
|
|
371
434
|
|
|
372
435
|
/** 读 crsi-lessons.md(按仓库根定位)提取教训精华。读不到则返回空。 */
|
|
373
436
|
private loadCrsiLessons(root: string): CrsiLessonSummary[] {
|
|
437
|
+
const path = join(root, LESSONS_FILE)
|
|
374
438
|
try {
|
|
375
|
-
const content = readFileSync(
|
|
439
|
+
const content = readFileSync(path, 'utf-8')
|
|
440
|
+
// Remember where the recalled text came from — `sizeReport` names it.
|
|
441
|
+
this.lessonsPath = path
|
|
376
442
|
return extractCrsiLessonSummaries(content)
|
|
377
443
|
} catch {
|
|
378
444
|
return []
|
|
@@ -392,6 +458,28 @@ Never omit it or present the work as purely human-authored.`)
|
|
|
392
458
|
return [...this.instructions]
|
|
393
459
|
}
|
|
394
460
|
|
|
461
|
+
/**
|
|
462
|
+
* How much instruction text this loader puts in the system prompt, per file.
|
|
463
|
+
*
|
|
464
|
+
* Read through `instructionPartText` — the same projection `buildSystemPrompt`
|
|
465
|
+
* uses — so the report cannot describe something other than what is sent. The
|
|
466
|
+
* CRSI lessons block counts too: it is rendered from `crsi-lessons.md` and
|
|
467
|
+
* carried on every request like any other rule file.
|
|
468
|
+
*/
|
|
469
|
+
sizeReport(): InstructionSizeReport {
|
|
470
|
+
const files: InstructionSize[] = []
|
|
471
|
+
for (const inst of this.instructions) {
|
|
472
|
+
const text = instructionPartText(inst)
|
|
473
|
+
if (text !== null) files.push({ path: inst.path, chars: text.length })
|
|
474
|
+
}
|
|
475
|
+
if (this.lessonsPath) {
|
|
476
|
+
const lessons = buildCrsiLessonsBlock(this.crsiLessonSummaries)
|
|
477
|
+
if (lessons) files.push({ path: this.lessonsPath, chars: lessons.length })
|
|
478
|
+
}
|
|
479
|
+
files.sort((a, b) => b.chars - a.chars)
|
|
480
|
+
return { totalChars: files.reduce((n, f) => n + f.chars, 0), files }
|
|
481
|
+
}
|
|
482
|
+
|
|
395
483
|
private tryLoad(path: string, level: InstructionFile['level']): void {
|
|
396
484
|
if (!existsSync(path)) return
|
|
397
485
|
|
|
@@ -546,9 +546,13 @@ export class MemoryManager {
|
|
|
546
546
|
const path = join(this.memoryDir, LINKS_FILE)
|
|
547
547
|
if (!existsSync(path)) return false
|
|
548
548
|
try {
|
|
549
|
-
const raw = JSON.parse(readFileSync(path, 'utf-8'))
|
|
549
|
+
const raw: unknown = JSON.parse(readFileSync(path, 'utf-8'))
|
|
550
|
+
// 形状门:值必须是**字符串数组**。`new Set("bc")` 会**按字符**迭代 ⇒ 一条链接被
|
|
551
|
+
// 拆成 'b'、'c' 两条(而 `as string[]` 让 TS 一声不吭)。对象/数组本身也过了门才用。
|
|
552
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false
|
|
550
553
|
for (const [k, v] of Object.entries(raw)) {
|
|
551
|
-
|
|
554
|
+
if (!Array.isArray(v)) continue
|
|
555
|
+
this.linkGraph.set(k, new Set(v.filter((x): x is string => typeof x === 'string')))
|
|
552
556
|
}
|
|
553
557
|
return this.linkGraph.size > 0
|
|
554
558
|
} catch {
|
|
@@ -587,12 +591,16 @@ export class MemoryManager {
|
|
|
587
591
|
const path = join(this.memoryDir, RECALL_STATS_FILE)
|
|
588
592
|
if (!existsSync(path)) return
|
|
589
593
|
try {
|
|
590
|
-
const raw = JSON.parse(readFileSync(path, 'utf-8'))
|
|
594
|
+
const raw: unknown = JSON.parse(readFileSync(path, 'utf-8'))
|
|
595
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return
|
|
591
596
|
for (const [k, v] of Object.entries(raw)) {
|
|
592
|
-
|
|
597
|
+
// 逐条门控(不是整表退回):`catch` 在循环外,一个坏条目会把**后面所有**条目
|
|
598
|
+
// 一起带走 —— 一条脏记录赔上整份召回统计。
|
|
599
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) continue
|
|
600
|
+
const rec = v as { recallCount?: unknown; lastRecalledAt?: unknown }
|
|
593
601
|
this.recallStats.set(k, {
|
|
594
|
-
recallCount: rec.recallCount
|
|
595
|
-
lastRecalledAt: rec.lastRecalledAt
|
|
602
|
+
recallCount: typeof rec.recallCount === 'number' ? rec.recallCount : 0,
|
|
603
|
+
lastRecalledAt: typeof rec.lastRecalledAt === 'string' ? rec.lastRecalledAt : '',
|
|
596
604
|
})
|
|
597
605
|
}
|
|
598
606
|
} catch {
|