@miphamai/cli 0.6.9 → 0.7.0

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.6.9",
3
+ "version": "0.7.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -148,6 +148,7 @@ export class QueryEngine {
148
148
 
149
149
  let assistantContent = ''
150
150
  let reasoningContent = ''
151
+ let thinkingContent = ''
151
152
  const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
152
153
 
153
154
  // Stream model response
@@ -174,6 +175,10 @@ export class QueryEngine {
174
175
  reasoningContent += chunk.reasoning_content
175
176
  }
176
177
 
178
+ if (chunk.type === 'thinking' && chunk.thinking) {
179
+ thinkingContent += chunk.thinking
180
+ }
181
+
177
182
  if (chunk.type === 'tool_use' && chunk.toolUse) {
178
183
  toolUses.push({
179
184
  id: chunk.toolUse.id,
@@ -184,10 +189,17 @@ export class QueryEngine {
184
189
 
185
190
  if (chunk.type === 'stop') {
186
191
  // Add assistant response to context
187
- if (assistantContent || reasoningContent) {
192
+ if (assistantContent || reasoningContent || thinkingContent) {
193
+ const contentBlocks: import('../shared/types').ContentBlock[] = []
194
+ if (thinkingContent) {
195
+ contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
196
+ }
197
+ if (assistantContent) {
198
+ contentBlocks.push({ type: 'text', text: assistantContent })
199
+ }
188
200
  const msg: import('../shared/types').Message = {
189
201
  role: 'assistant',
190
- content: assistantContent || '',
202
+ content: thinkingContent ? contentBlocks : assistantContent || '',
191
203
  }
192
204
  if (reasoningContent) msg.reasoning_content = reasoningContent
193
205
  this.context.addMessage(msg)
@@ -197,10 +209,17 @@ export class QueryEngine {
197
209
  } catch (err) {
198
210
  if (isAbortError(err)) {
199
211
  // User interrupted — keep partial content, stop gracefully
200
- if (assistantContent || reasoningContent) {
212
+ if (assistantContent || reasoningContent || thinkingContent) {
213
+ const contentBlocks: import('../shared/types').ContentBlock[] = []
214
+ if (thinkingContent) {
215
+ contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
216
+ }
217
+ if (assistantContent) {
218
+ contentBlocks.push({ type: 'text', text: assistantContent })
219
+ }
201
220
  const msg: import('../shared/types').Message = {
202
221
  role: 'assistant',
203
- content: assistantContent || '',
222
+ content: thinkingContent ? contentBlocks : assistantContent || '',
204
223
  }
205
224
  if (reasoningContent) msg.reasoning_content = reasoningContent
206
225
  this.context.addMessage(msg)
@@ -242,7 +261,13 @@ export class QueryEngine {
242
261
  })
243
262
  this.context.addMessage({
244
263
  role: 'user',
245
- content: [{ type: 'tool_result', tool_use_id: toolUse.id, content: result.content }],
264
+ content: [
265
+ {
266
+ type: 'tool_result',
267
+ tool_use_id: toolUse.id,
268
+ content: result.success ? result.content : result.error || result.content,
269
+ },
270
+ ],
246
271
  })
247
272
  }
248
273
 
@@ -282,6 +307,7 @@ export class QueryEngine {
282
307
 
283
308
  let assistantContent = ''
284
309
  let reasoningContent = ''
310
+ let thinkingContent = ''
285
311
  const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
286
312
 
287
313
  try {
@@ -301,6 +327,10 @@ export class QueryEngine {
301
327
  reasoningContent += chunk.reasoning_content
302
328
  }
303
329
 
330
+ if (chunk.type === 'thinking' && chunk.thinking) {
331
+ thinkingContent += chunk.thinking
332
+ }
333
+
304
334
  if (chunk.type === 'tool_use' && chunk.toolUse) {
305
335
  toolUses.push({
306
336
  id: chunk.toolUse.id,
@@ -311,10 +341,17 @@ export class QueryEngine {
311
341
  }
312
342
  } catch (err) {
313
343
  if (isAbortError(err)) {
314
- if (assistantContent || reasoningContent) {
344
+ if (assistantContent || reasoningContent || thinkingContent) {
345
+ const contentBlocks: import('../shared/types').ContentBlock[] = []
346
+ if (thinkingContent) {
347
+ contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
348
+ }
349
+ if (assistantContent) {
350
+ contentBlocks.push({ type: 'text', text: assistantContent })
351
+ }
315
352
  const msg: import('../shared/types').Message = {
316
353
  role: 'assistant',
317
- content: assistantContent || '',
354
+ content: thinkingContent ? contentBlocks : assistantContent || '',
318
355
  }
319
356
  if (reasoningContent) msg.reasoning_content = reasoningContent
320
357
  this.context.addMessage(msg)
@@ -326,15 +363,31 @@ export class QueryEngine {
326
363
  return
327
364
  }
328
365
 
329
- if (assistantContent || reasoningContent) {
366
+ if (assistantContent || reasoningContent || thinkingContent) {
367
+ const contentBlocks: import('../shared/types').ContentBlock[] = []
368
+ if (thinkingContent) {
369
+ contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
370
+ }
371
+ if (assistantContent) {
372
+ contentBlocks.push({ type: 'text', text: assistantContent })
373
+ }
330
374
  const msg: import('../shared/types').Message = {
331
375
  role: 'assistant',
332
- content: assistantContent || '',
376
+ content: thinkingContent ? contentBlocks : assistantContent || '',
333
377
  }
334
378
  if (reasoningContent) msg.reasoning_content = reasoningContent
335
379
  this.context.addMessage(msg)
336
380
  }
337
381
 
382
+ // Safety: prevent silent truncation when max turns reached
383
+ if (turn === MAX_TURNS - 1 && toolUses.length > 0) {
384
+ yield {
385
+ type: 'error',
386
+ error: `Max tool-calling turns (${MAX_TURNS}) reached. Some tool calls were not executed.`,
387
+ }
388
+ return
389
+ }
390
+
338
391
  // No more tool calls — fire Stop hook and potentially continue
339
392
  if (toolUses.length === 0) {
340
393
  yield* this.checkStopHook(signal)
@@ -356,7 +409,13 @@ export class QueryEngine {
356
409
  })
357
410
  this.context.addMessage({
358
411
  role: 'user',
359
- content: [{ type: 'tool_result', tool_use_id: toolUse.id, content: result.content }],
412
+ content: [
413
+ {
414
+ type: 'tool_result',
415
+ tool_use_id: toolUse.id,
416
+ content: result.success ? result.content : result.error || result.content,
417
+ },
418
+ ],
360
419
  })
361
420
  }
362
421
  }
package/src/index.tsx CHANGED
@@ -8,6 +8,7 @@ import { loadSessionMemories } from './core/memory/memory-loader'
8
8
  import { ContextManager } from './core/context'
9
9
  import { QueryEngine } from './core/engine'
10
10
  import { SessionStore } from './core/session-store'
11
+ import type { PermissionLevel } from './shared/types'
11
12
  import { SkillsLoader } from './skills/loader'
12
13
  import { PluginManager } from './plugin/plugin-manager'
13
14
  import { loadPlugins } from './plugin/plugin-loader'
@@ -148,6 +149,11 @@ export async function runApp(options: RunOptions): Promise<void> {
148
149
  engine.setAgentViewManager(agentViewManager)
149
150
  engine.setSkillsLoader(skillsLoader)
150
151
 
152
+ // Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
153
+ if (config.permission) {
154
+ engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
155
+ }
156
+
151
157
  // Initialize agent registry and load plugin agents/skills/MCP/hooks
152
158
  const agentRegistry = new AgentRegistry()
153
159
  agentRegistry.loadUserAgents()
package/src/mcp/client.ts CHANGED
@@ -85,16 +85,22 @@ export class McpClient {
85
85
  }
86
86
  }
87
87
 
88
- disconnect(name: string): void {
88
+ /**
89
+ * Disconnect an MCP server and return the names of its registered tools
90
+ * so the caller can unregister them from the central tool registry.
91
+ */
92
+ disconnect(name: string): string[] {
89
93
  const conn = this.connections.get(name)
90
- if (!conn) return
94
+ if (!conn) return []
91
95
 
92
96
  try {
93
97
  conn.transport.close()
94
98
  } catch {
95
99
  /* best effort */
96
100
  }
101
+ const toolNames = conn.tools.map((t) => t.name)
97
102
  this.connections.delete(name)
103
+ return toolNames
98
104
  }
99
105
 
100
106
  async closeAll(): Promise<void> {
@@ -24,6 +24,9 @@ export function loadPlugins(
24
24
  toolsMap: Map<string, ToolDefinition>,
25
25
  ): void {
26
26
  for (const plugin of pluginManager.getEnabled()) {
27
+ const mcpServers: string[] = []
28
+ const hookEvents: HookEvent[] = []
29
+
27
30
  // ── Custom agents ──
28
31
  const agentsDir = join(plugin.path, 'agents')
29
32
  if (existsSync(agentsDir)) {
@@ -59,6 +62,7 @@ export function loadPlugins(
59
62
  const raw = readFileSync(join(mcpDir, entry), 'utf-8')
60
63
  const cfg = JSON.parse(raw) as McpServerConfig
61
64
  if (cfg.name && cfg.command) {
65
+ mcpServers.push(cfg.name)
62
66
  mcpClient
63
67
  .connect(cfg)
64
68
  .then(() => {
@@ -87,8 +91,6 @@ export function loadPlugins(
87
91
  }
88
92
 
89
93
  // ── Hooks from plugin.json ──
90
- // Plugin hooks are HookConfig objects (type + command/url), not HookDefinition
91
- // (which requires a handler function). Convert them via executeHook wrapper.
92
94
  try {
93
95
  const manifestPath = join(plugin.path, 'plugin.json')
94
96
  if (existsSync(manifestPath)) {
@@ -100,6 +102,7 @@ export function loadPlugins(
100
102
  | HookEvent
101
103
  | undefined
102
104
  if (event) {
105
+ hookEvents.push(event)
103
106
  hookEngine.register({
104
107
  event,
105
108
  handler: async (ctx) => executeHook(hookCfg, ctx),
@@ -112,5 +115,28 @@ export function loadPlugins(
112
115
  } catch (err) {
113
116
  process.stderr.write(`[plugin] Failed to load hooks from "${plugin.name}": ${String(err)}\n`)
114
117
  }
118
+
119
+ // ── Register cleanup callback ──
120
+ pluginManager.onRemove(plugin.name, () => {
121
+ // Disconnect MCP servers and unregister their tools
122
+ for (const serverName of mcpServers) {
123
+ try {
124
+ const toolNames = mcpClient.disconnect(serverName)
125
+ for (const toolName of toolNames) {
126
+ toolsMap.delete(`mcp__${serverName}__${toolName}`)
127
+ }
128
+ } catch {
129
+ /* best effort */
130
+ }
131
+ }
132
+ // Unregister hooks
133
+ for (const event of hookEvents) {
134
+ try {
135
+ hookEngine.unregister(event)
136
+ } catch {
137
+ /* best effort */
138
+ }
139
+ }
140
+ })
115
141
  }
116
142
  }
@@ -18,6 +18,7 @@ export class PluginManager {
18
18
  private plugins: InstalledPlugin[] = []
19
19
  private pluginDir: string
20
20
  private statePath: string
21
+ private cleanupCallbacks = new Map<string, () => void>()
21
22
 
22
23
  constructor(pluginDir?: string) {
23
24
  this.pluginDir = pluginDir ?? PLUGIN_DIR
@@ -139,9 +140,29 @@ export class PluginManager {
139
140
  return [...this.plugins]
140
141
  }
141
142
 
143
+ /**
144
+ * Register a cleanup callback that will be invoked when the named plugin is removed.
145
+ * This allows the plugin loader to tear down hooks, agents, MCP connections, and tools.
146
+ */
147
+ onRemove(name: string, cleanup: () => void): void {
148
+ this.cleanupCallbacks.set(name, cleanup)
149
+ }
150
+
142
151
  remove(name: string): boolean {
143
152
  const plugin = this.plugins.find((p) => p.name === name)
144
153
  if (!plugin) return false
154
+
155
+ // Run registered cleanup: disconnect MCP, unregister hooks/agents/tools
156
+ const cleanup = this.cleanupCallbacks.get(name)
157
+ if (cleanup) {
158
+ try {
159
+ cleanup()
160
+ } catch {
161
+ /* cleanup is best-effort */
162
+ }
163
+ this.cleanupCallbacks.delete(name)
164
+ }
165
+
145
166
  try {
146
167
  rmSync(plugin.path, { recursive: true, force: true })
147
168
  } catch {
@@ -11,6 +11,7 @@ import { fetchWithRetry } from './fetch-utils'
11
11
  interface AnthropicContentBlock {
12
12
  type: string
13
13
  text?: string
14
+ thinking?: string
14
15
  id?: string
15
16
  name?: string
16
17
  input?: Record<string, unknown>
@@ -30,6 +31,7 @@ interface AnthropicSSEEvent {
30
31
  delta?: {
31
32
  type: string
32
33
  text?: string
34
+ thinking?: string
33
35
  partial_json?: string
34
36
  }
35
37
  error?: { type: string; message: string }
@@ -136,6 +138,10 @@ export class AnthropicProvider implements ProviderInstance {
136
138
  yield { type: 'text', content: delta.text }
137
139
  }
138
140
 
141
+ if (delta.type === 'thinking_delta' && delta.text) {
142
+ yield { type: 'thinking', thinking: delta.text }
143
+ }
144
+
139
145
  if (delta.type === 'input_json_delta' && delta.partial_json) {
140
146
  accumulatedToolInput += delta.partial_json
141
147
  }
@@ -250,6 +256,12 @@ export class AnthropicProvider implements ProviderInstance {
250
256
  }
251
257
  }
252
258
 
259
+ case 'thinking':
260
+ return {
261
+ type: 'thinking',
262
+ thinking: block.thinking,
263
+ }
264
+
253
265
  case 'tool_use':
254
266
  return {
255
267
  type: 'tool_use',
@@ -172,7 +172,7 @@ export class OpenAICompatProvider implements ProviderInstance {
172
172
  const next: Message | undefined = messages[i + 1]
173
173
  if (
174
174
  msg.role === 'assistant' &&
175
- msg.content.length > 0 &&
175
+ (msg.content.length > 0 || msg.reasoning_content) &&
176
176
  next &&
177
177
  next.role === 'assistant' &&
178
178
  typeof next.content !== 'string' &&
@@ -214,11 +214,12 @@ export class OpenAICompatProvider implements ProviderInstance {
214
214
 
215
215
  // ── Assistant tool_use → OpenAI tool_calls ──
216
216
  if (toolUses.length > 0 && msg.role === 'assistant') {
217
- const textContent =
218
- blocks
219
- .filter((b) => b.type === 'text')
220
- .map((b) => (b.type === 'text' ? b.text : ''))
221
- .join('') || null
217
+ const textParts: string[] = []
218
+ for (const b of blocks) {
219
+ if (b.type === 'text') textParts.push(b.text)
220
+ if (b.type === 'thinking') textParts.push(`[Thinking: ${b.thinking}]`)
221
+ }
222
+ const textContent = textParts.join('') || null
222
223
 
223
224
  const toolUseMsg: Record<string, unknown> = {
224
225
  role: 'assistant',
@@ -241,6 +242,16 @@ export class OpenAICompatProvider implements ProviderInstance {
241
242
 
242
243
  // ── Tool result → OpenAI tool role ──
243
244
  if (toolResults.length > 0) {
245
+ // Emit companion text blocks as a user message before tool results
246
+ const textContent =
247
+ blocks
248
+ .filter((b) => b.type === 'text')
249
+ .map((b) => (b.type === 'text' ? b.text : ''))
250
+ .join('') || null
251
+ if (textContent) {
252
+ result.push({ role: 'user', content: textContent })
253
+ }
254
+
244
255
  for (const tr of toolResults) {
245
256
  result.push({
246
257
  role: 'tool',
@@ -41,7 +41,16 @@ export interface ToolResultContent {
41
41
  tool_use_id: string
42
42
  content: string
43
43
  }
44
- export type ContentBlock = TextContent | ImageContent | ToolUseContent | ToolResultContent
44
+ export interface ThinkingContent {
45
+ type: 'thinking'
46
+ thinking: string
47
+ }
48
+ export type ContentBlock =
49
+ | TextContent
50
+ | ImageContent
51
+ | ToolUseContent
52
+ | ToolResultContent
53
+ | ThinkingContent
45
54
 
46
55
  export interface Message {
47
56
  role: 'system' | 'user' | 'assistant'
@@ -102,13 +111,15 @@ export interface ToolDefinition {
102
111
 
103
112
  // ── Stream Types ──
104
113
  export interface StreamChunk {
105
- type: 'text' | 'tool_use' | 'tool_result' | 'stop' | 'error'
114
+ type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stop' | 'error'
106
115
  content?: string
107
116
  toolUse?: ToolUseContent
108
117
  tool_use_id?: string
109
118
  error?: string
110
119
  /** DeepSeek reasoning tokens accumulated during this stream. */
111
120
  reasoning_content?: string
121
+ /** Anthropic thinking block content (DeepSeek Anthropic endpoint). */
122
+ thinking?: string
112
123
  }
113
124
 
114
125
  // ── Config Types ──
@@ -5,17 +5,46 @@ const BLOCKED_PATTERNS = [
5
5
  // Recursive root deletion without preserve-root safeguard
6
6
  /\brm\s+-rf\s+\/(\s|$)/,
7
7
  /\brm\s+-rf\s+\/\*\s*$/,
8
+ /\bsudo\s+rm\s+.*\//,
8
9
  // Filesystem manipulation
9
10
  /\bmkfs\./,
10
11
  /\bdd\s+if=/,
11
12
  // Fork bomb
12
13
  /:\s*\(\s*\)\s*\{\s*:\s*\|/,
13
14
  // Recursive root chmod
14
- /\bchmod\s+.*777\s+\//,
15
+ /\bchmod\s+.*(?:777|o\+w|a\+w)\s+\//,
15
16
  // Direct block device write
16
17
  />\s*\/dev\/sd[a-z]/,
17
18
  // SSH private key theft
18
19
  /\bcat\s+.*\/\.ssh\/id_/,
20
+ // Shell command substitution (commonly used for obfuscation/evasion)
21
+ /\$\(/,
22
+ // Backtick command substitution
23
+ /`[^`]+`/,
24
+ // Interpreter code execution (bypass vector)
25
+ /\bpython3?\s+-c\b/,
26
+ /\bpython3?\s+-m\b/,
27
+ /\bperl\s+-[ep]\b/,
28
+ /\bruby\s+-e\b/,
29
+ /\bnode\s+-e\b/,
30
+ // Reverse shell patterns
31
+ /\bnc\s+.*-e\b/,
32
+ /\bncat\s+.*-e\b/,
33
+ /\bexec\s+\d+<>/,
34
+ // Download + pipe to interpreter
35
+ /\bcurl\s+.*\|\s*(?:ba)?sh\b/,
36
+ /\bwget\s+.*\|\s*(?:ba)?sh\b/,
37
+ /\bcurl\s+.*\|\s*python/,
38
+ /\bwget\s+.*\|\s*python/,
39
+ // Download + execute
40
+ /\bcurl\s+.*-O\s+\/tmp\/.*\s*&&/,
41
+ /\bwget\s+.*-O\s+\/tmp\/.*\s*&&/,
42
+ // Data exfiltration via curl file://
43
+ /\bcurl\s+file:\/\//,
44
+ // SCP exfiltration of sensitive files
45
+ /\bscp\s+.*(?:\.ssh|\.aws|\.env)/,
46
+ // Write to system paths
47
+ />\s*\/(?:etc|usr|boot|sys|proc)\//,
19
48
  ]
20
49
 
21
50
  const BLOCKED_COMMANDS = [
@@ -28,6 +57,13 @@ const BLOCKED_COMMANDS = [
28
57
  'mkfs.fat',
29
58
  'mkfs.vfat',
30
59
  'mkswap',
60
+ 'shutdown',
61
+ 'reboot',
62
+ 'halt',
63
+ 'poweroff',
64
+ 'init',
65
+ 'telinit',
66
+ 'systemctl',
31
67
  ]
32
68
 
33
69
  function isBlocked(command: string): string | null {
@@ -24,7 +24,24 @@ export const editTool: ToolDefinition = {
24
24
  const newStr = params.new_string as string
25
25
  const replaceAll = params.replace_all as boolean
26
26
 
27
+ const MAX_FILE_SIZE = 10_000_000 // 10 MB
28
+ const MAX_STRING_SIZE = 5_000_000 // 5 MB
29
+ if (oldStr.length > MAX_STRING_SIZE || newStr.length > MAX_STRING_SIZE) {
30
+ return {
31
+ success: false,
32
+ content: '',
33
+ error: `String too large. Max: 5 MB per string.`,
34
+ }
35
+ }
36
+
27
37
  const content = readFileSync(filePath, 'utf-8')
38
+ if (content.length > MAX_FILE_SIZE) {
39
+ return {
40
+ success: false,
41
+ content: '',
42
+ error: `File too large (${(content.length / 1e6).toFixed(1)} MB). Max: 10 MB.`,
43
+ }
44
+ }
28
45
 
29
46
  if (replaceAll) {
30
47
  if (!content.includes(oldStr)) {
@@ -25,6 +25,15 @@ export const readTool: ToolDefinition = {
25
25
  if (stat.isDirectory()) {
26
26
  return { success: false, content: '', error: `Path is a directory: ${filePath}` }
27
27
  }
28
+ // Prevent OOM on single-line files (e.g. 500MB JSON blob)
29
+ const MAX_FILE_SIZE = 50_000_000 // 50 MB
30
+ if (stat.size > MAX_FILE_SIZE) {
31
+ return {
32
+ success: false,
33
+ content: '',
34
+ error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
35
+ }
36
+ }
28
37
  const offset = (params.offset as number) || 0
29
38
  const limit = (params.limit as number) || 2000
30
39
  const content = readFileSync(filePath, 'utf-8')
@@ -19,6 +19,16 @@ export const writeTool: ToolDefinition = {
19
19
  async execute(params, ctx) {
20
20
  const filePath = resolveSafe(ctx.cwd, params.file_path as string)
21
21
  const content = params.content as string
22
+
23
+ const MAX_CONTENT_SIZE = 10_000_000 // 10 MB
24
+ if (content.length > MAX_CONTENT_SIZE) {
25
+ return {
26
+ success: false,
27
+ content: '',
28
+ error: `Content too large (${(content.length / 1e6).toFixed(1)} MB). Max: 10 MB.`,
29
+ }
30
+ }
31
+
22
32
  mkdirSync(dirname(filePath), { recursive: true })
23
33
  writeFileSync(filePath, content, 'utf-8')
24
34
  const lines = content.split('\n').length
package/dist/mipham DELETED
Binary file