@miphamai/cli 0.54.0 → 0.55.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.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -10,9 +10,12 @@ interface FrontmatterResult {
10
10
  }
11
11
 
12
12
  function parseFrontmatter(raw: string): FrontmatterResult {
13
- const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
13
+ // Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
14
+ // BOM-prefixed file is silently treated as body text (effectively ignored).
15
+ const src = raw.replace(/^\uFEFF/, '')
16
+ const match = src.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
14
17
  if (!match) {
15
- return { data: {}, content: raw }
18
+ return { data: {}, content: src }
16
19
  }
17
20
  return {
18
21
  data: parseYaml(match[1] || '') as Record<string, unknown>,
@@ -9,7 +9,7 @@ import {
9
9
  } from 'node:fs'
10
10
  import { join, basename } from 'node:path'
11
11
  import { homedir, hostname } from 'node:os'
12
- import type { SessionInfo } from '../../shared/types'
12
+ import type { SessionInfo, CrossSessionInbound } from '../../shared/types'
13
13
 
14
14
  const MIPHAM_HOME = join(homedir(), '.mipham')
15
15
  const ACTIVE_SESSIONS_DIR = join(MIPHAM_HOME, '.active-sessions')
@@ -97,6 +97,7 @@ export function createSessionInfo(
97
97
  cwd?: string,
98
98
  provider?: string,
99
99
  model?: string,
100
+ crossSessionInbound?: CrossSessionInbound,
100
101
  ): SessionInfo {
101
102
  return {
102
103
  id: sessionId,
@@ -107,6 +108,7 @@ export function createSessionInfo(
107
108
  cwd,
108
109
  provider,
109
110
  model,
111
+ crossSessionInbound,
110
112
  }
111
113
  }
112
114
 
@@ -90,6 +90,16 @@ export class MessageRouter {
90
90
  }
91
91
  const targetSession = resolution.session!
92
92
 
93
+ // Recipient refuses inbound messages — report "refused" instead of a silent
94
+ // success so the sender knows the message was not delivered.
95
+ if (targetSession.crossSessionInbound === 'deny') {
96
+ return {
97
+ success: false,
98
+ routedTo: 'inbox',
99
+ error: `Session "${targetSession.name}" refuses inbound messages.`,
100
+ }
101
+ }
102
+
93
103
  // Get sender info — resolve the human-readable name when the sender is a
94
104
  // registered session, else fall back to the raw id (e.g. non-session senders).
95
105
  const senderName = discoverSessions().find((s) => s.id === from)?.name ?? from
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { CommandHandler, CommandContext, CommandResult } from '../ui/commands.js'
9
9
  import { getWorkspaceTrust } from '../core/workspace-trust'
10
+ import { atomicWriteFileSync } from '../shared/atomic-write'
10
11
 
11
12
  export {
12
13
  initCmd,
@@ -20,7 +21,7 @@ export {
20
21
  }
21
22
 
22
23
  const initCmd: CommandHandler = async (ctx) => {
23
- const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')
24
+ const { existsSync, mkdirSync } = await import('node:fs')
24
25
  const { join } = await import('node:path')
25
26
  const { homedir } = await import('node:os')
26
27
 
@@ -78,7 +79,7 @@ providers:
78
79
  ${providerYaml}
79
80
  `
80
81
 
81
- writeFileSync(userConfigPath, configContent, 'utf-8')
82
+ atomicWriteFileSync(userConfigPath, configContent)
82
83
  return {
83
84
  content: `✅ Mipham Code initialized!
84
85
 
@@ -434,7 +435,7 @@ permission: ask
434
435
  providers:
435
436
  ${providerYaml}
436
437
  `
437
- writeFileSync(configPath, defaultConfig, 'utf-8')
438
+ atomicWriteFileSync(configPath, defaultConfig)
438
439
  created.push('.mipham/config.yml')
439
440
  } else {
440
441
  skipped.push('.mipham/config.yml (already exists)')
@@ -6,11 +6,11 @@ import {
6
6
  readdirSync,
7
7
  unlinkSync,
8
8
  chmodSync,
9
- writeFileSync,
10
9
  } from 'node:fs'
11
10
  import { join } from 'node:path'
12
11
  import { homedir } from 'node:os'
13
12
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
13
+ import { atomicWriteFileSync } from '../shared/atomic-write'
14
14
  import type {
15
15
  MiphamConfig,
16
16
  ProviderConfig,
@@ -533,7 +533,7 @@ export function saveProviderApiKey(providerId: string, apiKey: string): boolean
533
533
  doc.providers = providers
534
534
 
535
535
  // Write back
536
- writeFileSync(configPath, stringifyYaml(doc), { encoding: 'utf-8', mode: 0o600 })
536
+ atomicWriteFileSync(configPath, stringifyYaml(doc), { mode: 0o600 })
537
537
  return true
538
538
  } catch (err: unknown) {
539
539
  const msg = err instanceof Error ? err.message : String(err)
@@ -10,9 +10,12 @@ interface FrontmatterResult {
10
10
  }
11
11
 
12
12
  function parseFrontmatter(raw: string): FrontmatterResult {
13
- const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
13
+ // Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
14
+ // BOM-prefixed file is silently treated as body text (effectively ignored).
15
+ const src = raw.replace(/^\uFEFF/, '')
16
+ const match = src.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
14
17
  if (!match) {
15
- return { data: {}, content: raw }
18
+ return { data: {}, content: src }
16
19
  }
17
20
  return {
18
21
  data: parseYaml(match[1] || '') as Record<string, unknown>,
@@ -101,6 +104,16 @@ export class InstructionsLoader {
101
104
  parts.push(this.buildPermissionContext(permissionMode))
102
105
  }
103
106
 
107
+ // 开场克制:寒暄只回一句短问候,不上能力清单(避免把「你好」当「你是谁」处理)
108
+ parts.push(`## Greeting Restraint
109
+
110
+ When the user's message is only a greeting or small talk ("hello", "hi",
111
+ "你好", "您好", "在吗", "早上好"), reply with ONE brief, warm line —
112
+ e.g. "你好,我是 Mipham,有什么可以帮你的?" Do NOT introduce yourself,
113
+ list your capabilities/tools/MCP servers, or enumerate what you can do.
114
+ Only describe your capabilities when the user explicitly asks ("你是谁",
115
+ "你能做什么", "what can you do", "introduce yourself").`)
116
+
104
117
  // Inject critical thinking self-check layer (for analysis/comparison tasks)
105
118
  parts.push(`## Critical Thinking Self-Check
106
119
 
package/src/index.tsx CHANGED
@@ -28,7 +28,7 @@ import { QueryEngine } from './core/engine'
28
28
  import { ExperienceRuleEngine } from './core/rule-engine.js'
29
29
  import { SessionLog } from './core/session-log'
30
30
  import { SessionStore } from './core/session-store'
31
- import type { PermissionLevel, MiphamConfig } from './shared/types'
31
+ import type { PermissionLevel, MiphamConfig, McpServerConfig } from './shared/types'
32
32
  import { SkillsLoader } from './skills/loader'
33
33
  import { PluginManager } from './plugin/plugin-manager'
34
34
  import { loadPlugins } from './plugin/plugin-loader'
@@ -194,6 +194,38 @@ function SetupGate(props: SetupGateProps) {
194
194
  })
195
195
  }
196
196
 
197
+ /**
198
+ * Connect all MCP servers in parallel and register their tools into the shared
199
+ * registry. Runs lazily in the background so a slow or dead server (e.g. a 15s
200
+ * connect timeout) never blocks first paint. Failures are non-fatal.
201
+ */
202
+ async function connectMcpServers(
203
+ mcpServers: McpServerConfig[],
204
+ tools: ReturnType<typeof createToolRegistry>,
205
+ ): Promise<void> {
206
+ if (mcpServers.length === 0) return
207
+ const mcp = McpClient.getInstance()
208
+ // Wire runtime tool-list changes before connect so mid-connect updates land.
209
+ syncMcpToolsOnChange(mcp, tools)
210
+ const results = await Promise.allSettled(
211
+ mcpServers.map(async (server) => {
212
+ await mcp.connect(server)
213
+ const count = registerMcpServerTools(server.name, tools)
214
+ if (count > 0) {
215
+ process.stderr.write(`[mcp] "${server.name}": registered ${count} tools\n`)
216
+ }
217
+ }),
218
+ )
219
+ for (let i = 0; i < results.length; i++) {
220
+ const result = results[i]!
221
+ if (result.status === 'rejected') {
222
+ process.stderr.write(
223
+ `[mcp] Failed to connect "${mcpServers[i]!.name}": ${String(result.reason)}\n`,
224
+ )
225
+ }
226
+ }
227
+ }
228
+
197
229
  export async function runApp(options: RunOptions): Promise<void> {
198
230
  // Metrics: count CLI invocation + active session
199
231
  getMetrics().cliInvocations.inc()
@@ -455,33 +487,10 @@ export async function runApp(options: RunOptions): Promise<void> {
455
487
  mountSkills(vajraContext, skillsLoader)
456
488
  mountLlm(vajraContext, registry)
457
489
 
458
- // Connect MCP servers and register their tools into the tool registry.
459
- // Uses Promise.allSettled for parallel connection — failures are non-fatal.
460
- const mcpServers = config.skills?.mcpServers ?? []
461
- if (mcpServers.length > 0) {
462
- const mcp = McpClient.getInstance()
463
- const results = await Promise.allSettled(
464
- mcpServers.map(async (server) => {
465
- await mcp.connect(server)
466
- const count = registerMcpServerTools(server.name, tools)
467
- if (count > 0) {
468
- process.stderr.write(`[mcp] "${server.name}": registered ${count} tools\n`)
469
- }
470
- }),
471
- )
472
- // Log failures (non-fatal — app starts without that server's tools)
473
- for (let i = 0; i < results.length; i++) {
474
- const result = results[i]!
475
- if (result.status === 'rejected') {
476
- process.stderr.write(
477
- `[mcp] Failed to connect "${mcpServers[i]!.name}": ${String(result.reason)}\n`,
478
- )
479
- }
480
- }
481
- }
482
-
483
- // Wire runtime MCP tool changes (tools/list_changed) into the central registry.
484
- syncMcpToolsOnChange(McpClient.getInstance(), tools)
490
+ // Connect MCP servers lazily in the background: the UI renders immediately,
491
+ // and each server's tools register into the shared registry as it connects.
492
+ // A dead server (e.g. a 15s connect timeout) no longer blocks startup.
493
+ void connectMcpServers(config.skills?.mcpServers ?? [], tools)
485
494
 
486
495
  // Initialize hook engine — register skill-defined hooks
487
496
  const hookEngine = new HookEngine()
@@ -549,12 +558,20 @@ export async function runApp(options: RunOptions): Promise<void> {
549
558
  // so `@mipham-code` can address this session; uniqueness is enforced against
550
559
  // other live sessions (suffix -2, -3, …). The id stays a stable `session-<ts>`.
551
560
  const defaultName = basename(process.cwd()) || 'session'
561
+
562
+ // Load cross-session config first so the registered SessionInfo carries this
563
+ // session's inbound policy — senders discover it and report "refused" instead
564
+ // of a silent drop when policy is 'deny'.
565
+ const crossSessionConfig = loadCrossSessionConfig(process.cwd())
566
+ engine.setCrossSessionConfig(crossSessionConfig)
567
+
552
568
  const sessionInfo = createSessionInfo(
553
569
  sessionName,
554
570
  ensureUniqueSessionName(defaultName, discoverSessions()),
555
571
  process.cwd(),
556
572
  defaultProvider,
557
573
  defaultModel,
574
+ crossSessionConfig.crossSessionInbound,
558
575
  )
559
576
  registerActiveSession(sessionInfo)
560
577
 
@@ -568,10 +585,6 @@ export async function runApp(options: RunOptions): Promise<void> {
568
585
  heartbeatInterval.unref()
569
586
  }
570
587
 
571
- // Load cross-session config and wire into the engine's inbound policy
572
- const crossSessionConfig = loadCrossSessionConfig(process.cwd())
573
- engine.setCrossSessionConfig(crossSessionConfig)
574
-
575
588
  // P2-1: Trigger SessionStart hooks after full initialization
576
589
  hookEngine.executeSessionStart(sessionName).catch(() => {
577
590
  // Hook failures never block session startup
@@ -0,0 +1,17 @@
1
+ import { writeFileSync, renameSync } from 'node:fs'
2
+
3
+ /**
4
+ * Write a file atomically: write to a same-directory `.tmp` file, then rename
5
+ * over the target. Same-filesystem rename is atomic, so a crash or kill
6
+ * mid-write can never leave a truncated/corrupt file — readers see either the
7
+ * old or the new content, never a partial write.
8
+ */
9
+ export function atomicWriteFileSync(
10
+ path: string,
11
+ content: string,
12
+ options: { mode?: number } = {},
13
+ ): void {
14
+ const tmp = path + '.tmp'
15
+ writeFileSync(tmp, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
16
+ renameSync(tmp, path)
17
+ }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.54.0' as const
12
+ export const PACKAGE_VERSION = '0.55.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -430,6 +430,7 @@ export interface SessionInfo {
430
430
  cwd?: string
431
431
  provider?: string
432
432
  model?: string
433
+ crossSessionInbound?: CrossSessionInbound
433
434
  }
434
435
 
435
436
  // ── Background Agent Types ──
@@ -13,9 +13,12 @@ interface FrontmatterResult {
13
13
  }
14
14
 
15
15
  function parseFrontmatter(raw: string): FrontmatterResult {
16
- const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
16
+ // Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
17
+ // BOM-prefixed file is silently treated as body text (effectively ignored).
18
+ const src = raw.replace(/^\uFEFF/, '')
19
+ const match = src.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
17
20
  if (!match) {
18
- return { data: {}, content: raw }
21
+ return { data: {}, content: src }
19
22
  }
20
23
  return {
21
24
  data: parseYaml(match[1] || '') as Record<string, unknown>,
@@ -14,7 +14,8 @@ import { useI18n } from '../i18n-context'
14
14
  import TextInput from 'ink-text-input'
15
15
  import { DEFAULT_PROVIDERS, OLLAMA_PRESET_MODELS } from '../shared/constants'
16
16
  import type { ModelInfo } from '../shared/types'
17
- import { mkdirSync, writeFileSync } from 'node:fs'
17
+ import { mkdirSync } from 'node:fs'
18
+ import { atomicWriteFileSync } from '../shared/atomic-write'
18
19
  import { join } from 'node:path'
19
20
  import { homedir } from 'node:os'
20
21
  import { execSync } from 'node:child_process'
@@ -115,7 +116,7 @@ function writeConfigFile(providerId: string, modelId: string, apiKey: string): v
115
116
  ...models.map((m) => ` - id: ${m.id}`),
116
117
  ]
117
118
 
118
- writeFileSync(join(configDir, 'config.yml'), lines.join('\n'), 'utf-8')
119
+ atomicWriteFileSync(join(configDir, 'config.yml'), lines.join('\n'))
119
120
  }
120
121
 
121
122
  function checkOllama(): { installed: boolean; running: boolean; models: string[] } {
@@ -1,95 +0,0 @@
1
- /**
2
- * OutputStylesLoader — loads custom response persona styles from .mipham/output-styles/.
3
- *
4
- * Each .md file in the directory is a named style. The active style's content
5
- * is injected into the system prompt to shape the AI's tone and communication style.
6
- *
7
- * Usage:
8
- * const loader = new OutputStylesLoader(cwd)
9
- * loader.list() // ['concise', 'academic', ...]
10
- * loader.get('concise') // style content or undefined
11
- * loader.setActive('concise') // switch active style
12
- * loader.getActiveContent() // active style content (for system prompt)
13
- */
14
-
15
- import { readdirSync, readFileSync, existsSync } from 'node:fs'
16
- import { join, basename, extname } from 'node:path'
17
-
18
- export class OutputStylesLoader {
19
- private stylesDir: string
20
- private activeStyle: string | null = null
21
-
22
- constructor(cwd: string) {
23
- this.stylesDir = join(cwd, '.mipham', 'output-styles')
24
- }
25
-
26
- /**
27
- * List all available style names (filename without .md extension).
28
- */
29
- list(): string[] {
30
- if (!existsSync(this.stylesDir)) return []
31
- try {
32
- return readdirSync(this.stylesDir)
33
- .filter((f) => f.endsWith('.md'))
34
- .map((f) => basename(f, extname(f)))
35
- } catch {
36
- return []
37
- }
38
- }
39
-
40
- /**
41
- * Get the content of a named style. Returns undefined if not found.
42
- */
43
- get(name: string): string | undefined {
44
- if (!existsSync(this.stylesDir)) return undefined
45
- const filepath = join(this.stylesDir, `${name}.md`)
46
- if (!existsSync(filepath)) return undefined
47
- try {
48
- return readFileSync(filepath, 'utf-8').trim()
49
- } catch {
50
- return undefined
51
- }
52
- }
53
-
54
- /**
55
- * Set the active output style.
56
- */
57
- setActive(name: string): boolean {
58
- const content = this.get(name)
59
- if (!content) return false
60
- this.activeStyle = name
61
- return true
62
- }
63
-
64
- /**
65
- * Clear the active output style (revert to default personality).
66
- */
67
- clearActive(): void {
68
- this.activeStyle = null
69
- }
70
-
71
- /**
72
- * Get the active style name (null if none selected).
73
- */
74
- getActiveName(): string | null {
75
- return this.activeStyle
76
- }
77
-
78
- /**
79
- * Get the active style content for system prompt injection.
80
- * Returns empty string if no style is active.
81
- */
82
- getActiveContent(): string {
83
- if (!this.activeStyle) return ''
84
- const content = this.get(this.activeStyle)
85
- if (!content) return ''
86
- return [
87
- `[Output Style: ${this.activeStyle}]`,
88
- 'Adopt the following communication style in all your responses:',
89
- '',
90
- content,
91
- '',
92
- '---',
93
- ].join('\n')
94
- }
95
- }