@miphamai/cli 0.85.3 → 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.
@@ -1,5 +1,6 @@
1
- import { readFileSync, existsSync } from 'node:fs'
1
+ import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { toMcpServerConfig } from './claude-plugin'
3
4
 
4
5
  export interface PluginManifest {
5
6
  name: string
@@ -16,12 +17,43 @@ export type PluginFormat = 'mipham' | 'claude'
16
17
  export interface PluginValidation {
17
18
  valid: boolean
18
19
  errors: string[]
20
+ /**
21
+ * Findings that do not block installation.
22
+ *
23
+ * The split is the point: a declaration we would silently drop is a defect in one
24
+ * part of one plugin, and refusing to install the whole thing over it would make
25
+ * the validator an obstacle rather than a report. What it must not do is stay
26
+ * quiet — a dropped declaration that nothing mentions is indistinguishable from
27
+ * one that worked.
28
+ */
29
+ warnings: string[]
19
30
  manifest?: PluginManifest
20
31
  format: PluginFormat
21
32
  /** Resolved manifest path (empty when no manifest found). */
22
33
  manifestPath: string
23
34
  }
24
35
 
36
+ /**
37
+ * Whether `loadPlugins` will hand this declaration to the MCP client.
38
+ *
39
+ * Lives here so the check and the loader read one rule: `plugin-loader.ts` calls
40
+ * this at its guard, and the MCP checks below call it to decide what to report. It
41
+ * requires a `name` because that is the key the client connects under, and one
42
+ * transport or the other — `McpServerConfig` makes `command` and `url` mutually
43
+ * exclusive, so demanding a `command` is what used to drop every remote server.
44
+ */
45
+ export function isLoadableMcpConfig(cfg: {
46
+ name?: unknown
47
+ command?: unknown
48
+ url?: unknown
49
+ }): boolean {
50
+ return (
51
+ typeof cfg.name === 'string' &&
52
+ cfg.name.length > 0 &&
53
+ (typeof cfg.command === 'string' || typeof cfg.url === 'string')
54
+ )
55
+ }
56
+
25
57
  /**
26
58
  * Resolve a plugin's manifest. Mipham plugins use `plugin.json` at the plugin
27
59
  * root; Claude marketplace plugins use `.claude-plugin/plugin.json`.
@@ -39,12 +71,144 @@ export function detectPluginFormat(dir: string): PluginFormat {
39
71
  return resolveManifestPath(dir)?.format ?? 'mipham'
40
72
  }
41
73
 
74
+ /**
75
+ * `${user_config.*}` is Claude Code's placeholder for a value it prompts the user
76
+ * for at install time. This loader has no such step: `expandPluginRoot` substitutes
77
+ * `${CLAUDE_PLUGIN_ROOT}` and nothing else, so the reference reaches the server as
78
+ * literal text. Every reference is reported — "declared" and "undeclared" are not a
79
+ * useful split here, because neither one resolves.
80
+ */
81
+ const USER_CONFIG_RE = /\$\{user_config\.([A-Za-z0-9_.-]+)\}/g
82
+
83
+ function userConfigKeys(texts: string[]): string[] {
84
+ const keys: string[] = []
85
+ const seen = new Set<string>()
86
+ for (const text of texts) {
87
+ for (const m of text.matchAll(USER_CONFIG_RE)) {
88
+ const key = m[1]!
89
+ if (seen.has(key)) continue
90
+ seen.add(key)
91
+ keys.push(key)
92
+ }
93
+ }
94
+ return keys
95
+ }
96
+
97
+ /** Hosts for which a cleartext URL stays on the machine, so there is nothing in transit. */
98
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]'])
99
+
100
+ function cleartextWarning(server: string, url: string): string | null {
101
+ if (!/^http:\/\//i.test(url)) return null
102
+ let host: string
103
+ try {
104
+ host = new URL(url).hostname
105
+ } catch {
106
+ return null // an unparseable URL is not the finding this check is for
107
+ }
108
+ if (LOOPBACK_HOSTS.has(host)) return null
109
+ return `MCP server "${server}" uses a cleartext http:// URL (${url}) — it is readable in transit`
110
+ }
111
+
112
+ const DROPPED = 'declares neither command nor url and would be skipped at load'
113
+
114
+ function describe(err: unknown): string {
115
+ return err instanceof Error ? err.message : String(err)
116
+ }
117
+
118
+ /** Check the Claude-format sources: `.mcp.json` at the root, plus inline `mcpServers`. */
119
+ function checkClaudeMcp(
120
+ dir: string,
121
+ manifest: PluginManifest,
122
+ texts: string[],
123
+ warnings: string[],
124
+ ): void {
125
+ const inspect = (label: string, servers: unknown): void => {
126
+ if (!servers || typeof servers !== 'object') return
127
+ for (const [name, entry] of Object.entries(servers as Record<string, unknown>)) {
128
+ const raw = (entry ?? {}) as Record<string, unknown>
129
+ if (toMcpServerConfig(name, raw) === null) {
130
+ warnings.push(`${label}: MCP server "${name}" ${DROPPED}`)
131
+ continue
132
+ }
133
+ if (typeof raw.url === 'string') {
134
+ const cleartext = cleartextWarning(name, raw.url)
135
+ if (cleartext) warnings.push(`${label}: ${cleartext}`)
136
+ }
137
+ }
138
+ }
139
+
140
+ const mcpJsonPath = join(dir, '.mcp.json')
141
+ if (existsSync(mcpJsonPath)) {
142
+ const text = readFileSync(mcpJsonPath, 'utf-8')
143
+ texts.push(text)
144
+ try {
145
+ inspect('`.mcp.json`', (JSON.parse(text) as { mcpServers?: unknown }).mcpServers)
146
+ } catch (err) {
147
+ warnings.push(`\`.mcp.json\` could not be parsed and is skipped at load: ${describe(err)}`)
148
+ }
149
+ }
150
+
151
+ const inline = manifest.mcpServers
152
+ if (typeof inline === 'string') {
153
+ // The manifest type admits a string here, and the loader's `collect` returns
154
+ // early on anything that is not an object — so this form is declared and read
155
+ // by nothing.
156
+ warnings.push('`mcpServers` is a string; this loader only reads an object and ignores it')
157
+ } else {
158
+ inspect('manifest `mcpServers`', inline)
159
+ }
160
+ }
161
+
162
+ /** Check the Mipham-format source: `mcp-servers/*.json` at the plugin root. */
163
+ function checkMiphamMcp(dir: string, texts: string[], warnings: string[]): void {
164
+ const mcpDir = join(dir, 'mcp-servers')
165
+ if (!existsSync(mcpDir)) return
166
+
167
+ let entries: string[]
168
+ try {
169
+ entries = readdirSync(mcpDir)
170
+ } catch {
171
+ return
172
+ }
173
+
174
+ for (const entry of entries) {
175
+ if (!entry.endsWith('.json')) continue
176
+ const label = `\`mcp-servers/${entry}\``
177
+ let text: string
178
+ try {
179
+ text = readFileSync(join(mcpDir, entry), 'utf-8')
180
+ } catch (err) {
181
+ warnings.push(`${label} could not be read and is skipped at load: ${describe(err)}`)
182
+ continue
183
+ }
184
+ texts.push(text)
185
+
186
+ let cfg: Record<string, unknown>
187
+ try {
188
+ cfg = JSON.parse(text) as Record<string, unknown>
189
+ } catch (err) {
190
+ warnings.push(`${label} could not be parsed and is skipped at load: ${describe(err)}`)
191
+ continue
192
+ }
193
+
194
+ if (!isLoadableMcpConfig(cfg ?? {})) {
195
+ warnings.push(`${label}: MCP server "${String(cfg?.name ?? entry)}" ${DROPPED}`)
196
+ continue
197
+ }
198
+ if (typeof cfg.url === 'string') {
199
+ const cleartext = cleartextWarning(String(cfg.name), cfg.url)
200
+ if (cleartext) warnings.push(`${label}: ${cleartext}`)
201
+ }
202
+ }
203
+ }
204
+
42
205
  export function validatePlugin(dir: string): PluginValidation {
43
206
  const resolved = resolveManifestPath(dir)
44
207
  if (!resolved) {
45
208
  return {
46
209
  valid: false,
47
210
  errors: ['No plugin manifest found (expected plugin.json or .claude-plugin/plugin.json)'],
211
+ warnings: [],
48
212
  format: 'mipham',
49
213
  manifestPath: '',
50
214
  }
@@ -69,9 +233,26 @@ export function validatePlugin(dir: string): PluginValidation {
69
233
  }
70
234
  }
71
235
 
236
+ const warnings: string[] = []
237
+ // Every text read on the way to a declaration, so a `${user_config.*}` inside an
238
+ // `args` array or a header value is caught wherever it appears.
239
+ const texts: string[] = [raw]
240
+ if (resolved.format === 'claude') {
241
+ checkClaudeMcp(dir, manifest, texts, warnings)
242
+ } else {
243
+ checkMiphamMcp(dir, texts, warnings)
244
+ }
245
+ for (const key of userConfigKeys(texts)) {
246
+ warnings.push(
247
+ `\`\${user_config.${key}}\` is passed through literally — this loader has no user-config ` +
248
+ `step, so the server receives the text itself`,
249
+ )
250
+ }
251
+
72
252
  return {
73
253
  valid: errors.length === 0,
74
254
  errors,
255
+ warnings,
75
256
  manifest,
76
257
  format: resolved.format,
77
258
  manifestPath: resolved.path,
@@ -81,6 +262,7 @@ export function validatePlugin(dir: string): PluginValidation {
81
262
  return {
82
263
  valid: false,
83
264
  errors: [`Failed to read ${label}: ${String(err)}`],
265
+ warnings: [],
84
266
  format: resolved.format,
85
267
  manifestPath: resolved.path,
86
268
  }
@@ -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
 
@@ -223,21 +232,27 @@ export class AnthropicProvider implements ProviderInstance {
223
232
  // 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
224
233
  // 那是一次行为变更,不属本次范围。
225
234
  if (currentToolId && currentToolName && accumulatedToolInput) {
226
- let parsedInput: Record<string, unknown> = {}
227
- try {
228
- parsedInput = JSON.parse(accumulatedToolInput)
229
- } catch {
230
- parsedInput = { _raw: accumulatedToolInput }
231
- }
232
-
233
- yield {
234
- type: 'tool_use',
235
- toolUse: {
235
+ // A replayed block carries the id it was first sent with, so the
236
+ // id is what tells a second call apart from the same call twice.
237
+ if (!emittedToolIds.has(currentToolId)) {
238
+ emittedToolIds.add(currentToolId)
239
+
240
+ let parsedInput: Record<string, unknown> = {}
241
+ try {
242
+ parsedInput = JSON.parse(accumulatedToolInput)
243
+ } catch {
244
+ parsedInput = { _raw: accumulatedToolInput }
245
+ }
246
+
247
+ yield {
236
248
  type: 'tool_use',
237
- id: currentToolId,
238
- name: currentToolName,
239
- input: parsedInput,
240
- },
249
+ toolUse: {
250
+ type: 'tool_use',
251
+ id: currentToolId,
252
+ name: currentToolName,
253
+ input: parsedInput,
254
+ },
255
+ }
241
256
  }
242
257
 
243
258
  // Reset accumulator
@@ -272,6 +287,7 @@ export class AnthropicProvider implements ProviderInstance {
272
287
  }
273
288
 
274
289
  case 'message_stop': {
290
+ sawTerminalEvent = true
275
291
  yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
276
292
  return
277
293
  }
@@ -287,7 +303,12 @@ export class AnthropicProvider implements ProviderInstance {
287
303
  }
288
304
  }
289
305
 
290
- yield { type: 'stop' }
306
+ // The stream ran out without `message_stop`. Whatever stopped it, the turn is
307
+ // incomplete — and this is the only place that knows, because a cleanly
308
+ // closed connection and a finished response are otherwise the same stream.
309
+ if (!sawTerminalEvent) truncated = true
310
+
311
+ yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
291
312
  }
292
313
 
293
314
  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
@@ -51,11 +103,7 @@ export async function fetchWithRetry(
51
103
 
52
104
  // 429 / 5xx → retry
53
105
  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)
106
+ await sleep(retryDelayMs(response.headers.get('Retry-After'), attempt, baseDelay))
59
107
  continue
60
108
  }
61
109
 
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Recursive `rm` whose target cannot be read off the command text.
3
+ *
4
+ * `rm -rf node_modules` names what it deletes. `rm -rf "$(pwd)"` does not — the
5
+ * target is produced at run time, so no allow rule, no mode and no reviewer can
6
+ * see how far the deletion reaches.
7
+ *
8
+ * This is deliberately **not** a blocklist of dangerous paths. Those already
9
+ * exist (`tools/exec/bash.ts` BLOCKED_PATTERNS refuses `/`, `~`, `*`, `.` and
10
+ * absolute paths). What is left uncovered is the case where the *path is not in
11
+ * the text at all* — and that case is invisible to every string-matching guard,
12
+ * which is why it has to be recognised structurally instead.
13
+ *
14
+ * Judged on every command line inside the input, via the same `flattenCommand`
15
+ * the deny-rule path uses: reading one normalization while the deny rules read
16
+ * another is how a guard fires on one spelling and not on its twin.
17
+ */
18
+
19
+ import { flattenCommand } from '../core/permission-rules'
20
+
21
+ export type DangerousRmKind =
22
+ /** The target is led by command-substitution output: `rm -rf "$(pwd)"`. */
23
+ | 'substitution'
24
+ /** A variable plus one top-level directory name: `rm -rf $ROOT/usr`. */
25
+ | 'variable-top-level'
26
+ /** The target is anchored to a working-directory variable: `rm -rf $PWD`. */
27
+ | 'cwd-derived'
28
+ /** Nothing but backslashes: `rm -rf \`. */
29
+ | 'backslash-only'
30
+
31
+ export interface DangerousRm {
32
+ kind: DangerousRmKind
33
+ /** The offending target, with its surrounding quotes stripped. */
34
+ target: string
35
+ }
36
+
37
+ /**
38
+ * Variables whose value is the directory the shell is in.
39
+ *
40
+ * The danger is not an unknown value — it is that the value is *movable*: an
41
+ * earlier segment of the same command line (`cd /tmp && …`) decides it, so the
42
+ * target is anchored to whatever directory the command happens to reach.
43
+ */
44
+ const CWD_VARS = new Set(['PWD', 'OLDPWD'])
45
+
46
+ /**
47
+ * Directory names that sit at the filesystem root.
48
+ *
49
+ * These are what make an empty variable dangerous. If `$VAR` is unset, the shell
50
+ * drops it and `rm -rf $VAR/usr` runs as `rm -rf /usr` — the variable does not
51
+ * fail loudly, it *disappears*, and what is left behind is an absolute path to a
52
+ * system directory. `$VAR/node_modules` collapsing to `/node_modules` is not in
53
+ * that class, so the directory name is what decides, not the variable.
54
+ */
55
+ const TOP_LEVEL_DIRS = new Set([
56
+ 'bin',
57
+ 'boot',
58
+ 'dev',
59
+ 'etc',
60
+ 'home',
61
+ 'lib',
62
+ 'lib64',
63
+ 'opt',
64
+ 'proc',
65
+ 'root',
66
+ 'run',
67
+ 'sbin',
68
+ 'srv',
69
+ 'sys',
70
+ 'tmp',
71
+ 'usr',
72
+ 'var',
73
+ ])
74
+
75
+ /** Strip one layer of matching quotes — `"$(pwd)"` and `$(pwd)` are one target. */
76
+ function stripQuotes(s: string): string {
77
+ if (s.length < 2) return s
78
+ const first = s[0]
79
+ const last = s[s.length - 1]
80
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) return s.slice(1, -1)
81
+ return s
82
+ }
83
+
84
+ /**
85
+ * Split an argument region into operands, keeping a quoted run or a `$(…)`
86
+ * substitution whole.
87
+ *
88
+ * Plain whitespace splitting is not enough here, and it fails in the direction
89
+ * that matters: `rm -rf "$(git rev-parse --show-toplevel)"` tokenizes to
90
+ * `['"$(git', 'rev-parse', '--show-toplevel)"']`, so the target never reads as a
91
+ * substitution at all. The cases this guard exists to catch are exactly the ones
92
+ * that contain spaces.
93
+ */
94
+ function splitOperands(args: string): string[] {
95
+ const out: string[] = []
96
+ let cur = ''
97
+ let quote: string | null = null
98
+ let depth = 0
99
+
100
+ for (let i = 0; i < args.length; i++) {
101
+ const ch = args[i]!
102
+
103
+ if (quote) {
104
+ cur += ch
105
+ if (ch === quote) quote = null
106
+ continue
107
+ }
108
+ if (ch === '"' || ch === "'" || ch === '`') {
109
+ quote = ch
110
+ cur += ch
111
+ continue
112
+ }
113
+ if (ch === '$' && args[i + 1] === '(') {
114
+ depth++
115
+ cur += '$('
116
+ i++
117
+ continue
118
+ }
119
+ if (depth > 0) {
120
+ if (ch === ')') depth--
121
+ cur += ch
122
+ continue
123
+ }
124
+ if (/\s/.test(ch)) {
125
+ if (cur) out.push(cur)
126
+ cur = ''
127
+ continue
128
+ }
129
+ cur += ch
130
+ }
131
+
132
+ if (cur) out.push(cur)
133
+ return out
134
+ }
135
+
136
+ /**
137
+ * True for an `rm` invocation carrying a recursive flag.
138
+ *
139
+ * Force (`-f`) is deliberately not required: `rm -r "$(pwd)"` deletes just as
140
+ * much as `rm -rf "$(pwd)"` and asks fewer questions on the way in.
141
+ */
142
+ function isRecursiveRm(tokens: string[]): boolean {
143
+ if (tokens[0] !== 'rm') return false
144
+ return tokens.slice(1).some((t) => {
145
+ if (t === '--recursive') return true
146
+ // A combined cluster (`-rf`, `-fr`, `-r`). A `--flag` long form is not one.
147
+ return /^-[A-Za-z]+$/.test(t) && t.includes('r')
148
+ })
149
+ }
150
+
151
+ /** Which enumerated shape (if any) this target has. */
152
+ function classifyTarget(raw: string): DangerousRmKind | null {
153
+ const target = stripQuotes(raw)
154
+
155
+ if (/^\\+$/.test(target)) return 'backslash-only'
156
+
157
+ // Led by command-substitution output. A substitution *anywhere* in the target
158
+ // is not enough to judge, but a target that starts with one is anchored to a
159
+ // value decided at run time — including the prefix form, where the suffix only
160
+ // narrows an unknown directory to a named entry inside it.
161
+ const sub = target.match(/^(\$\([^)]*\)|`[^`]*`)(\/.*)?$/)
162
+ if (sub) return 'substitution'
163
+
164
+ const m = target.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?(\/\S*)?$/)
165
+ if (!m) return null
166
+ const name = m[1]!
167
+ const rest = m[2]
168
+ if (CWD_VARS.has(name)) return 'cwd-derived'
169
+
170
+ // A variable followed by exactly one more segment, and that segment is a
171
+ // filesystem-root directory name. Deeper paths are a named subdirectory, which
172
+ // is the ordinary case this must not refuse.
173
+ if (rest) {
174
+ const segments = rest.split('/').filter(Boolean)
175
+ if (segments.length === 1 && TOP_LEVEL_DIRS.has(segments[0]!)) return 'variable-top-level'
176
+ }
177
+ return null
178
+ }
179
+
180
+ /** Return the first unbounded recursive-`rm` target in `command`, or `null`. */
181
+ export function detectDangerousRm(command: string): DangerousRm | null {
182
+ for (const segment of flattenCommand(command)) {
183
+ const tokens = splitOperands(segment)
184
+ if (!isRecursiveRm(tokens)) continue
185
+ for (const token of tokens.slice(1)) {
186
+ if (token.startsWith('-')) continue
187
+ const kind = classifyTarget(token)
188
+ if (kind) return { kind, target: stripQuotes(token) }
189
+ }
190
+ }
191
+ return null
192
+ }
@@ -95,6 +95,24 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
95
95
  vision: true,
96
96
  status: 'active',
97
97
  },
98
+ {
99
+ id: 'claude-fable-5-1',
100
+ name: 'Claude Fable 5.1',
101
+ providerId: 'anthropic',
102
+ contextWindow: 1_000_000,
103
+ maxOutput: 128_000,
104
+ vision: true,
105
+ status: 'active',
106
+ },
107
+ {
108
+ id: 'claude-opus-5-5',
109
+ name: 'Claude Opus 5.5',
110
+ providerId: 'anthropic',
111
+ contextWindow: 1_000_000,
112
+ maxOutput: 128_000,
113
+ vision: true,
114
+ status: 'active',
115
+ },
98
116
  ],
99
117
  },
100
118
  {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Detection and messaging for the "current working directory was deleted"
3
- * startup failure.
3
+ * failure — at startup, and mid-session.
4
4
  *
5
5
  * `process.cwd()` throws `ENOENT` when the directory the process was launched
6
6
  * from no longer exists (e.g. a removed git worktree). The CLI entry checks
@@ -8,6 +8,8 @@
8
8
  * error surface as a raw crash dump (matches Claude Code 2.1.239).
9
9
  */
10
10
 
11
+ import { existsSync } from 'node:fs'
12
+
11
13
  /** True when `err` is the ENOENT thrown by `process.cwd()` on a deleted cwd. */
12
14
  export function isDeletedCwdError(err: unknown): boolean {
13
15
  if (!(err instanceof Error)) return false
@@ -24,3 +26,46 @@ export function deletedCwdMessage(): string {
24
26
  `Change to a valid directory and run \`mipham\` again.`
25
27
  )
26
28
  }
29
+
30
+ /**
31
+ * `dir` (default `process.cwd()`) if it still exists, otherwise `null`.
32
+ *
33
+ * The launch-time check above covers a directory that was already gone when the
34
+ * process started. A directory deleted *while* the session runs reaches the same
35
+ * state, and neither runtime reports it on its own:
36
+ *
37
+ * - Node throws `ENOENT` from `process.cwd()` — but from inside whatever call
38
+ * site happens to touch it first, so the message names the wrong thing;
39
+ * - Bun does not throw at all. It keeps returning the path it cached at startup,
40
+ * so the deleted directory arrives as an ordinary string and only fails later,
41
+ * at the first syscall that uses it (`spawn /bin/sh ENOENT` blames the shell).
42
+ *
43
+ * Asking the filesystem makes both runtimes agree, and lets the caller decide
44
+ * what to say. An error that is not this one is not swallowed.
45
+ */
46
+ export function resolveExistingCwd(dir?: string): string | null {
47
+ let target: string
48
+ try {
49
+ target = dir ?? process.cwd()
50
+ } catch (err) {
51
+ if (isDeletedCwdError(err)) return null
52
+ throw err
53
+ }
54
+ return existsSync(target) ? target : null
55
+ }
56
+
57
+ /**
58
+ * Guidance for a session whose working directory was deleted while it ran.
59
+ *
60
+ * `deletedCwdMessage` is read before there is a session — it tells the reader to
61
+ * launch the CLI. This one is read from inside a running session, by the model
62
+ * and the operator, so it says what is broken now and what to do instead.
63
+ */
64
+ export function deletedCwdSessionMessage(): string {
65
+ return (
66
+ `The working directory for this session no longer exists — it was deleted ` +
67
+ `while the session was running.\n` +
68
+ `Tools that need a directory cannot run until the session is restarted from ` +
69
+ `a directory that exists.`
70
+ )
71
+ }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.85.3' as const
12
+ export const PACKAGE_VERSION = '0.85.5' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -278,6 +278,14 @@ export interface HookConfig {
278
278
  export interface HookDefinition {
279
279
  event: HookEvent
280
280
  toolName?: string
281
+ /**
282
+ * Who declared this hook, when it was not the operator — a plugin name, today.
283
+ *
284
+ * The operator is no longer the only author of the hooks that fire, and without
285
+ * this the engine cannot tell them apart: a failure message names only a command,
286
+ * health is tracked per key, and removal has nothing to scope to.
287
+ */
288
+ source?: string
281
289
  handler: (context: HookContext) => Promise<HookResult>
282
290
  }
283
291