@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
|
@@ -38,6 +38,10 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
38
38
|
Authorization: `Bearer ${apiKey}`,
|
|
39
39
|
},
|
|
40
40
|
body: JSON.stringify(body),
|
|
41
|
+
// The caller's cancellation (e.g. `self-critique`'s 2s budget) has to reach
|
|
42
|
+
// the transport, or it is a no-op: `fetch-utils` only combines a caller
|
|
43
|
+
// signal when this field exists.
|
|
44
|
+
signal: req.signal,
|
|
41
45
|
})
|
|
42
46
|
|
|
43
47
|
if (!response.ok) {
|
|
@@ -63,136 +67,147 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
63
67
|
// (DeepSeek V4 / reasoning models) aren't mistaken for a stalled connection.
|
|
64
68
|
const STREAM_READ_TIMEOUT_MS = streamIdleTimeoutMs(req.effort)
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
// The read loop and the fallback stop share one reader, and that reader owns
|
|
71
|
+
// the connection. `engine.ts` breaks out of this generator on the ordinary
|
|
72
|
+
// `stop` chunk, and a sub-agent throws mid-stream on abort — both call
|
|
73
|
+
// `.return()`, which unwinds through here. Without this, a turn that ends
|
|
74
|
+
// normally (or is abandoned) leaves the body unread and uncancelled, so the
|
|
75
|
+
// socket can't be reused. `cancel()` on an already-errored stream rejects, and
|
|
76
|
+
// on a closed one is a no-op — the catch covers the first.
|
|
77
|
+
try {
|
|
78
|
+
while (true) {
|
|
79
|
+
let readResult: Awaited<ReturnType<typeof reader.read>>
|
|
80
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
81
|
+
try {
|
|
82
|
+
readResult = await Promise.race([
|
|
83
|
+
reader.read(),
|
|
84
|
+
new Promise<never>((_, reject) => {
|
|
85
|
+
idleTimer = setTimeout(
|
|
86
|
+
() =>
|
|
87
|
+
reject(
|
|
88
|
+
new Error(
|
|
89
|
+
`Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
|
|
90
|
+
),
|
|
78
91
|
),
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
yield {
|
|
106
|
-
type: 'tool_use',
|
|
107
|
-
toolUse: {
|
|
92
|
+
STREAM_READ_TIMEOUT_MS,
|
|
93
|
+
)
|
|
94
|
+
}),
|
|
95
|
+
])
|
|
96
|
+
} catch (err) {
|
|
97
|
+
yield { type: 'error', error: `Stream stalled: ${String(err)}` }
|
|
98
|
+
return
|
|
99
|
+
} finally {
|
|
100
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
101
|
+
}
|
|
102
|
+
const { done, value } = readResult
|
|
103
|
+
if (done) break
|
|
104
|
+
|
|
105
|
+
buffer += decoder.decode(value, { stream: true })
|
|
106
|
+
const lines = buffer.split('\n')
|
|
107
|
+
buffer = lines.pop() || ''
|
|
108
|
+
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const trimmed = line.trim()
|
|
111
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
|
112
|
+
const data = trimmed.slice(6)
|
|
113
|
+
if (data === '[DONE]') {
|
|
114
|
+
// Emit any pending tool calls before stopping
|
|
115
|
+
for (const [, tc] of pendingToolCalls) {
|
|
116
|
+
if (!tc.name) continue // drop malformed tool call (missing name)
|
|
117
|
+
yield {
|
|
108
118
|
type: 'tool_use',
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
119
|
+
toolUse: {
|
|
120
|
+
type: 'tool_use',
|
|
121
|
+
id: tc.id || `call_${Date.now()}`,
|
|
122
|
+
name: tc.name,
|
|
123
|
+
input: this.safeParseJson(tc.arguments),
|
|
124
|
+
},
|
|
125
|
+
}
|
|
113
126
|
}
|
|
127
|
+
yield { type: 'stop', reasoning_content: reasoningContent }
|
|
128
|
+
return
|
|
114
129
|
}
|
|
115
|
-
yield { type: 'stop', reasoning_content: reasoningContent }
|
|
116
|
-
return
|
|
117
|
-
}
|
|
118
130
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(data)
|
|
133
|
+
const choice = parsed.choices?.[0]
|
|
134
|
+
|
|
135
|
+
// Capture token usage when available (final chunk with stream_options.include_usage)
|
|
136
|
+
if (parsed.usage) {
|
|
137
|
+
yield {
|
|
138
|
+
type: 'usage',
|
|
139
|
+
inputTokens: parsed.usage.prompt_tokens,
|
|
140
|
+
outputTokens: parsed.usage.completion_tokens,
|
|
141
|
+
}
|
|
129
142
|
}
|
|
130
|
-
}
|
|
131
143
|
|
|
132
|
-
|
|
144
|
+
if (!choice) continue
|
|
133
145
|
|
|
134
|
-
|
|
146
|
+
const delta = choice.delta
|
|
135
147
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
if (delta?.tool_calls) {
|
|
149
|
+
for (const tc of delta.tool_calls) {
|
|
150
|
+
const idx = tc.index ?? 0
|
|
151
|
+
const pending = pendingToolCalls.get(idx) || {
|
|
152
|
+
id: '',
|
|
153
|
+
name: '',
|
|
154
|
+
arguments: '',
|
|
155
|
+
}
|
|
144
156
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
157
|
+
if (tc.id) pending.id = tc.id
|
|
158
|
+
if (tc.function?.name) pending.name = tc.function.name
|
|
159
|
+
if (tc.function?.arguments) pending.arguments += tc.function.arguments
|
|
148
160
|
|
|
149
|
-
|
|
161
|
+
pendingToolCalls.set(idx, pending)
|
|
162
|
+
}
|
|
150
163
|
}
|
|
151
|
-
}
|
|
152
164
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
165
|
+
if (delta?.content) {
|
|
166
|
+
yield { type: 'text', content: delta.content }
|
|
167
|
+
}
|
|
156
168
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
169
|
+
if (delta?.reasoning_content) {
|
|
170
|
+
reasoningContent += delta.reasoning_content
|
|
171
|
+
}
|
|
160
172
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
type: 'tool_use',
|
|
167
|
-
toolUse: {
|
|
173
|
+
if (choice.finish_reason === 'tool_calls') {
|
|
174
|
+
// Emit fully accumulated tool calls
|
|
175
|
+
for (const [, tc] of pendingToolCalls) {
|
|
176
|
+
if (!tc.name) continue // drop malformed tool call (missing name)
|
|
177
|
+
yield {
|
|
168
178
|
type: 'tool_use',
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
toolUse: {
|
|
180
|
+
type: 'tool_use',
|
|
181
|
+
id: tc.id || `call_${Date.now()}`,
|
|
182
|
+
name: tc.name,
|
|
183
|
+
input: this.safeParseJson(tc.arguments),
|
|
184
|
+
},
|
|
185
|
+
}
|
|
173
186
|
}
|
|
187
|
+
pendingToolCalls.clear()
|
|
174
188
|
}
|
|
175
|
-
pendingToolCalls.clear()
|
|
176
|
-
}
|
|
177
189
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
190
|
+
if (choice.finish_reason === 'stop') {
|
|
191
|
+
yield { type: 'stop', reasoning_content: reasoningContent }
|
|
192
|
+
}
|
|
181
193
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
194
|
+
if (choice.finish_reason === 'length') {
|
|
195
|
+
// Truncated: the accumulated tool calls were cut off mid-arguments, so
|
|
196
|
+
// their JSON is incomplete. Drop them rather than dispatching a broken
|
|
197
|
+
// call, and clear the map so the `[DONE]` handler can't emit them either.
|
|
198
|
+
pendingToolCalls.clear()
|
|
199
|
+
yield { type: 'stop', reasoning_content: reasoningContent, truncated: true }
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// skip unparseable chunks
|
|
188
203
|
}
|
|
189
|
-
} catch {
|
|
190
|
-
// skip unparseable chunks
|
|
191
204
|
}
|
|
192
205
|
}
|
|
193
|
-
}
|
|
194
206
|
|
|
195
|
-
|
|
207
|
+
yield { type: 'stop', reasoning_content: reasoningContent }
|
|
208
|
+
} finally {
|
|
209
|
+
await reader.cancel().catch(() => {})
|
|
210
|
+
}
|
|
196
211
|
}
|
|
197
212
|
|
|
198
213
|
async listModels(): Promise<ModelInfo[]> {
|
|
@@ -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
|
+
}
|
|
@@ -47,6 +47,8 @@ const KNOWN_FLAGS = [
|
|
|
47
47
|
'--safe-mode',
|
|
48
48
|
'--resume',
|
|
49
49
|
'--permission',
|
|
50
|
+
'--provider',
|
|
51
|
+
'--model',
|
|
50
52
|
]
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -60,8 +62,16 @@ const KNOWN_FLAGS = [
|
|
|
60
62
|
* `--permission` is here for the same reason and for one more: `mipham attach <id>
|
|
61
63
|
* --permission plan` reads the session id through `firstPositional`, and without this
|
|
62
64
|
* entry it would have taken `plan` for a session id.
|
|
65
|
+
*
|
|
66
|
+
* `--provider`/`--model` are the same defect a third time, and here it was *measured*
|
|
67
|
+
* rather than reasoned about: both shipped IDE integrations build
|
|
68
|
+
* `mipham --provider <id> --model <id>` from their settings (`infrastructure/vscode/
|
|
69
|
+
* extension.js`, `MiphamAction.kt`), and that command was answered with
|
|
70
|
+
* `Unknown command: mipham deepseek` — the provider *value* blamed, the flag that was
|
|
71
|
+
* actually wrong never mentioned, exit 1, no CLI. Their values are open (a provider may
|
|
72
|
+
* be user-defined), so — unlike `--permission` — this module owns only their spelling.
|
|
63
73
|
*/
|
|
64
|
-
const VALUE_FLAGS = ['--resume', '--permission']
|
|
74
|
+
const VALUE_FLAGS = ['--resume', '--permission', '--provider', '--model']
|
|
65
75
|
|
|
66
76
|
/**
|
|
67
77
|
* The first token that would be read as a command, skipping flags and the values
|
package/src/shared/constants.ts
CHANGED
|
@@ -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
|
|
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.
|
|
12
|
+
export const PACKAGE_VERSION = '0.85.6' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
|
@@ -56,3 +56,38 @@ export const COMPANY_NAME_ZH = '北京华安麦逄科技有限公司' as const
|
|
|
56
56
|
|
|
57
57
|
/** 公司简称 */
|
|
58
58
|
export const COMPANY_SHORT = '华安麦逄科技' as const
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 计数类常量 —— 公开面(两个官网的产品页)与文档消费的那几个数。
|
|
62
|
+
*
|
|
63
|
+
* **不要手改。** 这四个数由 `apps/cli/scripts/sync-counts.ts` 从真源产出后回写:
|
|
64
|
+
* 命令 / 提供商 / 工具在**进程内算出**(`getCommandNames()` / `DEFAULT_PROVIDERS` /
|
|
65
|
+
* `createToolRegistry()`),测试数由**一次真套件跑**的自报总数产出。
|
|
66
|
+
*
|
|
67
|
+
* 为什么要有这几个槽位(2026-09-25):两个官网的产品页把「137 命令 · 3473 测试」
|
|
68
|
+
* 当**字面量**写死,没有真源 ⇒ 只能靠人记得去改,同一处**至少手改过 7 次**
|
|
69
|
+
* (2262 → … → 3473),而每次手改本身还会再漂。站点侧的传播链其实一直存在 ——
|
|
70
|
+
* 两站的 deploy 脚本都 `cp` 本文件覆盖自己那份 `src/config/package-info.json`
|
|
71
|
+
* —— 缺的只是**槽位**。名字/版本有槽位所以不漂,计数连槽位都没有。
|
|
72
|
+
*
|
|
73
|
+
* 守卫:`apps/cli/test/integrity/published-counts.test.ts`(三个进程内计数与落盘值
|
|
74
|
+
* 逐一对齐);测试总数另由 CI 的 Test job 与套件自报的总数比对(硬门禁)。
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/** Slash 命令总数(真源:`getCommandNames().length`,`apps/cli/src/ui/commands.ts`) */
|
|
78
|
+
export const SLASH_COMMAND_COUNT = 137 as const
|
|
79
|
+
|
|
80
|
+
/** 内置提供商总数(真源:`DEFAULT_PROVIDERS.length`,`apps/cli/src/shared/constants.ts`) */
|
|
81
|
+
export const PROVIDER_COUNT = 12 as const
|
|
82
|
+
|
|
83
|
+
/** 已注册工具总数(真源:`createToolRegistry().size`,`apps/cli/src/tools/index.ts`) */
|
|
84
|
+
export const TOOL_COUNT = 31 as const
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 测试总数(真源:**一次真套件跑**的自报总数)。
|
|
88
|
+
*
|
|
89
|
+
* 取**总数**而不是 `passed`:本机(macOS)与 CI(Linux)的 passed/skipped 切分**不同**
|
|
90
|
+
* —— `test/e2e/full-pipeline.test.ts` 在 Linux 上整文件 skip、在 macOS 上跑 ——
|
|
91
|
+
* 但**总数相同**(两边都把被 skip 的算进去)。
|
|
92
|
+
*/
|
|
93
|
+
export const TEST_COUNT = 3506 as const
|
package/src/shared/types.ts
CHANGED
|
@@ -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
|
|