@miphamai/cli 0.52.0 → 0.54.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/skills/mipham/save-to-wiki.mipham-skill.md +92 -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/core/claude-md-audit.ts +64 -0
- package/src/daemon/backoff.ts +4 -0
- package/src/daemon/channel-message.ts +59 -0
- 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/feishu/adapter.ts +16 -27
- package/src/daemon/index.ts +34 -0
- package/src/daemon/server.ts +36 -0
- package/src/daemon/telegram/adapter.ts +16 -27
- package/src/daemon/telegram/poller.ts +1 -5
- package/src/daemon/wecom/adapter.ts +61 -0
- package/src/daemon/wecom/api.ts +58 -0
- package/src/daemon/wecom/env.ts +16 -0
- package/src/daemon/wecom/types.ts +12 -0
- package/src/daemon/wecom/ws-client.ts +73 -0
- package/src/i18n-core/locales/en-US.json +8 -1
- package/src/i18n-core/locales/zh-CN.json +8 -1
- 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/package-info.ts +1 -1
- package/src/shared/types.ts +2 -0
- package/src/skills/bundled-skills.ts +1 -0
- package/src/ui/commands.ts +56 -1
- package/src/ui/input.tsx +4 -2
- package/src/ui/vim-motions.ts +17 -0
|
@@ -4,6 +4,7 @@ import type { FeishuConfig, FeishuTextMessage } from './types.js'
|
|
|
4
4
|
import type { SessionManager } from '../session-manager'
|
|
5
5
|
import type { SessionWorker } from '../session-worker'
|
|
6
6
|
import type { RateLimiter } from '../rate-limiter'
|
|
7
|
+
import { handleChannelMessage } from '../channel-message.js'
|
|
7
8
|
|
|
8
9
|
export interface FeishuAdapterDeps {
|
|
9
10
|
sm: SessionManager
|
|
@@ -24,33 +25,21 @@ export function createFeishuAdapter(config: FeishuConfig, deps: FeishuAdapterDep
|
|
|
24
25
|
const allowed = new Set(config.allowedOpenIds)
|
|
25
26
|
|
|
26
27
|
const onMessage = async (msg: FeishuTextMessage) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
await worker.processPrompt(msg.text)
|
|
44
|
-
const result = worker.getLastAssistantContent()
|
|
45
|
-
await api.sendText(msg.openId, result ? result.slice(0, 4000) : '(无回复)')
|
|
46
|
-
} catch (err) {
|
|
47
|
-
console.error('[feishu] message handling failed:', err)
|
|
48
|
-
try {
|
|
49
|
-
await api.sendText(msg.openId, '(处理失败,请稍后重试)')
|
|
50
|
-
} catch {
|
|
51
|
-
/* 忽略回送失败,确保不 rethrow → Feishu 不重试 */
|
|
52
|
-
}
|
|
53
|
-
}
|
|
28
|
+
await handleChannelMessage({
|
|
29
|
+
channel: 'feishu',
|
|
30
|
+
externalId: msg.openId,
|
|
31
|
+
text: msg.text,
|
|
32
|
+
allowed,
|
|
33
|
+
rateLimiter: deps.rateLimiter,
|
|
34
|
+
sm: deps.sm,
|
|
35
|
+
getOrCreateWorker: deps.getOrCreateWorker,
|
|
36
|
+
cwd: deps.cwd,
|
|
37
|
+
provider: deps.provider,
|
|
38
|
+
model: deps.model,
|
|
39
|
+
sendText: (id, t) => api.sendText(id, t),
|
|
40
|
+
maxLen: 4000,
|
|
41
|
+
logPrefix: '[feishu]',
|
|
42
|
+
})
|
|
54
43
|
}
|
|
55
44
|
|
|
56
45
|
const dispatcher = createFeishuEventDispatcher(config, onMessage)
|
package/src/daemon/index.ts
CHANGED
|
@@ -27,6 +27,10 @@ import { parseFeishuEnv } from './feishu/env.js'
|
|
|
27
27
|
import type { FeishuConfig } from './feishu/types.js'
|
|
28
28
|
import { parseTelegramEnv } from './telegram/env.js'
|
|
29
29
|
import type { TelegramConfig } from './telegram/types.js'
|
|
30
|
+
import { parseWecomEnv } from './wecom/env.js'
|
|
31
|
+
import type { WecomConfig } from './wecom/types.js'
|
|
32
|
+
import { parseDingtalkEnv } from './dingtalk/env.js'
|
|
33
|
+
import type { DingtalkConfig } from './dingtalk/types.js'
|
|
30
34
|
import { loadConfig } from '../config/loader'
|
|
31
35
|
|
|
32
36
|
const HOME = homedir()
|
|
@@ -194,6 +198,34 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
|
|
|
194
198
|
}
|
|
195
199
|
}
|
|
196
200
|
|
|
201
|
+
// 企业微信 remote-control adapter(env 未配置时跳过)
|
|
202
|
+
const wecomConfig = parseWecomEnv()
|
|
203
|
+
let wecom: { config: WecomConfig; cwd: string; provider: string; model: string } | undefined
|
|
204
|
+
if (wecomConfig) {
|
|
205
|
+
const cfg = loadConfig()
|
|
206
|
+
const provider = cfg.providers.find((p) => p.status !== 'upcoming') ?? cfg.providers[0]
|
|
207
|
+
wecom = {
|
|
208
|
+
config: wecomConfig,
|
|
209
|
+
cwd: process.env.WECOM_CWD || process.cwd(),
|
|
210
|
+
provider: provider?.id ?? 'anthropic',
|
|
211
|
+
model: provider?.models?.[0]?.id ?? 'claude-sonnet-5',
|
|
212
|
+
}
|
|
213
|
+
}
|
|
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
|
+
|
|
197
229
|
// Start HTTP server (Bun.serve starts listening immediately)
|
|
198
230
|
const server = createServer({
|
|
199
231
|
db,
|
|
@@ -210,6 +242,8 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
|
|
|
210
242
|
rateLimiter,
|
|
211
243
|
feishu,
|
|
212
244
|
telegram,
|
|
245
|
+
wecom,
|
|
246
|
+
dingtalk,
|
|
213
247
|
})
|
|
214
248
|
activeServer = server
|
|
215
249
|
|
package/src/daemon/server.ts
CHANGED
|
@@ -29,6 +29,12 @@ import type { FeishuConfig } from './feishu/types.js'
|
|
|
29
29
|
import { createTelegramAdapter } from './telegram/adapter.js'
|
|
30
30
|
import { createTelegramApi } from './telegram/api.js'
|
|
31
31
|
import type { TelegramConfig } from './telegram/types.js'
|
|
32
|
+
import { createWecomAdapter } from './wecom/adapter.js'
|
|
33
|
+
import { createWecomApi } from './wecom/api.js'
|
|
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'
|
|
32
38
|
import { startHeartbeat } from './heartbeat'
|
|
33
39
|
|
|
34
40
|
interface ServerConfig {
|
|
@@ -46,6 +52,8 @@ interface ServerConfig {
|
|
|
46
52
|
rateLimiter: RateLimiter
|
|
47
53
|
feishu?: { config: FeishuConfig; cwd: string; provider: string; model: string }
|
|
48
54
|
telegram?: { config: TelegramConfig; cwd: string; provider: string; model: string }
|
|
55
|
+
wecom?: { config: WecomConfig; cwd: string; provider: string; model: string }
|
|
56
|
+
dingtalk?: { config: DingtalkConfig; cwd: string; provider: string; model: string }
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
interface WsData {
|
|
@@ -124,6 +132,8 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
124
132
|
rateLimiter,
|
|
125
133
|
feishu,
|
|
126
134
|
telegram,
|
|
135
|
+
wecom,
|
|
136
|
+
dingtalk,
|
|
127
137
|
} = config
|
|
128
138
|
|
|
129
139
|
const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
|
|
@@ -274,6 +284,32 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
274
284
|
: undefined
|
|
275
285
|
telegramAdapter?.start()
|
|
276
286
|
|
|
287
|
+
// ── 企业微信 remote-control adapter(长连接,无需 webhook 路由)──
|
|
288
|
+
const wecomAdapter = wecom
|
|
289
|
+
? createWecomAdapter(wecom.config, createWecomApi(wecom.config), {
|
|
290
|
+
sm,
|
|
291
|
+
getOrCreateWorker,
|
|
292
|
+
rateLimiter,
|
|
293
|
+
cwd: wecom.cwd,
|
|
294
|
+
provider: wecom.provider,
|
|
295
|
+
model: wecom.model,
|
|
296
|
+
})
|
|
297
|
+
: undefined
|
|
298
|
+
wecomAdapter?.start()
|
|
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
|
+
|
|
277
313
|
// ── 心跳式通知:定时扫 pending(goal/schedule),只通知、不自主行动 ──
|
|
278
314
|
const heartbeatSource = {
|
|
279
315
|
listGoals: () => sm.listSessions().flatMap((s) => goalManager.getGoals(s.id)),
|
|
@@ -3,6 +3,7 @@ import type { TelegramConfig, TelegramMessage } from './types.js'
|
|
|
3
3
|
import type { SessionManager } from '../session-manager'
|
|
4
4
|
import type { SessionWorker } from '../session-worker'
|
|
5
5
|
import type { RateLimiter } from '../rate-limiter'
|
|
6
|
+
import { handleChannelMessage } from '../channel-message.js'
|
|
6
7
|
import { startTelegramPoller } from './poller.js'
|
|
7
8
|
|
|
8
9
|
export interface TelegramAdapterDeps {
|
|
@@ -29,33 +30,21 @@ export function createTelegramAdapter(
|
|
|
29
30
|
let stopPoller: (() => void) | null = null
|
|
30
31
|
|
|
31
32
|
async function handleMessage(msg: TelegramMessage): Promise<void> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
await worker.processPrompt(msg.text)
|
|
49
|
-
const result = worker.getLastAssistantContent()
|
|
50
|
-
await api.sendText(msg.chatId, result ? result.slice(0, 4096) : '(无回复)')
|
|
51
|
-
} catch (err) {
|
|
52
|
-
console.error('[telegram] message handling failed:', err)
|
|
53
|
-
try {
|
|
54
|
-
await api.sendText(msg.chatId, '(处理失败,请稍后重试)')
|
|
55
|
-
} catch {
|
|
56
|
-
/* 忽略回送失败,不 rethrow */
|
|
57
|
-
}
|
|
58
|
-
}
|
|
33
|
+
await handleChannelMessage({
|
|
34
|
+
channel: 'telegram',
|
|
35
|
+
externalId: msg.chatId,
|
|
36
|
+
text: msg.text,
|
|
37
|
+
allowed,
|
|
38
|
+
rateLimiter: deps.rateLimiter,
|
|
39
|
+
sm: deps.sm,
|
|
40
|
+
getOrCreateWorker: deps.getOrCreateWorker,
|
|
41
|
+
cwd: deps.cwd,
|
|
42
|
+
provider: deps.provider,
|
|
43
|
+
model: deps.model,
|
|
44
|
+
sendText: (id, t) => api.sendText(id, t),
|
|
45
|
+
maxLen: 4096,
|
|
46
|
+
logPrefix: '[telegram]',
|
|
47
|
+
})
|
|
59
48
|
}
|
|
60
49
|
|
|
61
50
|
return {
|
|
@@ -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,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { WecomApi } from './api.js'
|
|
2
|
+
import type { WecomConfig, WecomMessage } from './types.js'
|
|
3
|
+
import type { SessionManager } from '../session-manager'
|
|
4
|
+
import type { SessionWorker } from '../session-worker'
|
|
5
|
+
import type { RateLimiter } from '../rate-limiter'
|
|
6
|
+
import { startWecomWs } from './ws-client.js'
|
|
7
|
+
import { handleChannelMessage } from '../channel-message.js'
|
|
8
|
+
|
|
9
|
+
export interface WecomAdapterDeps {
|
|
10
|
+
sm: SessionManager
|
|
11
|
+
getOrCreateWorker: (sessionId: string) => SessionWorker | null
|
|
12
|
+
rateLimiter: RateLimiter
|
|
13
|
+
cwd: string
|
|
14
|
+
provider: string
|
|
15
|
+
model: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WecomAdapter {
|
|
19
|
+
start(): () => void
|
|
20
|
+
handleMessage(msg: WecomMessage): Promise<void>
|
|
21
|
+
isAllowed(userId: string): boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createWecomAdapter(
|
|
25
|
+
config: WecomConfig,
|
|
26
|
+
api: WecomApi,
|
|
27
|
+
deps: WecomAdapterDeps,
|
|
28
|
+
): WecomAdapter {
|
|
29
|
+
const allowed = new Set(config.allowedUserIds)
|
|
30
|
+
let stopWs: (() => void) | null = null
|
|
31
|
+
|
|
32
|
+
async function handleMessage(msg: WecomMessage): Promise<void> {
|
|
33
|
+
await handleChannelMessage({
|
|
34
|
+
channel: 'wecom',
|
|
35
|
+
externalId: msg.userId,
|
|
36
|
+
text: msg.text,
|
|
37
|
+
allowed,
|
|
38
|
+
rateLimiter: deps.rateLimiter,
|
|
39
|
+
sm: deps.sm,
|
|
40
|
+
getOrCreateWorker: deps.getOrCreateWorker,
|
|
41
|
+
cwd: deps.cwd,
|
|
42
|
+
provider: deps.provider,
|
|
43
|
+
model: deps.model,
|
|
44
|
+
sendText: async (userId, text) => {
|
|
45
|
+
api.respond(userId, text)
|
|
46
|
+
},
|
|
47
|
+
maxLen: 2048,
|
|
48
|
+
logPrefix: '[wecom]',
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
handleMessage,
|
|
54
|
+
isAllowed: (userId) => allowed.has(userId),
|
|
55
|
+
start() {
|
|
56
|
+
if (stopWs) return stopWs
|
|
57
|
+
stopWs = startWecomWs(api, handleMessage)
|
|
58
|
+
return stopWs
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { WecomConfig, WecomMessage } from './types.js'
|
|
2
|
+
|
|
3
|
+
export interface WecomApi {
|
|
4
|
+
open(): WebSocket
|
|
5
|
+
subscribe(ws: WebSocket): void
|
|
6
|
+
ping(ws: WebSocket): void
|
|
7
|
+
attach(ws: WebSocket | null): void
|
|
8
|
+
respond(userId: string, text: string): void
|
|
9
|
+
parseMessage(frame: unknown): WecomMessage | null
|
|
10
|
+
isDisconnected(frame: unknown): boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const WS_ENDPOINT = 'wss://openws.work.weixin.qq.com'
|
|
14
|
+
|
|
15
|
+
/** 协议帧 codec;内部持有 activeWs(由 ws-client attach)。零依赖(globalThis.WebSocket)。 */
|
|
16
|
+
export function createWecomApi(config: WecomConfig): WecomApi {
|
|
17
|
+
let activeWs: WebSocket | null = null
|
|
18
|
+
return {
|
|
19
|
+
open() {
|
|
20
|
+
return new WebSocket(WS_ENDPOINT)
|
|
21
|
+
},
|
|
22
|
+
subscribe(ws) {
|
|
23
|
+
ws.send(
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
cmd: 'aibot_subscribe',
|
|
26
|
+
body: { bot_id: config.botId, bot_secret: config.botSecret },
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
},
|
|
30
|
+
ping(ws) {
|
|
31
|
+
ws.send(JSON.stringify({ cmd: 'ping' }))
|
|
32
|
+
},
|
|
33
|
+
attach(ws) {
|
|
34
|
+
activeWs = ws
|
|
35
|
+
},
|
|
36
|
+
respond(userId, text) {
|
|
37
|
+
if (activeWs) {
|
|
38
|
+
activeWs.send(
|
|
39
|
+
JSON.stringify({ cmd: 'aibot_respond_msg', body: { userid: userId, content: text } }),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
parseMessage(frame) {
|
|
44
|
+
if (!frame || typeof frame !== 'object') return null
|
|
45
|
+
const f = frame as {
|
|
46
|
+
cmd?: string
|
|
47
|
+
body?: { userid?: string; chatid?: string; msg_id?: string; content?: string }
|
|
48
|
+
}
|
|
49
|
+
if (f.cmd !== 'aibot_msg_callback' || !f.body) return null
|
|
50
|
+
const { userid, chatid, msg_id, content } = f.body
|
|
51
|
+
if (!userid || !content) return null
|
|
52
|
+
return { userId: userid, chatId: chatid ?? '', msgId: msg_id ?? '', text: content }
|
|
53
|
+
},
|
|
54
|
+
isDisconnected(frame) {
|
|
55
|
+
return (frame as { cmd?: string })?.cmd === 'disconnected_event'
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { WecomConfig } from './types.js'
|
|
2
|
+
|
|
3
|
+
/** fail-closed:缺 botId 或 botSecret → null(daemon 不启用企微)。 */
|
|
4
|
+
export function parseWecomEnv(): WecomConfig | null {
|
|
5
|
+
const botId = process.env.WECOM_BOT_ID
|
|
6
|
+
const botSecret = process.env.WECOM_BOT_SECRET
|
|
7
|
+
if (!botId || !botSecret) return null
|
|
8
|
+
return {
|
|
9
|
+
botId,
|
|
10
|
+
botSecret,
|
|
11
|
+
allowedUserIds: (process.env.WECOM_ALLOWED_USER_IDS || '')
|
|
12
|
+
.split(',')
|
|
13
|
+
.map((s) => s.trim())
|
|
14
|
+
.filter(Boolean),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface WecomConfig {
|
|
2
|
+
botId: string
|
|
3
|
+
botSecret: string
|
|
4
|
+
allowedUserIds: string[] // 白名单(企微内部 userid)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface WecomMessage {
|
|
8
|
+
userId: string // 发消息用户(userid)
|
|
9
|
+
chatId: string // 会话 id(chatid)
|
|
10
|
+
msgId: string // 消息 id(req_id 关联回包)
|
|
11
|
+
text: string // 文本内容
|
|
12
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { WecomApi } from './api.js'
|
|
2
|
+
import type { WecomMessage } from './types.js'
|
|
3
|
+
import { nextBackoff } from '../backoff.js'
|
|
4
|
+
|
|
5
|
+
/** WebSocket 长连接生命周期:建连→subscribe→心跳→消息回调→断开重连。返回 stop。 */
|
|
6
|
+
export function startWecomWs(
|
|
7
|
+
api: WecomApi,
|
|
8
|
+
onMessage: (msg: WecomMessage) => Promise<void>,
|
|
9
|
+
opts?: { heartbeatMs?: number },
|
|
10
|
+
): () => void {
|
|
11
|
+
const heartbeatMs = opts?.heartbeatMs ?? 30_000
|
|
12
|
+
let stopped = false
|
|
13
|
+
let disconnected = false // disconnected_event 触发时置 true,主动 close 后不重连
|
|
14
|
+
let ws: WebSocket
|
|
15
|
+
let backoffMs = 1000
|
|
16
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
17
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
18
|
+
|
|
19
|
+
function clearTimers() {
|
|
20
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
|
21
|
+
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
22
|
+
heartbeatTimer = null
|
|
23
|
+
reconnectTimer = null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function connect() {
|
|
27
|
+
if (stopped) return
|
|
28
|
+
ws = api.open()
|
|
29
|
+
ws.onopen = () => {
|
|
30
|
+
backoffMs = 1000
|
|
31
|
+
api.attach(ws)
|
|
32
|
+
api.subscribe(ws)
|
|
33
|
+
heartbeatTimer = setInterval(() => api.ping(ws), heartbeatMs)
|
|
34
|
+
;(heartbeatTimer as unknown as { unref?: () => void }).unref?.()
|
|
35
|
+
}
|
|
36
|
+
ws.onmessage = (ev) => {
|
|
37
|
+
let frame: unknown
|
|
38
|
+
try {
|
|
39
|
+
frame = JSON.parse(ev.data as string)
|
|
40
|
+
} catch {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
if (api.isDisconnected(frame)) {
|
|
44
|
+
disconnected = true
|
|
45
|
+
ws.close()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const msg = api.parseMessage(frame)
|
|
49
|
+
if (msg) void onMessage(msg).catch(() => {})
|
|
50
|
+
}
|
|
51
|
+
ws.onclose = () => {
|
|
52
|
+
api.attach(null)
|
|
53
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
|
54
|
+
heartbeatTimer = null
|
|
55
|
+
if (stopped || disconnected) return
|
|
56
|
+
reconnectTimer = setTimeout(connect, backoffMs)
|
|
57
|
+
backoffMs = nextBackoff(backoffMs)
|
|
58
|
+
;(reconnectTimer as unknown as { unref?: () => void }).unref?.()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
connect()
|
|
63
|
+
return () => {
|
|
64
|
+
stopped = true
|
|
65
|
+
disconnected = true
|
|
66
|
+
clearTimers()
|
|
67
|
+
try {
|
|
68
|
+
ws.close()
|
|
69
|
+
} catch {
|
|
70
|
+
/* 连接尚未建立时 close 可能抛错,忽略 */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -215,6 +215,9 @@
|
|
|
215
215
|
"entered": "Entered plan mode.",
|
|
216
216
|
"content": "── Plan Mode ──\n\nEntering plan mode — read-only analysis and design.\nUse EnterPlanMode to start, ExitPlanMode to submit for approval."
|
|
217
217
|
},
|
|
218
|
+
"save": {
|
|
219
|
+
"content": "── Save to Wiki ──\n\nAnalyzing the conversation and filing the most valuable insight into your Obsidian wiki…"
|
|
220
|
+
},
|
|
218
221
|
"diff": {
|
|
219
222
|
"clean": "No uncommitted changes (working tree clean).",
|
|
220
223
|
"title": "── Git Diff ──",
|
|
@@ -560,6 +563,7 @@
|
|
|
560
563
|
"cd": {
|
|
561
564
|
"usage": "Usage: /cd <path>\n\nChange the session working directory.\nExample: /cd ~/projects/my-app\n\nCurrent: {cwd}",
|
|
562
565
|
"not_found": "❌ Directory not found: {path}",
|
|
566
|
+
"suggestions": "Did you mean:",
|
|
563
567
|
"failed": "❌ Failed to change directory: {error}",
|
|
564
568
|
"title": "── Directory Changed ──",
|
|
565
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."
|
|
@@ -591,7 +595,10 @@
|
|
|
591
595
|
"git_section": "── Git ──",
|
|
592
596
|
"no_git": " (not a git repository or git not available)",
|
|
593
597
|
"skills_section": "── Skills ──",
|
|
594
|
-
"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."
|
|
595
602
|
},
|
|
596
603
|
"plugins": {
|
|
597
604
|
"unavailable": "PluginManager not available in this session.",
|
|
@@ -215,6 +215,9 @@
|
|
|
215
215
|
"entered": "已进入计划模式。",
|
|
216
216
|
"content": "── 计划模式 ──\n\n进入计划模式 — 只读分析与设计。\n使用 EnterPlanMode 开始,ExitPlanMode 提交审批。"
|
|
217
217
|
},
|
|
218
|
+
"save": {
|
|
219
|
+
"content": "── 保存到 Wiki ──\n\n正在分析对话,并把最有价值的洞察归档到你的 Obsidian wiki…"
|
|
220
|
+
},
|
|
218
221
|
"diff": {
|
|
219
222
|
"clean": "没有未提交的更改(工作区干净)。",
|
|
220
223
|
"title": "── Git Diff ──",
|
|
@@ -560,6 +563,7 @@
|
|
|
560
563
|
"cd": {
|
|
561
564
|
"usage": "用法: /cd <path>\n\n更改会话工作目录。\n示例: /cd ~/projects/my-app\n\n当前: {cwd}",
|
|
562
565
|
"not_found": "❌ 目录未找到: {path}",
|
|
566
|
+
"suggestions": "你是想找:",
|
|
563
567
|
"failed": "❌ 更改目录失败: {error}",
|
|
564
568
|
"title": "── 目录已更改 ──",
|
|
565
569
|
"content": "新目录: {path}\n\nAI 将相对于此目录工作。\n注意: 这会更改 Read、Write、Bash 等工具的文件系统根路径。"
|
|
@@ -591,7 +595,10 @@
|
|
|
591
595
|
"git_section": "── Git ──",
|
|
592
596
|
"no_git": " (不是 git 仓库或 git 不可用)",
|
|
593
597
|
"skills_section": "── 技能 ──",
|
|
594
|
-
"skills_unavailable": " (技能信息不可用)"
|
|
598
|
+
"skills_unavailable": " (技能信息不可用)",
|
|
599
|
+
"audit_section": "── CLAUDE.md 审计 ──",
|
|
600
|
+
"audit_clean": " ✓ 未发现可从代码推断的冗余章节",
|
|
601
|
+
"audit_found": "将上述章节加入 `prompt-exclude` frontmatter,可停止发送给模型、节省 token。"
|
|
595
602
|
},
|
|
596
603
|
"plugins": {
|
|
597
604
|
"unavailable": "当前会话中 PluginManager 不可用。",
|
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
|
|