@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.81.5",
3
+ "version": "0.81.6",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -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
- /** Handle tools/list_changed notification — diff and re-register. */
100
- async onToolsChanged(name: string): Promise<void> {
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', async () => {
193
- await this.onToolsChanged(config.name)
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 this.connections.get(name)?.transport.close()
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.5' as const
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,