@miphamai/cli 0.54.0 → 0.56.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/bin/mipham.ts CHANGED
@@ -87,7 +87,9 @@ async function runWorkflowCLI(): Promise<boolean> {
87
87
  })
88
88
 
89
89
  // Commander will handle --help, unknown commands, etc.
90
- await program.parseAsync(process.argv)
90
+ // Skip node + script + the leading `workflow` keyword (already matched by the
91
+ // caller); `from: 'user'` stops commander from stripping two more elements.
92
+ await program.parseAsync(process.argv.slice(3), { from: 'user' })
91
93
  return true
92
94
  }
93
95
 
@@ -185,7 +187,9 @@ async function runPluginCLI(): Promise<boolean> {
185
187
  process.exit(result.success ? 0 : 1)
186
188
  })
187
189
 
188
- await program.parseAsync(process.argv)
190
+ // Skip node + script + the leading `plugin` keyword (already matched by the
191
+ // caller); `from: 'user'` stops commander from stripping two more elements.
192
+ await program.parseAsync(process.argv.slice(3), { from: 'user' })
189
193
  return true
190
194
  }
191
195
 
@@ -1054,6 +1058,21 @@ async function runTokenCLI(): Promise<boolean> {
1054
1058
  }
1055
1059
 
1056
1060
  async function main() {
1061
+ // ── Deleted-cwd guard ──────────────────────────────────────────────────
1062
+ // `process.cwd()` throws ENOENT when the directory the process was launched
1063
+ // from no longer exists (e.g. a removed git worktree). Print a clear message
1064
+ // instead of a raw crash dump (matches Claude Code 2.1.239).
1065
+ const { isDeletedCwdError, deletedCwdMessage } = await import('../src/shared/deleted-cwd')
1066
+ try {
1067
+ process.cwd()
1068
+ } catch (err) {
1069
+ if (isDeletedCwdError(err)) {
1070
+ console.error(deletedCwdMessage())
1071
+ process.exit(1)
1072
+ }
1073
+ throw err
1074
+ }
1075
+
1057
1076
  // ── Disable terminal special characters ──────────────────────────────────
1058
1077
  // Terminal intercepts Ctrl+S (XOFF), Ctrl+T (SIGINFO/status), Ctrl+R (rprnt)
1059
1078
  // before the app sees them. Disable these so Agent Dashboard shortcuts work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.54.0",
3
+ "version": "0.56.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)
@@ -38,6 +38,7 @@ import { UsageTracker } from './usage-tracker'
38
38
  import { getMetrics } from './metrics'
39
39
  import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
40
40
  import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
41
+ import { accumulateGraftSavings } from '../shared/graft-savings'
41
42
  import { getMessageBus, type AgentMessage } from '../agent/message-bus'
42
43
  import { createT } from '../i18n-core/t'
43
44
  import enUS from '../i18n-core/locales/en-US.json'
@@ -1127,6 +1128,9 @@ export class QueryEngine {
1127
1128
  tracker.persist()
1128
1129
  }
1129
1130
 
1131
+ // Accumulate graft token savings from "[graft] tokens saved ≈ N" footers
1132
+ accumulateGraftSavings(result.content)
1133
+
1130
1134
  return result
1131
1135
  } catch (err) {
1132
1136
  // P1-3: Trigger PostToolUseFailure hook on tool execution errors
@@ -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()
@@ -364,7 +396,7 @@ export async function runApp(options: RunOptions): Promise<void> {
364
396
  if (context.getMessageCount() === 0) {
365
397
  const basePrompt = instructions.buildSystemPrompt(config.permission as string)
366
398
  const memoryReminder = loadSessionMemories(basePrompt)
367
- const skillsReminder = skillsLoader.buildSystemReminder(basePrompt)
399
+ const skillsReminder = skillsLoader.buildSystemReminder()
368
400
 
369
401
  // Inject previous session summary for AI continuity
370
402
  let prompt = basePrompt
@@ -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,174 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { SkillsLoader } from '../skills/loader'
4
+ import type { AgentRegistry } from '../agent/agent-registry'
5
+ import type { McpClient } from '../mcp/client'
6
+ import type { McpServerConfig, ToolDefinition } from '../shared/types'
7
+ import { registerMcpServerTools } from '../mcp/registry'
8
+
9
+ /**
10
+ * Claude marketplace plugin adapter — loads the portable content of a Claude
11
+ * plugin (`.claude-plugin/plugin.json` + skills/agents/MCP) into Mipham Code's
12
+ * own subsystems. Commands (`commands/*.md`) and hooks (`hooks/hooks.json`) are
13
+ * not mapped here: Mipham Code has no file-based slash-command system, and the
14
+ * hook matcher semantics differ.
15
+ */
16
+
17
+ interface ClaudeManifest {
18
+ name: string
19
+ version?: string
20
+ mcpServers?: Record<string, unknown> | string
21
+ [key: string]: unknown
22
+ }
23
+
24
+ /** Read the Claude manifest at `<dir>/.claude-plugin/plugin.json`. */
25
+ export function readClaudeManifest(dir: string): ClaudeManifest | null {
26
+ const path = join(dir, '.claude-plugin', 'plugin.json')
27
+ if (!existsSync(path)) return null
28
+ try {
29
+ return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeManifest
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ /** Expand `${CLAUDE_PLUGIN_ROOT}` (Claude's plugin-root placeholder). */
36
+ function expandPluginRoot(value: string, pluginRoot: string): string {
37
+ return value.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot)
38
+ }
39
+
40
+ /** Load Claude skills — `skills/<name>/SKILL.md` (no leading dot on SKILL.md). */
41
+ function loadClaudeSkills(dir: string, skillsLoader: SkillsLoader): void {
42
+ const skillsDir = join(dir, 'skills')
43
+ if (!existsSync(skillsDir)) return
44
+ let entries: string[] = []
45
+ try {
46
+ entries = readdirSync(skillsDir)
47
+ } catch {
48
+ return
49
+ }
50
+ for (const entry of entries) {
51
+ let skillFile: string | null = null
52
+ try {
53
+ const full = join(skillsDir, entry)
54
+ if (statSync(full).isDirectory()) {
55
+ const candidate = join(full, 'SKILL.md')
56
+ if (existsSync(candidate)) skillFile = candidate
57
+ } else if (entry === 'SKILL.md') {
58
+ skillFile = full
59
+ }
60
+ } catch {
61
+ continue
62
+ }
63
+ if (skillFile) skillsLoader.loadSkillFile(skillFile, 'standard')
64
+ }
65
+ }
66
+
67
+ /** Map a Claude MCP server entry to a Mipham `McpServerConfig`. */
68
+ function toMcpServerConfig(name: string, raw: Record<string, unknown>): McpServerConfig | null {
69
+ const command = typeof raw.command === 'string' ? raw.command : undefined
70
+ const url = typeof raw.url === 'string' ? raw.url : undefined
71
+ if (!command && !url) return null
72
+
73
+ const cfg: McpServerConfig = { name }
74
+ if (command) {
75
+ cfg.command = command
76
+ cfg.args = Array.isArray(raw.args) ? raw.args.map(String) : undefined
77
+ } else {
78
+ cfg.url = url
79
+ }
80
+ if (raw.env && typeof raw.env === 'object') {
81
+ cfg.env = raw.env as Record<string, string>
82
+ }
83
+ if (raw.headers && typeof raw.headers === 'object') {
84
+ cfg.headers = raw.headers as Record<string, string>
85
+ }
86
+ return cfg
87
+ }
88
+
89
+ /** Load Claude MCP servers (`.mcp.json` + inline `mcpServers`) into the client. */
90
+ function loadClaudeMcp(
91
+ dir: string,
92
+ manifest: ClaudeManifest,
93
+ mcpClient: Pick<McpClient, 'connect'>,
94
+ toolsMap: Map<string, ToolDefinition>,
95
+ ): string[] {
96
+ const servers: McpServerConfig[] = []
97
+
98
+ const collect = (obj: unknown): void => {
99
+ if (!obj || typeof obj !== 'object') return
100
+ for (const [name, raw] of Object.entries(obj as Record<string, Record<string, unknown>>)) {
101
+ const cfg = toMcpServerConfig(name, raw)
102
+ if (cfg) servers.push(cfg)
103
+ }
104
+ }
105
+
106
+ // 1. `.mcp.json` at the plugin root
107
+ const mcpJsonPath = join(dir, '.mcp.json')
108
+ if (existsSync(mcpJsonPath)) {
109
+ try {
110
+ collect(JSON.parse(readFileSync(mcpJsonPath, 'utf-8')).mcpServers)
111
+ } catch {
112
+ /* skip unparseable .mcp.json */
113
+ }
114
+ }
115
+ // 2. Inline `mcpServers` field in the manifest
116
+ collect(manifest.mcpServers)
117
+
118
+ for (const cfg of servers) {
119
+ if (cfg.command) cfg.command = expandPluginRoot(cfg.command, dir)
120
+ if (cfg.args) cfg.args = cfg.args.map((a) => expandPluginRoot(a, dir))
121
+ if (cfg.env) {
122
+ for (const key of Object.keys(cfg.env)) cfg.env[key] = expandPluginRoot(cfg.env[key]!, dir)
123
+ }
124
+
125
+ mcpClient
126
+ .connect(cfg)
127
+ .then(() => {
128
+ const count = registerMcpServerTools(cfg.name, toolsMap)
129
+ if (count > 0) {
130
+ process.stderr.write(
131
+ `[plugin] "${manifest.name}": registered ${count} MCP tools from "${cfg.name}"\n`,
132
+ )
133
+ }
134
+ })
135
+ .catch((err: unknown) => {
136
+ process.stderr.write(
137
+ `[plugin] Failed to connect MCP "${cfg.name}" from "${manifest.name}": ${String(err)}\n`,
138
+ )
139
+ })
140
+ }
141
+
142
+ return servers.map((s) => s.name)
143
+ }
144
+
145
+ /** Load a Claude plugin's portable content (skills + agents + MCP) into Mipham Code.
146
+ * Returns the names of the connected MCP servers so the caller can wire cleanup. */
147
+ export function loadClaudePlugin(
148
+ dir: string,
149
+ deps: {
150
+ skillsLoader: SkillsLoader
151
+ agentRegistry: AgentRegistry
152
+ mcpClient: Pick<McpClient, 'connect'>
153
+ toolsMap: Map<string, ToolDefinition>
154
+ },
155
+ ): string[] {
156
+ const manifest = readClaudeManifest(dir)
157
+ if (!manifest) return []
158
+
159
+ loadClaudeSkills(dir, deps.skillsLoader)
160
+
161
+ // Claude agents use `.md` + frontmatter; largely compatible with Mipham's AgentDefinition.
162
+ const agentsDir = join(dir, 'agents')
163
+ if (existsSync(agentsDir)) {
164
+ try {
165
+ deps.agentRegistry.loadDirectory(agentsDir, 'user')
166
+ } catch (err) {
167
+ process.stderr.write(
168
+ `[plugin] Failed to load agents from "${manifest.name}": ${String(err)}\n`,
169
+ )
170
+ }
171
+ }
172
+
173
+ return loadClaudeMcp(dir, manifest, deps.mcpClient, deps.toolsMap)
174
+ }
@@ -7,6 +7,8 @@ import type { HookEngine } from '../core/hooks'
7
7
  import type { McpClient } from '../mcp/client'
8
8
  import { registerMcpServerTools } from '../mcp/registry'
9
9
  import { executeHook } from '../core/hooks-executor'
10
+ import { detectPluginFormat } from './plugin-validator'
11
+ import { loadClaudePlugin } from './claude-plugin'
10
12
  import type { McpServerConfig, ToolDefinition, HookConfig, HookEvent } from '../shared/types'
11
13
 
12
14
  /**
@@ -24,6 +26,29 @@ export function loadPlugins(
24
26
  toolsMap: Map<string, ToolDefinition>,
25
27
  ): void {
26
28
  for (const plugin of pluginManager.getEnabled()) {
29
+ // Claude marketplace plugins: load their portable content via the adapter.
30
+ if (detectPluginFormat(plugin.path) === 'claude') {
31
+ const claudeMcpServers = loadClaudePlugin(plugin.path, {
32
+ skillsLoader,
33
+ agentRegistry,
34
+ mcpClient,
35
+ toolsMap,
36
+ })
37
+ pluginManager.onRemove(plugin.name, () => {
38
+ for (const serverName of claudeMcpServers) {
39
+ try {
40
+ const toolNames = mcpClient.disconnect(serverName)
41
+ for (const toolName of toolNames) {
42
+ toolsMap.delete(`mcp__${serverName}__${toolName}`)
43
+ }
44
+ } catch {
45
+ /* best effort */
46
+ }
47
+ }
48
+ })
49
+ continue
50
+ }
51
+
27
52
  const mcpServers: string[] = []
28
53
  const hookEvents: HookEvent[] = []
29
54
 
@@ -1,4 +1,12 @@
1
- import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'
1
+ import {
2
+ mkdirSync,
3
+ existsSync,
4
+ writeFileSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ renameSync,
9
+ } from 'node:fs'
2
10
  import { join } from 'node:path'
3
11
  import { homedir } from 'node:os'
4
12
  import { execSync } from 'node:child_process'
@@ -48,7 +56,7 @@ export class PluginManager {
48
56
  const similarWarning = this.findSimilarWarning(validation.manifest.name)
49
57
  this.plugins.push({
50
58
  name: validation.manifest.name,
51
- version: validation.manifest.version,
59
+ version: validation.manifest.version || '0.0.0',
52
60
  path: destDir,
53
61
  enabled: true,
54
62
  installedAt: new Date().toISOString(),
@@ -78,44 +86,65 @@ export class PluginManager {
78
86
  return { success: false, message: `Invalid package name: "${packageName}"` }
79
87
  }
80
88
 
81
- // Derive plugin name from npm package name (strip scope prefix)
82
- const pluginName = packageName.replace(/^@.+\//, '').replace(/^mipham-plugin-/, '')
89
+ // Staging dir name derived from the npm package name (scope stripped). The
90
+ // final directory is renamed to the manifest `name` once validated — the two
91
+ // can differ (e.g. a scoped package whose manifest declares a shorter name).
92
+ const stagingName = packageName.replace(/^@.+\//, '').replace(/^mipham-plugin-/, '')
83
93
 
84
- const destDir = join(this.pluginDir, pluginName)
85
- if (existsSync(destDir)) {
94
+ const stagingDir = join(this.pluginDir, stagingName)
95
+ if (existsSync(stagingDir)) {
86
96
  return {
87
97
  success: false,
88
- message: `Plugin "${pluginName}" is already installed`,
98
+ message: `Plugin "${stagingName}" is already installed`,
89
99
  }
90
100
  }
91
101
 
92
102
  try {
93
103
  // Install the npm package into the plugins directory
94
- mkdirSync(destDir, { recursive: true })
104
+ mkdirSync(stagingDir, { recursive: true })
95
105
 
96
106
  // Create a minimal package.json so npm install works in a subdirectory
97
107
  writeFileSync(
98
- join(destDir, 'package.json'),
108
+ join(stagingDir, 'package.json'),
99
109
  JSON.stringify({ private: true }, null, 2),
100
110
  'utf-8',
101
111
  )
102
112
 
103
- execSync(`npm install ${packageName} --prefix "${destDir}" --no-save`, {
113
+ execSync(`npm install ${packageName} --prefix "${stagingDir}" --no-save`, {
104
114
  encoding: 'utf-8',
105
115
  stdio: 'pipe',
106
116
  timeout: 60_000,
107
117
  })
108
118
 
109
- // Validate the installed plugin
110
- const validation = validatePlugin(destDir)
119
+ // npm installs the package into <stagingDir>/node_modules/<packageName>/ —
120
+ // validate there, then flatten it to the plugin dir root so its layout
121
+ // matches `install(sourcePath)`.
122
+ const pkgDir = join(stagingDir, 'node_modules', packageName)
123
+ const validation = validatePlugin(pkgDir)
111
124
  if (!validation.valid) {
112
125
  // Clean up on invalid plugin
113
- rmSync(destDir, { recursive: true, force: true })
126
+ rmSync(stagingDir, { recursive: true, force: true })
114
127
  return { success: false, message: validation.errors.join('; ') }
115
128
  }
116
129
 
130
+ this.copyDir(pkgDir, stagingDir)
131
+ rmSync(join(stagingDir, 'node_modules'), { recursive: true, force: true })
132
+
133
+ // Use the manifest `name` for the final directory + record + message so
134
+ // they all agree (npm package name is only the staging key).
135
+ const manifestName = validation.manifest?.name || stagingName
136
+ let destDir = stagingDir
137
+ if (manifestName !== stagingName) {
138
+ destDir = join(this.pluginDir, manifestName)
139
+ if (existsSync(destDir)) {
140
+ rmSync(stagingDir, { recursive: true, force: true })
141
+ return { success: false, message: `Plugin "${manifestName}" is already installed` }
142
+ }
143
+ renameSync(stagingDir, destDir)
144
+ }
145
+
117
146
  this.plugins.push({
118
- name: validation.manifest?.name || pluginName,
147
+ name: manifestName,
119
148
  version: validation.manifest?.version || '0.0.0',
120
149
  path: destDir,
121
150
  enabled: true,
@@ -123,18 +152,18 @@ export class PluginManager {
123
152
  })
124
153
 
125
154
  this.saveState()
126
- const similarWarning = this.findSimilarWarning(pluginName)
155
+ const similarWarning = this.findSimilarWarning(manifestName)
127
156
  return {
128
157
  success: true,
129
158
  message:
130
- `Plugin "${pluginName}" installed from npm` +
159
+ `Plugin "${manifestName}" installed from npm` +
131
160
  (similarWarning ? `\n⚠ ${similarWarning}` : ''),
132
161
  }
133
162
  } catch (err: unknown) {
134
163
  const msg = err instanceof Error ? err.message : String(err)
135
164
  // Clean up on failure
136
165
  try {
137
- rmSync(destDir, { recursive: true, force: true })
166
+ rmSync(stagingDir, { recursive: true, force: true })
138
167
  } catch {
139
168
  /* ok */
140
169
  }
@@ -1,32 +1,67 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { readFileSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  export interface PluginManifest {
5
5
  name: string
6
- version: string
6
+ version?: string
7
7
  miphamVersion?: string
8
8
  hooks?: Record<string, unknown>
9
+ /** Claude manifest fields (commands/agents/skills/mcpServers/…) — read by the Claude loader. */
10
+ [key: string]: unknown
9
11
  }
10
12
 
11
- export function validatePlugin(dir: string): {
13
+ /** Which manifest convention a plugin uses. */
14
+ export type PluginFormat = 'mipham' | 'claude'
15
+
16
+ export interface PluginValidation {
12
17
  valid: boolean
13
18
  errors: string[]
14
19
  manifest?: PluginManifest
15
- } {
16
- const errors: string[] = []
20
+ format: PluginFormat
21
+ /** Resolved manifest path (empty when no manifest found). */
22
+ manifestPath: string
23
+ }
24
+
25
+ /**
26
+ * Resolve a plugin's manifest. Mipham plugins use `plugin.json` at the plugin
27
+ * root; Claude marketplace plugins use `.claude-plugin/plugin.json`.
28
+ */
29
+ function resolveManifestPath(dir: string): { path: string; format: PluginFormat } | null {
30
+ const mipham = join(dir, 'plugin.json')
31
+ if (existsSync(mipham)) return { path: mipham, format: 'mipham' }
32
+ const claude = join(dir, '.claude-plugin', 'plugin.json')
33
+ if (existsSync(claude)) return { path: claude, format: 'claude' }
34
+ return null
35
+ }
36
+
37
+ /** Detect a plugin's manifest convention (defaults to Mipham when absent). */
38
+ export function detectPluginFormat(dir: string): PluginFormat {
39
+ return resolveManifestPath(dir)?.format ?? 'mipham'
40
+ }
17
41
 
42
+ export function validatePlugin(dir: string): PluginValidation {
43
+ const resolved = resolveManifestPath(dir)
44
+ if (!resolved) {
45
+ return {
46
+ valid: false,
47
+ errors: ['No plugin manifest found (expected plugin.json or .claude-plugin/plugin.json)'],
48
+ format: 'mipham',
49
+ manifestPath: '',
50
+ }
51
+ }
52
+
53
+ const errors: string[] = []
18
54
  try {
19
- const raw = readFileSync(join(dir, 'plugin.json'), 'utf-8')
55
+ const raw = readFileSync(resolved.path, 'utf-8')
20
56
  const manifest = JSON.parse(raw) as PluginManifest
21
57
 
22
58
  if (!manifest.name || !/^[a-z0-9-]+$/.test(manifest.name)) {
23
59
  errors.push('Invalid plugin name: must be lowercase alphanumeric with hyphens')
24
60
  }
25
- if (!manifest.version) {
61
+ // Mipham plugins require a version; Claude plugins only require `name`.
62
+ if (resolved.format === 'mipham' && !manifest.version) {
26
63
  errors.push('Missing required field: version')
27
64
  }
28
-
29
- // Check for suspicious hooks
30
65
  if (manifest.hooks) {
31
66
  const hooksStr = JSON.stringify(manifest.hooks)
32
67
  if (hooksStr.includes('rm -rf') || hooksStr.includes('curl') || hooksStr.includes('eval')) {
@@ -34,8 +69,20 @@ export function validatePlugin(dir: string): {
34
69
  }
35
70
  }
36
71
 
37
- return { valid: errors.length === 0, errors, manifest }
72
+ return {
73
+ valid: errors.length === 0,
74
+ errors,
75
+ manifest,
76
+ format: resolved.format,
77
+ manifestPath: resolved.path,
78
+ }
38
79
  } catch (err) {
39
- return { valid: false, errors: [`Failed to read plugin.json: ${String(err)}`] }
80
+ const label = resolved.format === 'claude' ? '.claude-plugin/plugin.json' : 'plugin.json'
81
+ return {
82
+ valid: false,
83
+ errors: [`Failed to read ${label}: ${String(err)}`],
84
+ format: resolved.format,
85
+ manifestPath: resolved.path,
86
+ }
40
87
  }
41
88
  }
@@ -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
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Detection and messaging for the "current working directory was deleted"
3
+ * startup failure.
4
+ *
5
+ * `process.cwd()` throws `ENOENT` when the directory the process was launched
6
+ * from no longer exists (e.g. a removed git worktree). The CLI entry checks
7
+ * this up front and prints a clear, actionable message instead of letting the
8
+ * error surface as a raw crash dump (matches Claude Code 2.1.239).
9
+ */
10
+
11
+ /** True when `err` is the ENOENT thrown by `process.cwd()` on a deleted cwd. */
12
+ export function isDeletedCwdError(err: unknown): boolean {
13
+ if (!(err instanceof Error)) return false
14
+ // Node sets `code = 'ENOENT'` with a message ending in `, uv_cwd`. Bun and
15
+ // other runtimes may omit the code but still name `getcwd`/`uv_cwd`.
16
+ return (err as NodeJS.ErrnoException).code === 'ENOENT' || /uv_cwd|getcwd/i.test(err.message)
17
+ }
18
+
19
+ /** Human-readable guidance shown when the launch directory was deleted. */
20
+ export function deletedCwdMessage(): string {
21
+ return (
22
+ `Mipham Code can't start: the current working directory no longer exists.\n\n` +
23
+ `The directory you launched from was deleted (for example, a removed git worktree).\n` +
24
+ `Change to a valid directory and run \`mipham\` again.`
25
+ )
26
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Session-scoped accumulator for graft's token savings. Graft tools emit a
3
+ * "[graft] tokens saved ≈ N" footer on their output; the engine parses that
4
+ * footer after each tool result and adds it here, so the statusline can show
5
+ * "~N tok saved" (mirroring graft's own hooks.js `handleToolSavings`).
6
+ */
7
+
8
+ /** Sum the "[graft] tokens saved ≈ N" footers in a tool output. */
9
+ export function parseGraftSavings(text: string): number {
10
+ let total = 0
11
+ for (const m of text.matchAll(/\[graft\] tokens saved ≈ ([\d,]+)/g)) {
12
+ const digits = m[1]
13
+ if (digits) total += Number(digits.replace(/,/g, '')) || 0
14
+ }
15
+ return total
16
+ }
17
+
18
+ let savings = 0
19
+
20
+ /** Add a tool result's graft savings to the session running total. */
21
+ export function accumulateGraftSavings(text: string): void {
22
+ if (!text) return
23
+ savings += parseGraftSavings(text)
24
+ }
25
+
26
+ /** The session's running graft token savings. */
27
+ export function getGraftSavings(): number {
28
+ return savings
29
+ }
30
+
31
+ /** Reset the running total (e.g. on session reset). */
32
+ export function resetGraftSavings(): void {
33
+ savings = 0
34
+ }
@@ -0,0 +1,36 @@
1
+ import { readFileSync, existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /** Graft's hook-maintained stats cache (`graft/.cache/stats.json`). */
5
+ export interface GraftStats {
6
+ nodeCount: number
7
+ edgeCount: number
8
+ staleCount: number
9
+ dirty: boolean
10
+ syncing: boolean
11
+ lastFile: string | null
12
+ }
13
+
14
+ /**
15
+ * Read graft's stats cache for the given project. Returns null when graft is
16
+ * not built/installed in `cwd` (no `graft/.cache/stats.json`, or an empty
17
+ * graph) — callers render nothing in that case.
18
+ */
19
+ export function readGraftStats(cwd: string): GraftStats | null {
20
+ const statsPath = join(cwd, 'graft', '.cache', 'stats.json')
21
+ if (!existsSync(statsPath)) return null
22
+ try {
23
+ const raw = JSON.parse(readFileSync(statsPath, 'utf-8')) as Record<string, unknown>
24
+ if (typeof raw.nodeCount !== 'number' || raw.nodeCount === 0) return null
25
+ return {
26
+ nodeCount: raw.nodeCount,
27
+ edgeCount: typeof raw.edgeCount === 'number' ? raw.edgeCount : 0,
28
+ staleCount: typeof raw.staleCount === 'number' ? raw.staleCount : 0,
29
+ dirty: raw.dirty === true,
30
+ syncing: raw.syncing === true,
31
+ lastFile: typeof raw.lastFile === 'string' ? raw.lastFile : null,
32
+ }
33
+ } catch {
34
+ return null
35
+ }
36
+ }
@@ -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.56.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>,
@@ -79,6 +82,11 @@ export class SkillsLoader implements Skills {
79
82
  }
80
83
  }
81
84
 
85
+ /** Load a single skill file by path (used by Claude plugins: `skills/<name>/SKILL.md`). */
86
+ loadSkillFile(path: string, type: 'standard' | 'mipham' = 'standard'): void {
87
+ this.tryLoad(path, type)
88
+ }
89
+
82
90
  get(name: string): SkillDefinition | undefined {
83
91
  return this.skills.get(name)
84
92
  }
@@ -192,40 +200,13 @@ export class SkillsLoader implements Skills {
192
200
  }
193
201
 
194
202
  /**
195
- * Recall the skills most relevant to a context (e.g. the session's system
196
- * prompt / project context), mirroring MemoryManager.recall's keyword
197
- * scoring. Name keywords are a strong signal (+3); description keywords are
198
- * weak (+1). Returns up to `limit` skills sorted by relevance.
199
- */
200
- recall(context: string, limit: number = 5): SkillDefinition[] {
201
- const ctxWords = new Set(context.toLowerCase().split(/\s+/))
202
- const scored: Array<{ skill: SkillDefinition; score: number }> = []
203
-
204
- for (const skill of this.list()) {
205
- if (skill.disableModelInvocation) continue
206
- let score = 0
207
- for (const word of skill.name.toLowerCase().split(/[-_\s]+/)) {
208
- if (word.length > 2 && ctxWords.has(word)) score += 3
209
- }
210
- for (const word of skill.description.toLowerCase().split(/\s+/)) {
211
- if (word.length > 3 && ctxWords.has(word)) score += 1
212
- }
213
- if (score > 0) scored.push({ skill, score })
214
- }
215
-
216
- scored.sort((a, b) => b.score - a.score)
217
- return scored.slice(0, limit).map((s) => s.skill)
218
- }
219
-
220
- /**
221
- * Build the system-reminder block for AI auto-triggering.
222
- * With a `context`, only the most relevant skills are injected (selective,
223
- * to save tokens); without one, all skills are listed.
203
+ * Build the system-reminder block listing every skill the AI may invoke via
204
+ * the Skill tool. A single full listing keeps the whole catalog discoverable:
205
+ * at session start there is no query to match against, so a keyword "recall"
206
+ * would silently hide most skills. Capped at `maxTokens` to stay bounded.
224
207
  */
225
- buildSystemReminder(context?: string, maxTokens: number = 5000): string {
226
- const selected = context
227
- ? this.recall(context)
228
- : this.list().filter((s) => !s.disableModelInvocation)
208
+ buildSystemReminder(maxTokens: number = 5000): string {
209
+ const selected = this.list().filter((s) => !s.disableModelInvocation)
229
210
 
230
211
  if (selected.length === 0) return ''
231
212
 
@@ -6,7 +6,7 @@ export interface Skills {
6
6
  get(name: string): SkillDefinition | undefined
7
7
  list(): SkillDefinition[]
8
8
  has(name: string): boolean
9
- buildSystemReminder(context?: string, maxTokens?: number): string
9
+ buildSystemReminder(maxTokens?: number): string
10
10
  }
11
11
 
12
12
  /** 缝键:ctx.skills。 */
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from
2
2
  import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import type { ToolDefinition } from '../../shared/index.ts'
5
+ import { MemoryManager } from '../../core/memory/memory-manager'
5
6
 
6
7
  const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
7
8
 
@@ -24,7 +25,7 @@ ${content}
24
25
 
25
26
  export const memoryTool: ToolDefinition = {
26
27
  name: 'Memory',
27
- description: 'Read and write persistent memory files in ~/.mipham/memory/.',
28
+ description: 'Read, write, list, and search persistent memory files in ~/.mipham/memory/.',
28
29
  category: 'agent',
29
30
  permission: 'auto',
30
31
  parameters: {
@@ -32,11 +33,12 @@ export const memoryTool: ToolDefinition = {
32
33
  properties: {
33
34
  action: {
34
35
  type: 'string',
35
- enum: ['read', 'write', 'list'],
36
+ enum: ['read', 'write', 'list', 'search'],
36
37
  description: 'Action to perform',
37
38
  },
38
39
  name: { type: 'string', description: 'Memory file name (slug)' },
39
40
  content: { type: 'string', description: 'Content to write (for write action)' },
41
+ query: { type: 'string', description: 'Search query (for search action)' },
40
42
  },
41
43
  required: ['action'],
42
44
  },
@@ -49,6 +51,22 @@ export const memoryTool: ToolDefinition = {
49
51
  return { success: true, content: files.join('\n') || '(no memories)' }
50
52
  }
51
53
 
54
+ // Semantic-less recall (keyword + wikilink + time-decay scoring) — lets the
55
+ // AI retrieve relevant memories mid-conversation, not only at session start.
56
+ if (action === 'search') {
57
+ const query = (params.query as string)?.trim()
58
+ if (!query) return { success: false, content: '', error: 'query is required' }
59
+ const mm = new MemoryManager(MEMORY_DIR)
60
+ mm.loadAll()
61
+ const results = mm.recall(query, 10)
62
+ if (results.length === 0) return { success: true, content: '(no matching memories)' }
63
+ const lines = results.map((entry) => {
64
+ const snippet = entry.content.replace(/\n+/g, ' ').trim().slice(0, 140)
65
+ return `- ${entry.name}: ${entry.description || snippet}\n ${snippet}`
66
+ })
67
+ return { success: true, content: lines.join('\n') }
68
+ }
69
+
52
70
  const name = params.name as string
53
71
  if (!name) return { success: false, content: '', error: 'name is required' }
54
72
  const filePath = join(MEMORY_DIR, `${name}.md`)
package/src/ui/app.tsx CHANGED
@@ -22,6 +22,18 @@ import { ChatPanel } from './chat'
22
22
  import { InputBar } from './input'
23
23
  import { ModelPicker } from './picker'
24
24
  import { AgentFooter, type AgentEntry } from './agent-footer'
25
+ import { GraftStatusLine } from './graft-status'
26
+
27
+ /** Current context-window usage % — undefined when unknown (remote stub). */
28
+ function contextUsagePct(engine: QueryEngine | RemoteEngine): number | undefined {
29
+ const ctx = engine.getContext() as {
30
+ getEstimatedTokens(): number
31
+ getMaxTokens?(): number
32
+ }
33
+ const max = ctx.getMaxTokens?.()
34
+ if (!max || max <= 0) return undefined
35
+ return Math.round((ctx.getEstimatedTokens() / max) * 100)
36
+ }
25
37
  import { AgentViewDashboard } from '../agent-view/dashboard'
26
38
  import type { AgentViewManager } from '../agent-view/agent-view-manager'
27
39
  import { WorkflowProgress } from './workflow-progress.js'
@@ -965,6 +977,9 @@ export function App({
965
977
  agentProgress={agentProgress}
966
978
  />
967
979
 
980
+ {/* graft status line — mirrors graft's own "◤ graft · …" bar */}
981
+ <GraftStatusLine cwd={process.cwd()} ctxPct={contextUsagePct(engine)} />
982
+
968
983
  {/* Status line — Claude Code style */}
969
984
  <Box marginTop={1} flexDirection="column">
970
985
  <Box flexDirection="row">
@@ -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[] } {
@@ -0,0 +1,59 @@
1
+ import React, { useState } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { basename } from 'node:path'
4
+ import { readGraftStats, type GraftStats } from '../shared/graft-stats'
5
+ import { getGraftSavings } from '../shared/graft-savings'
6
+
7
+ /** Freshness segment mirroring graft's own statusline (see @nanonets/graft format.js). */
8
+ function freshnessSegment(stats: GraftStats): { label: string; color: string } {
9
+ if (stats.syncing) return { label: 'syncing…', color: 'yellow' }
10
+ if (stats.dirty && stats.staleCount > 0)
11
+ return { label: `⚠ ${stats.staleCount} stale`, color: 'yellow' }
12
+ if (stats.dirty) return { label: '⚠ stale', color: 'yellow' }
13
+ return { label: '✓ synced', color: 'blue' }
14
+ }
15
+
16
+ /**
17
+ * Bottom-of-screen graft status line — mirrors graft's own "◤ graft · …" bar:
18
+ * ◤ graft · N nodes / E edges · ✓ synced · ~T tok saved
19
+ * ▸ ctx X% · last: file
20
+ * Reads `graft/.cache/stats.json` once at mount; the session's tok-saved total
21
+ * is read live each render (the app re-renders on the agent tick). Returns
22
+ * null (renders nothing) when graft isn't built here.
23
+ */
24
+ export function GraftStatusLine({ cwd, ctxPct }: { cwd: string; ctxPct?: number }) {
25
+ const [stats] = useState(() => readGraftStats(cwd))
26
+ if (!stats) return null
27
+ const fresh = freshnessSegment(stats)
28
+ const saved = getGraftSavings()
29
+ const lastFile = stats.lastFile ? basename(stats.lastFile) : null
30
+
31
+ const bottom: string[] = []
32
+ if (typeof ctxPct === 'number') bottom.push(`ctx ${ctxPct}%`)
33
+ if (lastFile) bottom.push(`last: ${lastFile}`)
34
+
35
+ return (
36
+ <Box flexDirection="column">
37
+ <Box>
38
+ <Text dimColor>◤ </Text>
39
+ <Text color="blue">graft</Text>
40
+ <Text dimColor>
41
+ {' '}
42
+ · {stats.nodeCount} nodes / {stats.edgeCount} edges ·{' '}
43
+ </Text>
44
+ <Text color={fresh.color}>{fresh.label}</Text>
45
+ {saved > 0 && (
46
+ <>
47
+ <Text dimColor> · </Text>
48
+ <Text color="blue">~{saved.toLocaleString()} tok saved</Text>
49
+ </>
50
+ )}
51
+ </Box>
52
+ {bottom.length > 0 && (
53
+ <Box>
54
+ <Text dimColor>▸ {bottom.join(' · ')}</Text>
55
+ </Box>
56
+ )}
57
+ </Box>
58
+ )
59
+ }
@@ -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
- }