@miphamai/cli 0.81.5 → 0.81.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/package.json +1 -1
- package/src/core/engine.ts +13 -0
- package/src/core/permission-rules.ts +24 -0
- package/src/daemon/remote-engine.ts +3 -0
- package/src/mcp/client.ts +105 -6
- package/src/shared/package-info.ts +1 -1
- package/src/ui/app.tsx +7 -0
package/package.json
CHANGED
package/src/core/engine.ts
CHANGED
|
@@ -193,6 +193,19 @@ export class QueryEngine {
|
|
|
193
193
|
this.effort = level
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Forget which files the Read tool has loaded.
|
|
198
|
+
*
|
|
199
|
+
* The read-before-write guard (`tools/file/write.ts`) refuses to overwrite a
|
|
200
|
+
* file this session has not read. That record is per-conversation, so it has
|
|
201
|
+
* to be dropped whenever the conversation is not the same one anymore —
|
|
202
|
+
* `/clear` and `/resume` both replace the message history. Carrying it over
|
|
203
|
+
* let a fresh conversation silently overwrite a file it had never read.
|
|
204
|
+
*/
|
|
205
|
+
resetFileTracking(): void {
|
|
206
|
+
this.readFiles.clear()
|
|
207
|
+
}
|
|
208
|
+
|
|
196
209
|
/** Set the session identifier (called from index.tsx at startup with the actual session name). */
|
|
197
210
|
setSessionId(id: string): void {
|
|
198
211
|
this.sessionId = id
|
|
@@ -43,6 +43,30 @@ const READER_COMMANDS = new Set([
|
|
|
43
43
|
'view',
|
|
44
44
|
'zcat',
|
|
45
45
|
'bzcat',
|
|
46
|
+
// Text formatters/transforms that take an input file and write to stdout.
|
|
47
|
+
// These were missing, so `Read(secret)` was bypassed by `fmt secret` /
|
|
48
|
+
// `column -t secret` — the file sits after the command's own options.
|
|
49
|
+
'fmt',
|
|
50
|
+
'column',
|
|
51
|
+
'pr',
|
|
52
|
+
'fold',
|
|
53
|
+
'expand',
|
|
54
|
+
'unexpand',
|
|
55
|
+
'rev',
|
|
56
|
+
'look',
|
|
57
|
+
'bat',
|
|
58
|
+
// Structured readers and byte-level utilities that read their file argument.
|
|
59
|
+
'jq',
|
|
60
|
+
'yq',
|
|
61
|
+
'base64',
|
|
62
|
+
'md5sum',
|
|
63
|
+
'sha1sum',
|
|
64
|
+
'sha256sum',
|
|
65
|
+
'shasum',
|
|
66
|
+
'cksum',
|
|
67
|
+
'sum',
|
|
68
|
+
'cmp',
|
|
69
|
+
'iconv',
|
|
46
70
|
])
|
|
47
71
|
|
|
48
72
|
/** Commands that write/modify a file (bypass Write()/Edit() rules today). */
|
|
@@ -227,6 +227,9 @@ export class RemoteEngine {
|
|
|
227
227
|
/** No-op: reasoning effort is managed by the daemon session. */
|
|
228
228
|
setEffort(_level: string): void {}
|
|
229
229
|
|
|
230
|
+
/** No-op: file-read tracking lives with the daemon's own engine. */
|
|
231
|
+
resetFileTracking(): void {}
|
|
232
|
+
|
|
230
233
|
/** Remote mode has no local agent registry. */
|
|
231
234
|
getAgentRegistry(): undefined {
|
|
232
235
|
return undefined
|
package/src/mcp/client.ts
CHANGED
|
@@ -31,6 +31,16 @@ function connectTimeoutMs(): number {
|
|
|
31
31
|
return Number.isFinite(env) && env > 0 ? env : DEFAULT_CONNECT_TIMEOUT_MS
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// A server may emit `notifications/tools/list_changed` once per tool it adds, or
|
|
35
|
+
// in a tight loop. Each notification used to trigger its own `tools/list` round
|
|
36
|
+
// trip plus a full downstream re-registration, so a burst produced sustained CPU
|
|
37
|
+
// and a re-registration storm. Notifications are coalesced into one refresh per
|
|
38
|
+
// window instead.
|
|
39
|
+
const TOOLS_CHANGED_DEBOUNCE_MS = 250
|
|
40
|
+
// Ceiling on the coalescing window — a server that notifies without pause would
|
|
41
|
+
// otherwise keep pushing the refresh out forever.
|
|
42
|
+
const TOOLS_CHANGED_MAX_DELAY_MS = 2_000
|
|
43
|
+
|
|
34
44
|
interface ActiveConnection {
|
|
35
45
|
config: McpServerConfig
|
|
36
46
|
transport: Transport
|
|
@@ -39,6 +49,14 @@ interface ActiveConnection {
|
|
|
39
49
|
tools: ToolDefinition[]
|
|
40
50
|
serverInfo?: { name: string; version: string }
|
|
41
51
|
error?: string
|
|
52
|
+
/** Coalescing timer for `tools/list_changed` (see scheduleToolsRefresh). */
|
|
53
|
+
toolsRefreshTimer?: ReturnType<typeof setTimeout>
|
|
54
|
+
/** When the last refresh started — caps the coalescing window. */
|
|
55
|
+
toolsRefreshedAt?: number
|
|
56
|
+
/** A `tools/list` round trip is in flight. */
|
|
57
|
+
toolsRefreshInFlight?: boolean
|
|
58
|
+
/** A notification arrived mid-flight: run exactly one more refresh after. */
|
|
59
|
+
toolsRefreshQueued?: boolean
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
/**
|
|
@@ -96,8 +114,19 @@ export class McpClient {
|
|
|
96
114
|
})
|
|
97
115
|
}
|
|
98
116
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Handle a `tools/list_changed` notification — diff and re-register.
|
|
119
|
+
*
|
|
120
|
+
* Returns immediately: the actual `tools/list` round trip is coalesced
|
|
121
|
+
* (see `scheduleToolsRefresh`), so a burst of notifications does not fan out
|
|
122
|
+
* into a burst of round trips.
|
|
123
|
+
*/
|
|
124
|
+
onToolsChanged(name: string): void {
|
|
125
|
+
this.scheduleToolsRefresh(name)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Fetch the tool list and emit `tools-changed` when it actually differs. */
|
|
129
|
+
private async applyToolsChanged(name: string): Promise<void> {
|
|
101
130
|
const connection = this.connections.get(name)
|
|
102
131
|
if (!connection || connection.status !== 'connected') return
|
|
103
132
|
|
|
@@ -115,6 +144,70 @@ export class McpClient {
|
|
|
115
144
|
}
|
|
116
145
|
}
|
|
117
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Coalesce a `tools/list_changed` notification into a single refresh.
|
|
149
|
+
*
|
|
150
|
+
* Rapid notifications (one per added tool, or a server stuck in a loop) become
|
|
151
|
+
* one `tools/list` round trip per window rather than one each. The window is
|
|
152
|
+
* capped by `TOOLS_CHANGED_MAX_DELAY_MS` so a server that never stops notifying
|
|
153
|
+
* still gets refreshed at a bounded rate instead of being starved forever.
|
|
154
|
+
*/
|
|
155
|
+
private scheduleToolsRefresh(name: string): void {
|
|
156
|
+
const connection = this.connections.get(name)
|
|
157
|
+
if (!connection || connection.status !== 'connected') return
|
|
158
|
+
|
|
159
|
+
if (connection.toolsRefreshInFlight) {
|
|
160
|
+
// Don't drop a change that landed during the round trip — queue one more.
|
|
161
|
+
connection.toolsRefreshQueued = true
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if (connection.toolsRefreshTimer) return // already scheduled in this window
|
|
165
|
+
|
|
166
|
+
const elapsed = Date.now() - (connection.toolsRefreshedAt ?? 0)
|
|
167
|
+
const delay = Math.min(
|
|
168
|
+
TOOLS_CHANGED_DEBOUNCE_MS,
|
|
169
|
+
Math.max(0, TOOLS_CHANGED_MAX_DELAY_MS - elapsed),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
connection.toolsRefreshTimer = undefined
|
|
174
|
+
void this.runToolsRefresh(name)
|
|
175
|
+
}, delay)
|
|
176
|
+
// Housekeeping only — it must not hold the CLI process open.
|
|
177
|
+
timer.unref()
|
|
178
|
+
connection.toolsRefreshTimer = timer
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Run a coalesced refresh, then drain a notification that arrived mid-flight. */
|
|
182
|
+
private async runToolsRefresh(name: string): Promise<void> {
|
|
183
|
+
const connection = this.connections.get(name)
|
|
184
|
+
if (!connection || connection.status !== 'connected') return
|
|
185
|
+
|
|
186
|
+
connection.toolsRefreshedAt = Date.now()
|
|
187
|
+
connection.toolsRefreshInFlight = true
|
|
188
|
+
try {
|
|
189
|
+
await this.applyToolsChanged(name)
|
|
190
|
+
} finally {
|
|
191
|
+
// Re-read: the server may have been disconnected while we were awaiting.
|
|
192
|
+
const current = this.connections.get(name)
|
|
193
|
+
if (current) {
|
|
194
|
+
current.toolsRefreshInFlight = false
|
|
195
|
+
if (current.toolsRefreshQueued) {
|
|
196
|
+
current.toolsRefreshQueued = false
|
|
197
|
+
this.scheduleToolsRefresh(name)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Drop any pending refresh state for a connection being torn down. */
|
|
204
|
+
private cancelToolsRefresh(connection: ActiveConnection): void {
|
|
205
|
+
if (connection.toolsRefreshTimer) clearTimeout(connection.toolsRefreshTimer)
|
|
206
|
+
connection.toolsRefreshTimer = undefined
|
|
207
|
+
connection.toolsRefreshQueued = false
|
|
208
|
+
connection.toolsRefreshInFlight = false
|
|
209
|
+
}
|
|
210
|
+
|
|
118
211
|
/** Reconnect with exponential backoff (1s→2s→4s→…max 60s, 10 attempts). */
|
|
119
212
|
async reconnect(name: string): Promise<void> {
|
|
120
213
|
const connection = this.connections.get(name)
|
|
@@ -132,6 +225,9 @@ export class McpClient {
|
|
|
132
225
|
} catch {
|
|
133
226
|
/* ok */
|
|
134
227
|
}
|
|
228
|
+
// A pending refresh timer would outlive this connection and re-fire
|
|
229
|
+
// against the replacement registered under the same name.
|
|
230
|
+
this.cancelToolsRefresh(connection)
|
|
135
231
|
this.connections.delete(name)
|
|
136
232
|
|
|
137
233
|
await this.connect(config)
|
|
@@ -188,9 +284,9 @@ export class McpClient {
|
|
|
188
284
|
connection.status = 'connected'
|
|
189
285
|
connection.serverInfo = initResult.serverInfo
|
|
190
286
|
|
|
191
|
-
// Wire tools-changed notification
|
|
192
|
-
protocol.on('tools-changed',
|
|
193
|
-
|
|
287
|
+
// Wire tools-changed notification (coalesced — see scheduleToolsRefresh)
|
|
288
|
+
protocol.on('tools-changed', () => {
|
|
289
|
+
this.scheduleToolsRefresh(config.name)
|
|
194
290
|
})
|
|
195
291
|
|
|
196
292
|
// Discover tools
|
|
@@ -241,6 +337,7 @@ export class McpClient {
|
|
|
241
337
|
const conn = this.connections.get(name)
|
|
242
338
|
if (!conn) return []
|
|
243
339
|
|
|
340
|
+
this.cancelToolsRefresh(conn)
|
|
244
341
|
try {
|
|
245
342
|
conn.transport.close()
|
|
246
343
|
} catch {
|
|
@@ -254,8 +351,10 @@ export class McpClient {
|
|
|
254
351
|
async closeAll(): Promise<void> {
|
|
255
352
|
const names = Array.from(this.connections.keys())
|
|
256
353
|
for (const name of names) {
|
|
354
|
+
const conn = this.connections.get(name)
|
|
355
|
+
if (conn) this.cancelToolsRefresh(conn)
|
|
257
356
|
try {
|
|
258
|
-
await
|
|
357
|
+
await conn?.transport.close()
|
|
259
358
|
} catch {
|
|
260
359
|
/* best effort */
|
|
261
360
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.81.
|
|
12
|
+
export const PACKAGE_VERSION = '0.81.6' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/ui/app.tsx
CHANGED
|
@@ -927,6 +927,13 @@ export function App({
|
|
|
927
927
|
if (result.nextProvider) setProviderId(result.nextProvider)
|
|
928
928
|
if (result.nextModel) setModelId(result.nextModel)
|
|
929
929
|
if (result.exit) process.exit(0)
|
|
930
|
+
if (result.clearMessages || (result.forwardedMessages?.length ?? 0) > 0) {
|
|
931
|
+
// The conversation this session is looking at just changed (/clear,
|
|
932
|
+
// /resume) — the read-before-write record belongs to the old one.
|
|
933
|
+
// Clearing is the fail-closed direction: an unread file must be
|
|
934
|
+
// re-read rather than silently overwritten.
|
|
935
|
+
engine.resetFileTracking()
|
|
936
|
+
}
|
|
930
937
|
if (result.forwardedMessages && result.forwardedMessages.length > 0) {
|
|
931
938
|
const restored: ChatMessage[] = result.forwardedMessages.map((msg) => ({
|
|
932
939
|
role: msg.role,
|