@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.
Files changed (52) hide show
  1. package/bin/mipham.ts +48 -7
  2. package/package.json +2 -2
  3. package/src/agent/agent-context.ts +8 -1
  4. package/src/agent/effectiveness-tracker.ts +16 -2
  5. package/src/agent/sub-agent.ts +25 -17
  6. package/src/agent-view/agents-standalone.tsx +42 -0
  7. package/src/core/autocomplete.ts +30 -2
  8. package/src/core/context.ts +61 -6
  9. package/src/core/dream-engine.ts +17 -2
  10. package/src/core/engine.ts +95 -34
  11. package/src/core/error-signature-db.ts +7 -2
  12. package/src/core/hooks-executor.ts +88 -11
  13. package/src/core/hooks.ts +26 -2
  14. package/src/core/instructions.ts +105 -17
  15. package/src/core/memory/memory-manager.ts +14 -6
  16. package/src/core/permission-classifier.ts +17 -5
  17. package/src/core/permission-rules.ts +1 -1
  18. package/src/core/permission.ts +50 -1
  19. package/src/core/rule-engine.ts +27 -2
  20. package/src/core/self-critique.ts +15 -3
  21. package/src/core/session-log.ts +60 -0
  22. package/src/daemon/index.ts +2 -5
  23. package/src/daemon/launch.ts +69 -2
  24. package/src/daemon/server.ts +19 -14
  25. package/src/i18n-core/locales/en-US.json +86 -143
  26. package/src/i18n-core/locales/zh-CN.json +86 -143
  27. package/src/index.tsx +17 -7
  28. package/src/mcp/client.ts +61 -0
  29. package/src/mcp/instructions.ts +49 -0
  30. package/src/mcp/types.ts +7 -0
  31. package/src/plugin/claude-plugin.ts +12 -2
  32. package/src/plugin/plugin-loader.ts +32 -14
  33. package/src/plugin/plugin-manager.ts +16 -2
  34. package/src/plugin/plugin-validator.ts +183 -1
  35. package/src/providers/anthropic.ts +159 -124
  36. package/src/providers/fetch-utils.ts +65 -34
  37. package/src/providers/openai-compat.ts +121 -106
  38. package/src/security/dangerous-rm.ts +192 -0
  39. package/src/shared/arg-validation.ts +11 -1
  40. package/src/shared/constants.ts +18 -0
  41. package/src/shared/deleted-cwd.ts +46 -1
  42. package/src/shared/package-info.ts +36 -1
  43. package/src/shared/types.ts +8 -0
  44. package/src/shared/update.ts +290 -146
  45. package/src/ui/command-picker.tsx +18 -10
  46. package/src/ui/commands.ts +20 -6
  47. package/src/ui/config-wizard.tsx +22 -19
  48. package/src/ui/graft-status.tsx +35 -6
  49. package/src/ui/input.tsx +7 -2
  50. package/src/ui/picker.tsx +38 -27
  51. package/src/ui/use-key-state.ts +55 -0
  52. package/src/daemon/message-bus.ts +0 -84
@@ -78,6 +78,15 @@ export class AnthropicProvider implements ProviderInstance {
78
78
  // off at the output ceiling rather than ended by the model.
79
79
  let truncated = false
80
80
 
81
+ // Whether this stream reached `message_stop`. A stream that runs out without
82
+ // one was cut — a proxy or gateway closing the connection cleanly looks
83
+ // exactly like a finished response otherwise.
84
+ let sawTerminalEvent = false
85
+
86
+ // Tool blocks already emitted. A replayed event is the same call, not a
87
+ // second one; emitting it twice makes the engine run the tool twice.
88
+ const emittedToolIds = new Set<string>()
89
+
81
90
  const messages = this.convertMessages(req.messages)
82
91
  this.markPrefixCacheBreakpoint(messages)
83
92
 
@@ -123,6 +132,9 @@ export class AnthropicProvider implements ProviderInstance {
123
132
  'anthropic-beta': 'prompt-caching-2024-07-31',
124
133
  },
125
134
  body: JSON.stringify(body),
135
+ // Same as `openai-compat`: without this the caller's signal never reaches
136
+ // the transport, and every per-call cancellation budget is decorative.
137
+ signal: req.signal,
126
138
  })
127
139
 
128
140
  if (!response.ok) {
@@ -144,150 +156,173 @@ export class AnthropicProvider implements ProviderInstance {
144
156
  // passes aren't mistaken for a stalled connection.
145
157
  const STREAM_READ_TIMEOUT_MS = streamIdleTimeoutMs(req.effort)
146
158
 
147
- while (true) {
148
- let readResult: Awaited<ReturnType<typeof reader.read>>
149
- let idleTimer: ReturnType<typeof setTimeout> | undefined
150
- try {
151
- readResult = await Promise.race([
152
- reader.read(),
153
- new Promise<never>((_, reject) => {
154
- idleTimer = setTimeout(
155
- () =>
156
- reject(
157
- new Error(
158
- `Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
159
- ),
160
- ),
161
- STREAM_READ_TIMEOUT_MS,
162
- )
163
- }),
164
- ])
165
- } catch (err) {
166
- yield { type: 'error', error: `Stream stalled: ${String(err)}` }
167
- return
168
- } finally {
169
- if (idleTimer) clearTimeout(idleTimer)
170
- }
171
- const { done, value } = readResult
172
- if (done) break
173
-
174
- buffer += decoder.decode(value, { stream: true })
175
- const lines = buffer.split('\n')
176
- buffer = lines.pop() || ''
177
-
178
- for (const line of lines) {
179
- const trimmed = line.trim()
180
- if (!trimmed || !trimmed.startsWith('data: ')) continue
181
- const data = trimmed.slice(6)
182
-
159
+ // The read loop and the trailing stop share one reader, and that reader owns
160
+ // the connection. `engine.ts` breaks out of this generator on the ordinary
161
+ // `stop` chunk, and a sub-agent throws mid-stream on abort — both call
162
+ // `.return()`, which unwinds through here. Without this, a turn that ends
163
+ // normally (or is abandoned) leaves the body unread and uncancelled, so the
164
+ // socket can't be reused. `cancel()` on an already-errored stream rejects, and
165
+ // on a closed one is a no-op — the catch covers the first.
166
+ try {
167
+ while (true) {
168
+ let readResult: Awaited<ReturnType<typeof reader.read>>
169
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
183
170
  try {
184
- const event = JSON.parse(data) as AnthropicSSEEvent
185
-
186
- switch (event.type) {
187
- case 'content_block_start': {
188
- const cb = event.content_block
189
- if (!cb) continue
190
-
191
- if (cb.type === 'tool_use') {
192
- currentToolName = cb.name || ''
193
- currentToolId = cb.id || ''
194
- accumulatedToolInput = ''
195
- }
196
- break
197
- }
198
-
199
- case 'content_block_delta': {
200
- const delta = event.delta
201
- if (!delta) continue
202
-
203
- if (delta.type === 'text_delta' && delta.text) {
204
- yield { type: 'text', content: delta.text }
171
+ readResult = await Promise.race([
172
+ reader.read(),
173
+ new Promise<never>((_, reject) => {
174
+ idleTimer = setTimeout(
175
+ () =>
176
+ reject(
177
+ new Error(
178
+ `Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
179
+ ),
180
+ ),
181
+ STREAM_READ_TIMEOUT_MS,
182
+ )
183
+ }),
184
+ ])
185
+ } catch (err) {
186
+ yield { type: 'error', error: `Stream stalled: ${String(err)}` }
187
+ return
188
+ } finally {
189
+ if (idleTimer) clearTimeout(idleTimer)
190
+ }
191
+ const { done, value } = readResult
192
+ if (done) break
193
+
194
+ buffer += decoder.decode(value, { stream: true })
195
+ const lines = buffer.split('\n')
196
+ buffer = lines.pop() || ''
197
+
198
+ for (const line of lines) {
199
+ const trimmed = line.trim()
200
+ if (!trimmed || !trimmed.startsWith('data: ')) continue
201
+ const data = trimmed.slice(6)
202
+
203
+ try {
204
+ const event = JSON.parse(data) as AnthropicSSEEvent
205
+
206
+ switch (event.type) {
207
+ case 'content_block_start': {
208
+ const cb = event.content_block
209
+ if (!cb) continue
210
+
211
+ if (cb.type === 'tool_use') {
212
+ currentToolName = cb.name || ''
213
+ currentToolId = cb.id || ''
214
+ accumulatedToolInput = ''
215
+ }
216
+ break
205
217
  }
206
218
 
207
- if (delta.type === 'thinking_delta' && delta.text) {
208
- yield { type: 'thinking', thinking: delta.text }
209
- }
219
+ case 'content_block_delta': {
220
+ const delta = event.delta
221
+ if (!delta) continue
210
222
 
211
- if (delta.type === 'input_json_delta' && delta.partial_json) {
212
- accumulatedToolInput += delta.partial_json
213
- }
214
- break
215
- }
216
-
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
- // 那是一次行为变更,不属本次范围。
225
- if (currentToolId && currentToolName && accumulatedToolInput) {
226
- let parsedInput: Record<string, unknown> = {}
227
- try {
228
- parsedInput = JSON.parse(accumulatedToolInput)
229
- } catch {
230
- parsedInput = { _raw: accumulatedToolInput }
223
+ if (delta.type === 'text_delta' && delta.text) {
224
+ yield { type: 'text', content: delta.text }
231
225
  }
232
226
 
233
- yield {
234
- type: 'tool_use',
235
- toolUse: {
236
- type: 'tool_use',
237
- id: currentToolId,
238
- name: currentToolName,
239
- input: parsedInput,
240
- },
227
+ if (delta.type === 'thinking_delta' && delta.text) {
228
+ yield { type: 'thinking', thinking: delta.text }
241
229
  }
242
230
 
243
- // Reset accumulator
244
- currentToolName = ''
245
- currentToolId = ''
246
- accumulatedToolInput = ''
231
+ if (delta.type === 'input_json_delta' && delta.partial_json) {
232
+ accumulatedToolInput += delta.partial_json
233
+ }
234
+ break
247
235
  }
248
- break
249
- }
250
236
 
251
- case 'message_delta': {
252
- // Capture token usage for accurate cost tracking
253
- if (event.usage) {
254
- yield {
255
- type: 'usage',
256
- inputTokens: event.usage.input_tokens,
257
- outputTokens: event.usage.output_tokens,
237
+ case 'content_block_stop': {
238
+ // 此刻还无从得知本轮是否被截断 —— `stop_reason` 要到后面的
239
+ // `message_delta` 才到(见下方同名分支)。所以被截断的 `tool_use`
240
+ // 在这里已经发出去了;openai-compat 那条路上「截断即丢弃未完成的
241
+ // tool_call」的处置,这里结构上做不到(它的 finish_reason 与
242
+ // tool_calls 落在同一个响应体里)。**这是有意的不对称,不是漏做**:
243
+ // 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
244
+ // 那是一次行为变更,不属本次范围。
245
+ if (currentToolId && currentToolName && accumulatedToolInput) {
246
+ // A replayed block carries the id it was first sent with, so the
247
+ // id is what tells a second call apart from the same call twice.
248
+ if (!emittedToolIds.has(currentToolId)) {
249
+ emittedToolIds.add(currentToolId)
250
+
251
+ let parsedInput: Record<string, unknown> = {}
252
+ try {
253
+ parsedInput = JSON.parse(accumulatedToolInput)
254
+ } catch {
255
+ parsedInput = { _raw: accumulatedToolInput }
256
+ }
257
+
258
+ yield {
259
+ type: 'tool_use',
260
+ toolUse: {
261
+ type: 'tool_use',
262
+ id: currentToolId,
263
+ name: currentToolName,
264
+ input: parsedInput,
265
+ },
266
+ }
267
+ }
268
+
269
+ // Reset accumulator
270
+ currentToolName = ''
271
+ currentToolId = ''
272
+ accumulatedToolInput = ''
258
273
  }
274
+ break
259
275
  }
260
- // Contains stop_reason; also handles late input_json_delta
261
- if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
262
- accumulatedToolInput += event.delta.partial_json
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
276
+
277
+ case 'message_delta': {
278
+ // Capture token usage for accurate cost tracking
279
+ if (event.usage) {
280
+ yield {
281
+ type: 'usage',
282
+ inputTokens: event.usage.input_tokens,
283
+ outputTokens: event.usage.output_tokens,
284
+ }
285
+ }
286
+ // Contains stop_reason; also handles late input_json_delta
287
+ if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
288
+ accumulatedToolInput += event.delta.partial_json
289
+ }
290
+ // `max_tokens` means the turn hit the output ceiling. Without this the
291
+ // truncation is indistinguishable from `end_turn`: both arrive here and
292
+ // the terminal stop below looks the same either way.
293
+ const stopReason = event.delta?.stop_reason
294
+ if (stopReason === 'max_tokens') {
295
+ truncated = true
296
+ }
297
+ break
270
298
  }
271
- break
272
- }
273
299
 
274
- case 'message_stop': {
275
- yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
276
- return
277
- }
300
+ case 'message_stop': {
301
+ sawTerminalEvent = true
302
+ yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
303
+ return
304
+ }
278
305
 
279
- case 'error': {
280
- yield { type: 'error', error: event.error?.message || 'Unknown Anthropic error' }
281
- return
306
+ case 'error': {
307
+ yield { type: 'error', error: event.error?.message || 'Unknown Anthropic error' }
308
+ return
309
+ }
282
310
  }
311
+ } catch {
312
+ // Skip unparseable SSE events
283
313
  }
284
- } catch {
285
- // Skip unparseable SSE events
286
314
  }
287
315
  }
288
- }
289
316
 
290
- yield { type: 'stop' }
317
+ // The stream ran out without `message_stop`. Whatever stopped it, the turn is
318
+ // incomplete — and this is the only place that knows, because a cleanly
319
+ // closed connection and a finished response are otherwise the same stream.
320
+ if (!sawTerminalEvent) truncated = true
321
+
322
+ yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
323
+ } finally {
324
+ await reader.cancel().catch(() => {})
325
+ }
291
326
  }
292
327
 
293
328
  async listModels(): Promise<ModelInfo[]> {
@@ -16,6 +16,58 @@ export interface FetchWithRetryOptions {
16
16
 
17
17
  const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
18
18
 
19
+ /**
20
+ * Upper bound on a server-supplied `Retry-After`, in ms.
21
+ *
22
+ * The header is a *request*, not a contract: a 5xx answering `Retry-After: 3600`
23
+ * used to park the CLI in `sleep` for a full hour with nothing on screen. The
24
+ * user cannot cancel what they cannot see, so the wait is capped and the reason
25
+ * is left in the comment rather than the terminal.
26
+ */
27
+ export const RETRY_AFTER_MAX_MS = 60_000
28
+
29
+ /**
30
+ * Lower bound on a server-supplied `Retry-After`, in ms.
31
+ *
32
+ * `Retry-After: 0` is a real thing servers send, and honouring it literally
33
+ * means retrying the instant the previous attempt failed — a hammering loop
34
+ * dressed up as politeness. Any present-but-tiny value lands here instead.
35
+ */
36
+ const RETRY_AFTER_MIN_MS = 1_000
37
+
38
+ /**
39
+ * Delay before the next retry attempt.
40
+ *
41
+ * `Retry-After` is honoured when it is parseable, clamped to
42
+ * `[RETRY_AFTER_MIN_MS, RETRY_AFTER_MAX_MS]`, and **ignored in favour of
43
+ * exponential backoff when it is not** — an unparseable header must not become
44
+ * `sleep(NaN)`, which `setTimeout` reads as 0 (the same back-to-back retry as
45
+ * `Retry-After: 0`, but silent).
46
+ *
47
+ * Accepts both RFC 9110 forms: delta-seconds and an HTTP-date.
48
+ */
49
+ export function retryDelayMs(
50
+ retryAfter: string | null,
51
+ attempt: number,
52
+ baseDelay: number,
53
+ ): number {
54
+ const backoff = baseDelay * Math.pow(2, attempt)
55
+ if (retryAfter === null) return backoff
56
+
57
+ const header = retryAfter.trim()
58
+ const seconds = parseInt(header, 10)
59
+ let requested: number
60
+ if (!Number.isNaN(seconds)) {
61
+ requested = seconds * 1000
62
+ } else {
63
+ const at = Date.parse(header)
64
+ if (Number.isNaN(at)) return backoff // unparseable → backoff, never a zero sleep
65
+ requested = at - Date.now()
66
+ }
67
+
68
+ return Math.min(Math.max(requested, RETRY_AFTER_MIN_MS), RETRY_AFTER_MAX_MS)
69
+ }
70
+
19
71
  function isRetryableError(err: unknown): boolean {
20
72
  if (err instanceof DOMException && err.name === 'AbortError') return false
21
73
  return true
@@ -44,18 +96,20 @@ export async function fetchWithRetry(
44
96
  timedOut = true
45
97
  controller.abort()
46
98
  }, timeout)
47
- const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal
99
+ // `AbortSignal.any` (node ≥22 / bun ≥1.2, both in `engines`) instead of a
100
+ // hand-rolled combiner: it keeps a *weak* reference to the source signals, so
101
+ // the combination stays live for the reader without pinning the caller's
102
+ // signal — which is what the hand-rolled version had to trade away.
103
+ const signal = init.signal
104
+ ? AbortSignal.any([init.signal, controller.signal])
105
+ : controller.signal
48
106
 
49
107
  try {
50
108
  const response = await fetch(url, { ...init, signal })
51
109
 
52
110
  // 429 / 5xx → retry
53
111
  if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) {
54
- const retryAfter = response.headers.get('Retry-After')
55
- const delay = retryAfter
56
- ? parseInt(retryAfter, 10) * 1000
57
- : baseDelay * Math.pow(2, attempt)
58
- await sleep(delay)
112
+ await sleep(retryDelayMs(response.headers.get('Retry-After'), attempt, baseDelay))
59
113
  continue
60
114
  }
61
115
 
@@ -75,14 +129,11 @@ export async function fetchWithRetry(
75
129
  await sleep(baseDelay * Math.pow(2, attempt))
76
130
  } finally {
77
131
  clearTimeout(timer)
78
- // Clean up combined signal if we created one
79
- if (init.signal) {
80
- try {
81
- controller.abort()
82
- } catch {
83
- /* best effort */
84
- }
85
- }
132
+ // Nothing to release: the combination is natively managed. Do NOT abort
133
+ // anything here — `fetch` holds that signal and the caller reads the body
134
+ // *after* we return, so aborting it at this point errored every response at
135
+ // the headers (measured on Node: the next read throws AbortError; Bun
136
+ // happens to tolerate it, which is why a Bun-only run never showed this).
86
137
  }
87
138
  }
88
139
 
@@ -115,23 +166,3 @@ export function streamIdleTimeoutMs(effort?: string): number {
115
166
  const multiplier = effort ? (EFFORT_TIMEOUT_MULTIPLIER[effort] ?? 1) : 1
116
167
  return STREAM_IDLE_TIMEOUT_BASE_MS * multiplier
117
168
  }
118
-
119
- /**
120
- * Combine multiple AbortSignals into one — any signal aborting
121
- * triggers the combined signal.
122
- */
123
- function anySignal(signals: AbortSignal[]): AbortSignal {
124
- const controller = new AbortController()
125
- const onAbort = () => {
126
- controller.abort()
127
- for (const s of signals) s.removeEventListener('abort', onAbort)
128
- }
129
- for (const s of signals) {
130
- if (s.aborted) {
131
- controller.abort()
132
- return controller.signal
133
- }
134
- s.addEventListener('abort', onAbort)
135
- }
136
- return controller.signal
137
- }