@miphamai/cli 0.53.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 +1 -1
- package/src/agent/agent-registry.ts +5 -2
- package/src/agent/cross-session/discovery.ts +3 -1
- package/src/agent/message-router.ts +10 -0
- package/src/agent-view/agent-view-manager.ts +15 -6
- package/src/agent-view/dashboard.tsx +21 -2
- package/src/commands/cd-suggest.ts +35 -0
- package/src/commands/project.ts +4 -3
- package/src/config/loader.ts +2 -2
- package/src/core/claude-md-audit.ts +64 -0
- package/src/core/instructions.ts +15 -2
- package/src/daemon/backoff.ts +4 -0
- package/src/daemon/channel-message.ts +3 -3
- package/src/daemon/dingtalk/adapter.ts +62 -0
- package/src/daemon/dingtalk/api.ts +120 -0
- package/src/daemon/dingtalk/env.ts +16 -0
- package/src/daemon/dingtalk/types.ts +13 -0
- package/src/daemon/dingtalk/ws-client.ts +81 -0
- package/src/daemon/index.ts +17 -0
- package/src/daemon/server.ts +18 -0
- package/src/daemon/telegram/poller.ts +1 -5
- package/src/daemon/wecom/ws-client.ts +1 -5
- package/src/i18n-core/locales/en-US.json +5 -1
- package/src/i18n-core/locales/zh-CN.json +5 -1
- package/src/index.tsx +45 -32
- package/src/mcp/client.ts +2 -2
- package/src/mcp/http-transport.ts +11 -4
- package/src/mcp/oauth.ts +17 -9
- package/src/mcp/transport.ts +9 -3
- package/src/shared/atomic-write.ts +17 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +3 -0
- package/src/skills/loader.ts +5 -2
- package/src/ui/commands.ts +37 -1
- package/src/ui/config-wizard.tsx +3 -2
- package/src/core/output-styles.ts +0 -95
package/src/daemon/index.ts
CHANGED
|
@@ -29,6 +29,8 @@ import { parseTelegramEnv } from './telegram/env.js'
|
|
|
29
29
|
import type { TelegramConfig } from './telegram/types.js'
|
|
30
30
|
import { parseWecomEnv } from './wecom/env.js'
|
|
31
31
|
import type { WecomConfig } from './wecom/types.js'
|
|
32
|
+
import { parseDingtalkEnv } from './dingtalk/env.js'
|
|
33
|
+
import type { DingtalkConfig } from './dingtalk/types.js'
|
|
32
34
|
import { loadConfig } from '../config/loader'
|
|
33
35
|
|
|
34
36
|
const HOME = homedir()
|
|
@@ -210,6 +212,20 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
|
|
|
210
212
|
}
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
// 钉钉 remote-control adapter(Stream 长连接,env 未配置时跳过)
|
|
216
|
+
const dingtalkConfig = parseDingtalkEnv()
|
|
217
|
+
let dingtalk: { config: DingtalkConfig; cwd: string; provider: string; model: string } | undefined
|
|
218
|
+
if (dingtalkConfig) {
|
|
219
|
+
const cfg = loadConfig()
|
|
220
|
+
const provider = cfg.providers.find((p) => p.status !== 'upcoming') ?? cfg.providers[0]
|
|
221
|
+
dingtalk = {
|
|
222
|
+
config: dingtalkConfig,
|
|
223
|
+
cwd: process.env.DINGTALK_CWD || process.cwd(),
|
|
224
|
+
provider: provider?.id ?? 'anthropic',
|
|
225
|
+
model: provider?.models?.[0]?.id ?? 'claude-sonnet-5',
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
213
229
|
// Start HTTP server (Bun.serve starts listening immediately)
|
|
214
230
|
const server = createServer({
|
|
215
231
|
db,
|
|
@@ -227,6 +243,7 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
|
|
|
227
243
|
feishu,
|
|
228
244
|
telegram,
|
|
229
245
|
wecom,
|
|
246
|
+
dingtalk,
|
|
230
247
|
})
|
|
231
248
|
activeServer = server
|
|
232
249
|
|
package/src/daemon/server.ts
CHANGED
|
@@ -32,6 +32,9 @@ import type { TelegramConfig } from './telegram/types.js'
|
|
|
32
32
|
import { createWecomAdapter } from './wecom/adapter.js'
|
|
33
33
|
import { createWecomApi } from './wecom/api.js'
|
|
34
34
|
import type { WecomConfig } from './wecom/types.js'
|
|
35
|
+
import { createDingtalkAdapter } from './dingtalk/adapter.js'
|
|
36
|
+
import { createDingtalkApi } from './dingtalk/api.js'
|
|
37
|
+
import type { DingtalkConfig } from './dingtalk/types.js'
|
|
35
38
|
import { startHeartbeat } from './heartbeat'
|
|
36
39
|
|
|
37
40
|
interface ServerConfig {
|
|
@@ -50,6 +53,7 @@ interface ServerConfig {
|
|
|
50
53
|
feishu?: { config: FeishuConfig; cwd: string; provider: string; model: string }
|
|
51
54
|
telegram?: { config: TelegramConfig; cwd: string; provider: string; model: string }
|
|
52
55
|
wecom?: { config: WecomConfig; cwd: string; provider: string; model: string }
|
|
56
|
+
dingtalk?: { config: DingtalkConfig; cwd: string; provider: string; model: string }
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
interface WsData {
|
|
@@ -129,6 +133,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
129
133
|
feishu,
|
|
130
134
|
telegram,
|
|
131
135
|
wecom,
|
|
136
|
+
dingtalk,
|
|
132
137
|
} = config
|
|
133
138
|
|
|
134
139
|
const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
|
|
@@ -292,6 +297,19 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
292
297
|
: undefined
|
|
293
298
|
wecomAdapter?.start()
|
|
294
299
|
|
|
300
|
+
// ── 钉钉 remote-control adapter(Stream 长连接,无需 webhook 路由)──
|
|
301
|
+
const dingtalkAdapter = dingtalk
|
|
302
|
+
? createDingtalkAdapter(dingtalk.config, createDingtalkApi(dingtalk.config), {
|
|
303
|
+
sm,
|
|
304
|
+
getOrCreateWorker,
|
|
305
|
+
rateLimiter,
|
|
306
|
+
cwd: dingtalk.cwd,
|
|
307
|
+
provider: dingtalk.provider,
|
|
308
|
+
model: dingtalk.model,
|
|
309
|
+
})
|
|
310
|
+
: undefined
|
|
311
|
+
dingtalkAdapter?.start()
|
|
312
|
+
|
|
295
313
|
// ── 心跳式通知:定时扫 pending(goal/schedule),只通知、不自主行动 ──
|
|
296
314
|
const heartbeatSource = {
|
|
297
315
|
listGoals: () => sm.listSessions().flatMap((s) => goalManager.getGoals(s.id)),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TelegramApi } from './api.js'
|
|
2
2
|
import type { TelegramMessage } from './types.js'
|
|
3
|
+
import { nextBackoff } from '../backoff.js'
|
|
3
4
|
|
|
4
5
|
/** 从 update 提取文本消息;非文本/缺 message → null。chat.id 统一字符串化比对(id < 2^53;超范围 bigint 后续再引入)。 */
|
|
5
6
|
export function extractTextMessage(update: unknown): TelegramMessage | null {
|
|
@@ -16,11 +17,6 @@ export function nextOffset(updates: Array<{ update_id?: number }>, prevOffset: n
|
|
|
16
17
|
return Math.max(...updates.map((u) => u.update_id ?? prevOffset)) + 1
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
/** 指数退避,封顶 30s。 */
|
|
20
|
-
export function nextBackoff(currentMs: number): number {
|
|
21
|
-
return Math.min(currentMs * 2, 30_000)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
20
|
/** 长轮询循环(unref 不阻退出)。返回 stop 函数。循环行为由 Task 7 集成测试覆盖。 */
|
|
25
21
|
export function startTelegramPoller(
|
|
26
22
|
api: TelegramApi,
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import type { WecomApi } from './api.js'
|
|
2
2
|
import type { WecomMessage } from './types.js'
|
|
3
|
-
|
|
4
|
-
/** 指数退避,封顶 30s。与 telegram poller.nextBackoff 同源。 */
|
|
5
|
-
export function nextBackoff(currentMs: number): number {
|
|
6
|
-
return Math.min(currentMs * 2, 30_000)
|
|
7
|
-
}
|
|
3
|
+
import { nextBackoff } from '../backoff.js'
|
|
8
4
|
|
|
9
5
|
/** WebSocket 长连接生命周期:建连→subscribe→心跳→消息回调→断开重连。返回 stop。 */
|
|
10
6
|
export function startWecomWs(
|
|
@@ -563,6 +563,7 @@
|
|
|
563
563
|
"cd": {
|
|
564
564
|
"usage": "Usage: /cd <path>\n\nChange the session working directory.\nExample: /cd ~/projects/my-app\n\nCurrent: {cwd}",
|
|
565
565
|
"not_found": "❌ Directory not found: {path}",
|
|
566
|
+
"suggestions": "Did you mean:",
|
|
566
567
|
"failed": "❌ Failed to change directory: {error}",
|
|
567
568
|
"title": "── Directory Changed ──",
|
|
568
569
|
"content": "New CWD: {path}\n\nThe AI will now work relative to this directory.\nNote: This changes the filesystem root for tools like Read, Write, Bash."
|
|
@@ -594,7 +595,10 @@
|
|
|
594
595
|
"git_section": "── Git ──",
|
|
595
596
|
"no_git": " (not a git repository or git not available)",
|
|
596
597
|
"skills_section": "── Skills ──",
|
|
597
|
-
"skills_unavailable": " (skills info unavailable)"
|
|
598
|
+
"skills_unavailable": " (skills info unavailable)",
|
|
599
|
+
"audit_section": "── CLAUDE.md Audit ──",
|
|
600
|
+
"audit_clean": " ✓ no code-derivable sections found",
|
|
601
|
+
"audit_found": "Add the sections above to the `prompt-exclude` frontmatter to stop sending them to the model."
|
|
598
602
|
},
|
|
599
603
|
"plugins": {
|
|
600
604
|
"unavailable": "PluginManager not available in this session.",
|
|
@@ -563,6 +563,7 @@
|
|
|
563
563
|
"cd": {
|
|
564
564
|
"usage": "用法: /cd <path>\n\n更改会话工作目录。\n示例: /cd ~/projects/my-app\n\n当前: {cwd}",
|
|
565
565
|
"not_found": "❌ 目录未找到: {path}",
|
|
566
|
+
"suggestions": "你是想找:",
|
|
566
567
|
"failed": "❌ 更改目录失败: {error}",
|
|
567
568
|
"title": "── 目录已更改 ──",
|
|
568
569
|
"content": "新目录: {path}\n\nAI 将相对于此目录工作。\n注意: 这会更改 Read、Write、Bash 等工具的文件系统根路径。"
|
|
@@ -594,7 +595,10 @@
|
|
|
594
595
|
"git_section": "── Git ──",
|
|
595
596
|
"no_git": " (不是 git 仓库或 git 不可用)",
|
|
596
597
|
"skills_section": "── 技能 ──",
|
|
597
|
-
"skills_unavailable": " (技能信息不可用)"
|
|
598
|
+
"skills_unavailable": " (技能信息不可用)",
|
|
599
|
+
"audit_section": "── CLAUDE.md 审计 ──",
|
|
600
|
+
"audit_clean": " ✓ 未发现可从代码推断的冗余章节",
|
|
601
|
+
"audit_found": "将上述章节加入 `prompt-exclude` frontmatter,可停止发送给模型、节省 token。"
|
|
598
602
|
},
|
|
599
603
|
"plugins": {
|
|
600
604
|
"unavailable": "当前会话中 PluginManager 不可用。",
|
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
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
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
|
package/src/mcp/client.ts
CHANGED
|
@@ -161,8 +161,8 @@ export class McpClient {
|
|
|
161
161
|
if (existing?.status === 'connected') return
|
|
162
162
|
|
|
163
163
|
const transport: StdioTransport | HttpTransport = config.url
|
|
164
|
-
? new HttpTransport()
|
|
165
|
-
: new StdioTransport()
|
|
164
|
+
? new HttpTransport(undefined, config.request_timeout_ms)
|
|
165
|
+
: new StdioTransport(config.request_timeout_ms)
|
|
166
166
|
const protocol = new McpProtocol(transport)
|
|
167
167
|
|
|
168
168
|
const connection: ActiveConnection = {
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification, JsonRpcError } from './types'
|
|
2
2
|
import type { Transport, NotificationHandler } from './transport'
|
|
3
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS, requestTimeoutError } from './transport'
|
|
3
4
|
|
|
4
5
|
type FetchFn = (input: string, init?: RequestInit) => Promise<Response>
|
|
5
6
|
|
|
6
|
-
const REQUEST_TIMEOUT_MS = 60_000
|
|
7
|
-
|
|
8
7
|
/**
|
|
9
8
|
* Merge multiple SSE `data:` events into a single JSON-RPC result.
|
|
10
9
|
*
|
|
@@ -74,7 +73,10 @@ export class HttpTransport implements Transport {
|
|
|
74
73
|
private closed = false
|
|
75
74
|
private notificationHandlers: NotificationHandler[] = []
|
|
76
75
|
|
|
77
|
-
constructor(
|
|
76
|
+
constructor(
|
|
77
|
+
private fetchImpl: FetchFn = fetch as unknown as FetchFn,
|
|
78
|
+
private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
79
|
+
) {}
|
|
78
80
|
|
|
79
81
|
async start(
|
|
80
82
|
url: string,
|
|
@@ -107,7 +109,7 @@ export class HttpTransport implements Transport {
|
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
const controller = new AbortController()
|
|
110
|
-
const timer = setTimeout(() => controller.abort(),
|
|
112
|
+
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs)
|
|
111
113
|
|
|
112
114
|
try {
|
|
113
115
|
const response = await this.fetchImpl(this.url, {
|
|
@@ -142,6 +144,11 @@ export class HttpTransport implements Transport {
|
|
|
142
144
|
throw new Error(`MCP error ${json.error.code}: ${json.error.message}`)
|
|
143
145
|
}
|
|
144
146
|
return json.result
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (controller.signal.aborted) {
|
|
149
|
+
throw requestTimeoutError(method, this.requestTimeoutMs)
|
|
150
|
+
}
|
|
151
|
+
throw err
|
|
145
152
|
} finally {
|
|
146
153
|
clearTimeout(timer)
|
|
147
154
|
}
|
package/src/mcp/oauth.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createServer, Server } from 'node:http'
|
|
|
4
4
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
5
5
|
import type { McpServerConfig } from '../shared/types'
|
|
6
6
|
import { TokenStore } from './token-store'
|
|
7
|
+
import { fetchWithRetry } from '../providers/fetch-utils'
|
|
7
8
|
import { createT } from '../i18n-core/t'
|
|
8
9
|
import enUS from '../i18n-core/locales/en-US.json'
|
|
9
10
|
import zhCN from '../i18n-core/locales/zh-CN.json'
|
|
@@ -148,15 +149,22 @@ export class OAuthClient {
|
|
|
148
149
|
throw new Error(`No refresh token available for "${serverName}"`)
|
|
149
150
|
}
|
|
150
151
|
const auth = config.auth!
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
152
|
+
// A single failed refresh is often a transient network/server error, not a
|
|
153
|
+
// real revocation — retry (5xx/429/network) before forcing PKCE. 4xx (e.g.
|
|
154
|
+
// invalid_grant) is a genuine revocation and falls through to PKCE directly.
|
|
155
|
+
const response = await fetchWithRetry(
|
|
156
|
+
auth.tokenUrl,
|
|
157
|
+
{
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
160
|
+
body: new URLSearchParams({
|
|
161
|
+
grant_type: 'refresh_token',
|
|
162
|
+
refresh_token: saved.refreshToken,
|
|
163
|
+
client_id: auth.clientId,
|
|
164
|
+
}).toString(),
|
|
165
|
+
},
|
|
166
|
+
{ maxRetries: 1, baseDelay: 300 },
|
|
167
|
+
)
|
|
160
168
|
if (!response.ok) {
|
|
161
169
|
this.store.delete(serverName)
|
|
162
170
|
const fresh = await this.executePkceFlow(config)
|
package/src/mcp/transport.ts
CHANGED
|
@@ -19,7 +19,12 @@ export interface Transport {
|
|
|
19
19
|
isConnected(): boolean
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
|
|
23
|
+
|
|
24
|
+
/** Shared timeout error for both stdio and HTTP transports. */
|
|
25
|
+
export function requestTimeoutError(method: string, ms: number): Error {
|
|
26
|
+
return new Error(`MCP request timeout: ${method} (${ms}ms)`)
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
// ── Environment variable security: block sensitive vars from MCP subprocess ──
|
|
25
30
|
//
|
|
@@ -69,6 +74,7 @@ function buildProcEnv(extra?: Record<string, string>): Record<string, string> {
|
|
|
69
74
|
* Uses Node.js child_process for portability across Bun and Node runtimes.
|
|
70
75
|
*/
|
|
71
76
|
export class StdioTransport implements Transport {
|
|
77
|
+
constructor(private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {}
|
|
72
78
|
private proc: ChildProcess | null = null
|
|
73
79
|
private msgId = 0
|
|
74
80
|
private pending = new Map<
|
|
@@ -169,8 +175,8 @@ export class StdioTransport implements Transport {
|
|
|
169
175
|
return new Promise((resolve, reject) => {
|
|
170
176
|
const timer = setTimeout(() => {
|
|
171
177
|
this.pending.delete(id)
|
|
172
|
-
reject(
|
|
173
|
-
},
|
|
178
|
+
reject(requestTimeoutError(method, this.requestTimeoutMs))
|
|
179
|
+
}, this.requestTimeoutMs)
|
|
174
180
|
|
|
175
181
|
this.pending.set(id, { resolve, reject, timer })
|
|
176
182
|
|
|
@@ -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.
|
|
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
|
package/src/shared/types.ts
CHANGED
|
@@ -176,6 +176,8 @@ export interface McpServerConfig {
|
|
|
176
176
|
/** HTTP: extra request headers (e.g. Authorization). */
|
|
177
177
|
headers?: Record<string, string>
|
|
178
178
|
env?: Record<string, string>
|
|
179
|
+
/** Per-server tool-call request timeout (ms). Overrides the 60s default. */
|
|
180
|
+
request_timeout_ms?: number
|
|
179
181
|
auth?: {
|
|
180
182
|
type: 'oauth'
|
|
181
183
|
authorizationUrl: string
|
|
@@ -428,6 +430,7 @@ export interface SessionInfo {
|
|
|
428
430
|
cwd?: string
|
|
429
431
|
provider?: string
|
|
430
432
|
model?: string
|
|
433
|
+
crossSessionInbound?: CrossSessionInbound
|
|
431
434
|
}
|
|
432
435
|
|
|
433
436
|
// ── Background Agent Types ──
|
package/src/skills/loader.ts
CHANGED
|
@@ -13,9 +13,12 @@ interface FrontmatterResult {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function parseFrontmatter(raw: string): FrontmatterResult {
|
|
16
|
-
|
|
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:
|
|
21
|
+
return { data: {}, content: src }
|
|
19
22
|
}
|
|
20
23
|
return {
|
|
21
24
|
data: parseYaml(match[1] || '') as Record<string, unknown>,
|
package/src/ui/commands.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type { PluginManager } from '../plugin/plugin-manager'
|
|
|
11
11
|
import type { Message } from '../shared/types.js'
|
|
12
12
|
import { McpClient } from '../mcp/client'
|
|
13
13
|
import { buildCapabilityReport } from '../core/capability-inventory'
|
|
14
|
+
import { InstructionsLoader } from '../core/instructions'
|
|
15
|
+
import { findDerivableSections, DERIVABLE_HINTS } from '../core/claude-md-audit'
|
|
14
16
|
import { runCrsiModification, approvePending, rejectPending, hasPending } from '../core/crsi-modify'
|
|
15
17
|
import {
|
|
16
18
|
produceCrsiProposal,
|
|
@@ -57,6 +59,7 @@ import {
|
|
|
57
59
|
} from '../commands/project.js'
|
|
58
60
|
import { themeCmd, releaseNotesCmd, ideCmd, terminalSetupCmd } from '../commands/environment.js'
|
|
59
61
|
import { commitCmd, pushCmd, prCmd, issueCmd } from '../commands/git.js'
|
|
62
|
+
import { suggestDirectories } from '../commands/cd-suggest.js'
|
|
60
63
|
import { keysCmd } from '../commands/keys'
|
|
61
64
|
import { workflowViewCmd, workflowWatchCmd } from '../commands/workflow-view.js'
|
|
62
65
|
import { execSync } from 'node:child_process'
|
|
@@ -2905,6 +2908,34 @@ const doctorCmd: CommandHandler = async (ctx) => {
|
|
|
2905
2908
|
}
|
|
2906
2909
|
}
|
|
2907
2910
|
|
|
2911
|
+
// CLAUDE.md audit — flag sections the model can infer from the codebase
|
|
2912
|
+
try {
|
|
2913
|
+
const loader = new InstructionsLoader()
|
|
2914
|
+
loader.loadAll(process.cwd())
|
|
2915
|
+
const claudeFiles = loader.list().filter((f) => f.path.endsWith('CLAUDE.md'))
|
|
2916
|
+
|
|
2917
|
+
lines.push('')
|
|
2918
|
+
lines.push(t('commands.doctor.audit_section'))
|
|
2919
|
+
let found = false
|
|
2920
|
+
for (const file of claudeFiles) {
|
|
2921
|
+
const sections = findDerivableSections(file.content)
|
|
2922
|
+
if (sections.length === 0) continue
|
|
2923
|
+
found = true
|
|
2924
|
+
lines.push(` ${file.path}`)
|
|
2925
|
+
for (const s of sections) {
|
|
2926
|
+
lines.push(` - "${s.heading}" — ${DERIVABLE_HINTS[s.reason]}`)
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
if (!found) {
|
|
2930
|
+
lines.push(t('commands.doctor.audit_clean'))
|
|
2931
|
+
} else {
|
|
2932
|
+
lines.push('')
|
|
2933
|
+
lines.push(t('commands.doctor.audit_found'))
|
|
2934
|
+
}
|
|
2935
|
+
} catch {
|
|
2936
|
+
// best-effort — skip the audit on any failure
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2908
2939
|
return { content: lines.join('\n') }
|
|
2909
2940
|
}
|
|
2910
2941
|
|
|
@@ -3183,7 +3214,12 @@ const cdCmd: CommandHandler = async (ctx, args) => {
|
|
|
3183
3214
|
const resolved = resolve(target.replace(/^~/, process.env.HOME || '~'))
|
|
3184
3215
|
|
|
3185
3216
|
if (!existsSync(resolved)) {
|
|
3186
|
-
|
|
3217
|
+
const suggestions = suggestDirectories(resolved)
|
|
3218
|
+
let content = t('commands.cd.not_found', { path: resolved })
|
|
3219
|
+
if (suggestions.length > 0) {
|
|
3220
|
+
content += `\n\n${t('commands.cd.suggestions')}\n${suggestions.map((s) => ` ${s}`).join('\n')}`
|
|
3221
|
+
}
|
|
3222
|
+
return { content }
|
|
3187
3223
|
}
|
|
3188
3224
|
|
|
3189
3225
|
try {
|
package/src/ui/config-wizard.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
}
|