@miphamai/cli 0.53.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -250,17 +250,26 @@ export class AgentViewManager {
250
250
  return true
251
251
  }
252
252
 
253
+ /**
254
+ * Permanently remove a single session (any status). Returns true if found.
255
+ */
256
+ remove(id: string): boolean {
257
+ if (!this.sessions.has(id)) return false
258
+ this.sessions.delete(id)
259
+ this.sessionOrder = this.sessionOrder.filter((oid) => oid !== id)
260
+ return true
261
+ }
262
+
253
263
  /**
254
264
  * Remove all completed and failed sessions (cleanup).
255
265
  */
256
266
  prune(): number {
267
+ const terminalIds = Array.from(this.sessions.entries())
268
+ .filter(([, session]) => session.status === 'completed' || session.status === 'failed')
269
+ .map(([id]) => id)
257
270
  let removed = 0
258
- for (const [id, session] of this.sessions) {
259
- if (session.status === 'completed' || session.status === 'failed') {
260
- this.sessions.delete(id)
261
- this.sessionOrder = this.sessionOrder.filter((oid) => oid !== id)
262
- removed++
263
- }
271
+ for (const id of terminalIds) {
272
+ if (this.remove(id)) removed++
264
273
  }
265
274
  return removed
266
275
  }
@@ -32,6 +32,8 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
32
32
  const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
33
33
  const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
34
34
  const [feedback, setFeedback] = useState<string | null>(null)
35
+ // Bump to force flatList recompute after a session is removed (list membership change).
36
+ const [version, setVersion] = useState(0)
35
37
 
36
38
  // Flash a brief feedback message that auto-clears
37
39
  const showFeedback = useCallback((msg: string) => {
@@ -87,7 +89,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
87
89
  }
88
90
 
89
91
  return result
90
- }, [manager, groupBy])
92
+ }, [manager, groupBy, version])
91
93
 
92
94
  // Flatten sessions only for navigation (skip headers)
93
95
  const sessionsOnly = useMemo(
@@ -140,6 +142,22 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
140
142
  return
141
143
  }
142
144
 
145
+ // Ctrl+X — permanently remove the selected session
146
+ if (key.ctrl && input === 'x') {
147
+ if (sessionsOnly.length === 0) {
148
+ showFeedback('No sessions to remove')
149
+ return
150
+ }
151
+ const current = sessionsOnly[selectedIndex]
152
+ if (!current) return
153
+ manager.remove(current.session.id)
154
+ setPeekingSessionId(null)
155
+ setSelectedIndex((prev) => Math.max(0, Math.min(prev, sessionsOnly.length - 2)))
156
+ setVersion((v) => v + 1)
157
+ showFeedback(`Removed ${current.session.title || current.session.id}`)
158
+ return
159
+ }
160
+
143
161
  if (input === 'j') {
144
162
  if (sessionsOnly.length === 0) {
145
163
  showFeedback('No sessions to navigate — spawn a background agent first')
@@ -223,7 +241,8 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
223
241
  </Box>
224
242
  <Box>
225
243
  <Text dimColor>
226
- j/k navigate · Space peek · Enter attach · Ctrl+T group · Ctrl+R rename · Esc back
244
+ j/k navigate · Space peek · Enter attach · Ctrl+T group · Ctrl+R rename · Ctrl+X remove
245
+ · Esc back
227
246
  </Text>
228
247
  </Box>
229
248
  </Box>
@@ -0,0 +1,35 @@
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { dirname, basename, join } from 'node:path'
3
+
4
+ /**
5
+ * Suggest existing directories that match a partial `/cd` target.
6
+ *
7
+ * `path` must already be tilde-expanded and resolved by the caller (`/cd`
8
+ * passes the `resolve()`d target). Given a path that does not (fully) exist,
9
+ * walk up to its nearest existing ancestor and return the subdirectories whose
10
+ * names prefix-match the trailing segment. Files and non-matching entries are
11
+ * excluded. Returns `[]` when the path already exists or nothing matches.
12
+ */
13
+ export function suggestDirectories(path: string): string[] {
14
+ if (existsSync(path)) return []
15
+
16
+ let ancestor = dirname(path)
17
+ while (!existsSync(ancestor)) {
18
+ const parent = dirname(ancestor)
19
+ if (parent === ancestor) return [] // hit filesystem root
20
+ ancestor = parent
21
+ }
22
+
23
+ const prefix = basename(path)
24
+ let entries
25
+ try {
26
+ entries = readdirSync(ancestor, { withFileTypes: true })
27
+ } catch {
28
+ return []
29
+ }
30
+
31
+ return entries
32
+ .filter((e) => e.isDirectory() && e.name.startsWith(prefix))
33
+ .map((e) => join(ancestor, e.name))
34
+ .sort()
35
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * CLAUDE.md audit — find sections whose content the model can infer from the
3
+ * codebase itself (directory structure, tech stack, dependencies, commit
4
+ * history, project/submodule catalogs, test counts). Such sections burn
5
+ * context tokens every session and go stale as the code changes; they are
6
+ * candidates for the `prompt-exclude` frontmatter.
7
+ */
8
+
9
+ export type DerivableReason =
10
+ 'structure' | 'tech-stack' | 'dependencies' | 'commits' | 'catalog' | 'tests'
11
+
12
+ export interface DerivableSection {
13
+ heading: string
14
+ reason: DerivableReason
15
+ }
16
+
17
+ /** Human-readable hint for each derivable category (diagnostic output). */
18
+ export const DERIVABLE_HINTS: Record<DerivableReason, string> = {
19
+ structure: 'directory/file structure — inferable from the repo itself',
20
+ 'tech-stack': 'tech stack — inferable from package.json / config',
21
+ dependencies: 'dependencies — inferable from package.json',
22
+ commits: 'commit/revision history — inferable from git log',
23
+ catalog: 'project/submodule catalog — inferable from .gitmodules / directories',
24
+ tests: 'test counts — inferable from running the suite',
25
+ }
26
+
27
+ const PATTERNS: Array<{ pattern: RegExp; reason: DerivableReason }> = [
28
+ {
29
+ pattern:
30
+ /目录结构|项目结构|文件结构|directory structure|project structure|file structure|repo structure|monorepo/i,
31
+ reason: 'structure',
32
+ },
33
+ { pattern: /技术栈|tech\s*stack|technology\s*stack/i, reason: 'tech-stack' },
34
+ { pattern: /依赖关系|依赖清单|dependencies/i, reason: 'dependencies' },
35
+ {
36
+ pattern: /最近提交|recent\s*commit|修订历史|revision\s*history|changelog|变更记录|变更历史/i,
37
+ reason: 'commits',
38
+ },
39
+ {
40
+ pattern: /项目一览|项目清单|项目列表|子模块清单|submodule\s*(list|catalog)|catalog/i,
41
+ reason: 'catalog',
42
+ },
43
+ { pattern: /测试矩阵|test\s*matrix|测试覆盖|coverage/i, reason: 'tests' },
44
+ ]
45
+
46
+ /**
47
+ * Return `##`/`###` headings whose title matches a derivable-content pattern,
48
+ * in document order. Headings without a match are ignored.
49
+ */
50
+ export function findDerivableSections(content: string): DerivableSection[] {
51
+ const found: DerivableSection[] = []
52
+ for (const line of content.split('\n')) {
53
+ const m = line.match(/^(#{2,3})\s+(.+?)\s*$/)
54
+ if (!m) continue
55
+ const heading = m[2]!.trim()
56
+ for (const { pattern, reason } of PATTERNS) {
57
+ if (pattern.test(heading)) {
58
+ found.push({ heading, reason })
59
+ break
60
+ }
61
+ }
62
+ }
63
+ return found
64
+ }
@@ -0,0 +1,4 @@
1
+ /** 指数退避,封顶 30s。三频道(telegram/wecom/dingtalk)重连共享。 */
2
+ export function nextBackoff(currentMs: number): number {
3
+ return Math.min(currentMs * 2, 30_000)
4
+ }
@@ -3,7 +3,7 @@ import type { SessionWorker } from './session-worker'
3
3
  import type { RateLimiter } from './rate-limiter'
4
4
 
5
5
  export interface ChannelMessageOptions {
6
- channel: string // 'feishu' | 'telegram' | 'wecom'
6
+ channel: string // 'feishu' | 'telegram' | 'wecom' | 'dingtalk'
7
7
  externalId: string // openId / chatId / userId
8
8
  text: string
9
9
  allowed: Set<string>
@@ -14,11 +14,11 @@ export interface ChannelMessageOptions {
14
14
  provider: string
15
15
  model: string
16
16
  sendText: (externalId: string, text: string) => Promise<void>
17
- maxLen: number // 飞书 4000 / Telegram 4096 / 企微 2048
17
+ maxLen: number // 飞书 4000 / Telegram 4096 / 企微 2048 / 钉钉 2000
18
18
  logPrefix: string // '[feishu]' / '[telegram]' / '[wecom]'
19
19
  }
20
20
 
21
- /** 三频道共享的消息处理骨架:白名单→限流→会话→processPrompt→回发。 */
21
+ /** 四频道共享的消息处理骨架:白名单→限流→会话→processPrompt→回发。 */
22
22
  export async function handleChannelMessage(opts: ChannelMessageOptions): Promise<void> {
23
23
  const {
24
24
  channel,
@@ -0,0 +1,62 @@
1
+ import type { DingtalkApi } from './api.js'
2
+ import type { DingtalkConfig, DingtalkMessage } 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 { startDingtalkWs } from './ws-client.js'
7
+ import { handleChannelMessage } from '../channel-message.js'
8
+
9
+ export interface DingtalkAdapterDeps {
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 DingtalkAdapter {
19
+ start(): () => void
20
+ handleMessage(msg: DingtalkMessage): Promise<void>
21
+ isAllowed(staffId: string): boolean
22
+ }
23
+
24
+ export function createDingtalkAdapter(
25
+ config: DingtalkConfig,
26
+ api: DingtalkApi,
27
+ deps: DingtalkAdapterDeps,
28
+ ): DingtalkAdapter {
29
+ const allowed = new Set(config.allowedStaffIds)
30
+ let stopWs: (() => void) | null = null
31
+
32
+ async function handleMessage(msg: DingtalkMessage): Promise<void> {
33
+ await handleChannelMessage({
34
+ channel: 'dingtalk',
35
+ externalId: msg.staffId,
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
+ // 钉钉回发走每条消息自带的 sessionWebhook(非持久 userId 路由)
45
+ sendText: async (_staffId, text) => {
46
+ await api.reply(msg.sessionWebhook, text)
47
+ },
48
+ maxLen: 2000,
49
+ logPrefix: '[dingtalk]',
50
+ })
51
+ }
52
+
53
+ return {
54
+ handleMessage,
55
+ isAllowed: (staffId) => allowed.has(staffId),
56
+ start() {
57
+ if (stopWs) return stopWs
58
+ stopWs = startDingtalkWs(api, handleMessage)
59
+ return stopWs
60
+ },
61
+ }
62
+ }
@@ -0,0 +1,120 @@
1
+ import type { DingtalkConfig, DingtalkMessage } from './types.js'
2
+
3
+ const GATEWAY_URL = 'https://api.dingtalk.com/v1.0/gateway/connections/open'
4
+
5
+ export interface DingtalkApi {
6
+ register(): Promise<{ endpoint: string; ticket: string }>
7
+ open(endpoint: string, ticket: string): WebSocket
8
+ reply(sessionWebhook: string, text: string): Promise<void>
9
+ parseMessage(frame: unknown): DingtalkMessage | null
10
+ isPing(frame: unknown): boolean
11
+ ack(ws: WebSocket, frame: unknown): void
12
+ pong(ws: WebSocket, frame: unknown): void
13
+ }
14
+
15
+ /** 回帧信封:code 200 + 回显 headers.messageId。ack 与 pong 共用。 */
16
+ function sendResponse(ws: WebSocket, frame: unknown, data: unknown): void {
17
+ const f = frame as { headers?: { messageId?: string } }
18
+ ws.send(
19
+ JSON.stringify({
20
+ code: 200,
21
+ headers: { contentType: 'application/json', messageId: f?.headers?.messageId ?? '' },
22
+ message: 'OK',
23
+ data,
24
+ }),
25
+ )
26
+ }
27
+
28
+ /**
29
+ * 钉钉 Stream Mode 协议 codec。零依赖:HTTP 用裸 fetch(register + 回发),
30
+ * WebSocket 用 globalThis.WebSocket(Node 22+ / Bun 原生)。ticket 一次性且
31
+ * 90s 过期,由 ws-client 每次重连前重新 register。
32
+ */
33
+ export function createDingtalkApi(
34
+ config: DingtalkConfig,
35
+ fetchImpl: typeof fetch = fetch,
36
+ ): DingtalkApi {
37
+ return {
38
+ async register() {
39
+ const res = await fetchImpl(GATEWAY_URL, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({
43
+ clientId: config.clientId,
44
+ clientSecret: config.clientSecret,
45
+ subscriptions: [{ type: 'CALLBACK', topic: '/v1.0/im/bot/messages/get' }],
46
+ ua: 'mipham-code',
47
+ }),
48
+ })
49
+ if (!res.ok) throw new Error(`dingtalk gateway register ${res.status}`)
50
+ const data = (await res.json()) as { endpoint?: string; ticket?: string }
51
+ if (!data.endpoint || !data.ticket) {
52
+ throw new Error('dingtalk gateway: missing endpoint/ticket')
53
+ }
54
+ return { endpoint: data.endpoint, ticket: data.ticket }
55
+ },
56
+
57
+ open(endpoint, ticket) {
58
+ return new WebSocket(`${endpoint}?ticket=${encodeURIComponent(ticket)}`)
59
+ },
60
+
61
+ async reply(sessionWebhook, text) {
62
+ if (!sessionWebhook) return
63
+ const res = await fetchImpl(sessionWebhook, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({ msgtype: 'text', text: { content: text } }),
67
+ })
68
+ if (!res.ok) throw new Error(`dingtalk reply ${res.status}`)
69
+ const data = (await res.json()) as { errcode?: number; errmsg?: string }
70
+ if (data.errcode != null && data.errcode !== 0) {
71
+ throw new Error(`dingtalk reply errcode=${data.errcode}: ${data.errmsg ?? 'unknown'}`)
72
+ }
73
+ },
74
+
75
+ parseMessage(frame) {
76
+ if (!frame || typeof frame !== 'object') return null
77
+ const f = frame as { type?: string; data?: unknown }
78
+ if (f.type !== 'CALLBACK' || typeof f.data !== 'string') return null
79
+ let payload: {
80
+ msgtype?: string
81
+ text?: { content?: string }
82
+ senderStaffId?: string
83
+ senderId?: string
84
+ conversationId?: string
85
+ msgId?: string
86
+ sessionWebhook?: string
87
+ }
88
+ try {
89
+ payload = JSON.parse(f.data)
90
+ } catch {
91
+ return null
92
+ }
93
+ if (payload?.msgtype !== 'text') return null
94
+ const text = payload?.text?.content?.trim()
95
+ if (!text) return null
96
+ const staffId = payload?.senderStaffId ?? payload?.senderId
97
+ if (!staffId) return null
98
+ return {
99
+ staffId,
100
+ conversationId: payload?.conversationId ?? '',
101
+ msgId: payload?.msgId ?? '',
102
+ text,
103
+ sessionWebhook: payload?.sessionWebhook ?? '',
104
+ }
105
+ },
106
+
107
+ isPing(frame) {
108
+ const f = frame as { type?: string; headers?: { topic?: string } }
109
+ return f?.type === 'SYSTEM' && f?.headers?.topic === 'ping'
110
+ },
111
+
112
+ ack(ws, frame) {
113
+ sendResponse(ws, frame, '{"response": null}')
114
+ },
115
+
116
+ pong(ws, frame) {
117
+ sendResponse(ws, frame, (frame as { data?: unknown }).data)
118
+ },
119
+ }
120
+ }
@@ -0,0 +1,16 @@
1
+ import type { DingtalkConfig } from './types.js'
2
+
3
+ /** fail-closed:缺 clientId 或 clientSecret → null(daemon 不启用钉钉)。 */
4
+ export function parseDingtalkEnv(): DingtalkConfig | null {
5
+ const clientId = process.env.DINGTALK_CLIENT_ID
6
+ const clientSecret = process.env.DINGTALK_CLIENT_SECRET
7
+ if (!clientId || !clientSecret) return null
8
+ return {
9
+ clientId,
10
+ clientSecret,
11
+ allowedStaffIds: (process.env.DINGTALK_ALLOWED_STAFF_IDS || '')
12
+ .split(',')
13
+ .map((s) => s.trim())
14
+ .filter(Boolean),
15
+ }
16
+ }
@@ -0,0 +1,13 @@
1
+ export interface DingtalkConfig {
2
+ clientId: string
3
+ clientSecret: string
4
+ allowedStaffIds: string[] // 白名单(钉钉 senderStaffId / senderId)
5
+ }
6
+
7
+ export interface DingtalkMessage {
8
+ staffId: string // 发消息用户(senderStaffId,回退 senderId)
9
+ conversationId: string // 会话 id
10
+ msgId: string // 消息 id(业务侧)
11
+ text: string // 文本内容
12
+ sessionWebhook: string // 回发路由(机器人 sessionWebhook URL)
13
+ }
@@ -0,0 +1,81 @@
1
+ import type { DingtalkApi } from './api.js'
2
+ import type { DingtalkMessage } from './types.js'
3
+ import { nextBackoff } from '../backoff.js'
4
+
5
+ /**
6
+ * 钉钉 Stream 长连接生命周期:register(HTTP 拿 endpoint+ticket)→ 建连 →
7
+ * 服务端 ping/pong → 消息回调(parse→ack→onMessage)→ 断开重连。ticket 一次性
8
+ * 且 90s 过期,故每次重连前必须重新 register。返回 stop。
9
+ */
10
+ export function startDingtalkWs(
11
+ api: DingtalkApi,
12
+ onMessage: (msg: DingtalkMessage) => Promise<void>,
13
+ ): () => void {
14
+ let stopped = false
15
+ let ws: WebSocket | null = null
16
+ let backoffMs = 1000
17
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
18
+
19
+ function clearReconnect() {
20
+ if (reconnectTimer) clearTimeout(reconnectTimer)
21
+ reconnectTimer = null
22
+ }
23
+
24
+ function scheduleReconnect() {
25
+ if (stopped) return
26
+ reconnectTimer = setTimeout(() => void connect(), backoffMs)
27
+ backoffMs = nextBackoff(backoffMs)
28
+ ;(reconnectTimer as unknown as { unref?: () => void }).unref?.()
29
+ }
30
+
31
+ async function connect() {
32
+ if (stopped) return
33
+ let endpoint: string
34
+ let ticket: string
35
+ try {
36
+ ;({ endpoint, ticket } = await api.register())
37
+ } catch {
38
+ scheduleReconnect()
39
+ return
40
+ }
41
+ if (stopped) return
42
+ ws = api.open(endpoint, ticket)
43
+ ws.onopen = () => {
44
+ backoffMs = 1000
45
+ }
46
+ ws.onmessage = (ev) => {
47
+ let frame: unknown
48
+ try {
49
+ frame = JSON.parse(ev.data as string)
50
+ } catch {
51
+ return
52
+ }
53
+ if (api.isPing(frame)) {
54
+ if (ws) api.pong(ws, frame)
55
+ return
56
+ }
57
+ const msg = api.parseMessage(frame)
58
+ if (msg) {
59
+ if (ws) api.ack(ws, frame)
60
+ void onMessage(msg).catch(() => {})
61
+ }
62
+ }
63
+ ws.onclose = () => {
64
+ ws = null
65
+ scheduleReconnect()
66
+ }
67
+ }
68
+
69
+ void connect()
70
+ return () => {
71
+ stopped = true
72
+ clearReconnect()
73
+ if (ws) {
74
+ try {
75
+ ws.close()
76
+ } catch {
77
+ /* 连接尚未建立时 close 可能抛错,忽略 */
78
+ }
79
+ }
80
+ }
81
+ }
@@ -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
 
@@ -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/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(private fetchImpl: FetchFn = fetch as unknown as FetchFn) {}
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(), REQUEST_TIMEOUT_MS)
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
- const response = await fetch(auth.tokenUrl, {
152
- method: 'POST',
153
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
154
- body: new URLSearchParams({
155
- grant_type: 'refresh_token',
156
- refresh_token: saved.refreshToken,
157
- client_id: auth.clientId,
158
- }).toString(),
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)
@@ -19,7 +19,12 @@ export interface Transport {
19
19
  isConnected(): boolean
20
20
  }
21
21
 
22
- const REQUEST_TIMEOUT_MS = 60_000
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(new Error(`MCP request timeout: ${method} (${REQUEST_TIMEOUT_MS}ms)`))
173
- }, REQUEST_TIMEOUT_MS)
178
+ reject(requestTimeoutError(method, this.requestTimeoutMs))
179
+ }, this.requestTimeoutMs)
174
180
 
175
181
  this.pending.set(id, { resolve, reject, timer })
176
182
 
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.53.0' as const
12
+ export const PACKAGE_VERSION = '0.54.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -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
@@ -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
- return { content: t('commands.cd.not_found', { path: resolved }) }
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 {