@leviyuan/lodestar 0.1.0 → 2.0.14

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/daemon.ts CHANGED
@@ -1,203 +1,1139 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Lodestar 2.0 daemon Feishu (Lark) Claude Code headless bridge.
3
+ * Feishu daemon for DeepSeek TUI standalone process that watches all
4
+ * Feishu groups and routes messages to/from DeepSeek via Runtime API.
4
5
  *
5
- * Listens on Lark WebSocket for inbound messages and card-action
6
- * callbacks, routes each to a per-chat Session that owns a headless
7
- * `claude` subprocess and a streaming Card Kit card.
6
+ * "hi" → create or check a Runtime API thread
7
+ * "restart" interrupt + fork (fresh context)
8
+ * "kill" → archive thread
9
+ * "clear" → compact thread
10
+ * "ls" → render a tree of the project directory
11
+ * other → inject as a turn into the thread
8
12
  *
9
- * Run: bun daemon.ts
10
- * Stop: SIGTERM
13
+ * Run: bun daemon.ts
14
+ *
15
+ * Design doc: LODESTAR_DEEPSEEK_DESIGN.md
11
16
  */
12
17
 
18
+ import './env-bootstrap' // MUST be first
19
+
13
20
  import * as lark from '@larksuiteoapi/node-sdk'
14
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
15
- import { dirname } from 'node:path'
16
- import { Session } from './src/session'
17
- import * as feishu from './src/feishu'
18
- import { config } from './src/config'
19
- import { log } from './src/log'
20
- import { PID_FILE } from './src/paths'
21
-
22
- // ── PID guard ───────────────────────────────────────────────────────────
23
- try {
24
- const existing = readFileSync(PID_FILE, 'utf8').trim()
21
+ import { execSync } from 'child_process'
22
+ import { createHash } from 'crypto'
23
+ import {
24
+ readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync,
25
+ appendFileSync, readdirSync, statSync, copyFileSync,
26
+ } from 'fs'
27
+ import { homedir } from 'os'
28
+ import { join, resolve as resolvePath, sep as pathSep, basename, dirname } from 'path'
29
+ import {
30
+ RuntimeApiClient,
31
+ MessageQueue,
32
+ SseEventHandler,
33
+ ThreadMap,
34
+ injectFeishuMcpConfig,
35
+ buildSystemPrompt,
36
+ } from './runtime-api'
37
+ import type { QueuedMessage, SseDelta, TurnInfo } from './runtime-api'
38
+ import { startEmailWorker, stopEmailWorker } from './email-worker'
39
+ import { startStatusDashboard } from './status-dashboard'
40
+
41
+ // ── State paths ──────────────────────────────────────────────────────────────
42
+
43
+ const STATE_DIR = join(homedir(), '.deepseek', 'lodestar')
44
+ const PID_FILE = join(STATE_DIR, 'daemon.pid')
45
+ const LOG_FILE = join(STATE_DIR, 'daemon.log')
46
+ const INBOX_DIR = join(STATE_DIR, 'inbox')
47
+ const PROJECTS_ROOT = process.env.FEISHU_PROJECTS_ROOT ?? homedir()
48
+
49
+ // Runtime API config
50
+ const API_URL = process.env.DEEPSEEK_API_URL ?? 'http://localhost:7878'
51
+ const API_TOKEN = process.env.DEEPSEEK_API_TOKEN ?? ''
52
+
53
+ function sanitizeSessionName(raw: string): string {
54
+ return raw.replace(/[^\w一-鿿\-]/g, '_').slice(0, 64)
55
+ }
56
+
57
+ const APP_ID = process.env.FEISHU_APP_ID
58
+ const APP_SECRET = process.env.FEISHU_APP_SECRET
59
+
60
+ if (!APP_ID || !APP_SECRET) {
61
+ console.error('feishu-daemon: FEISHU_APP_ID and FEISHU_APP_SECRET required')
62
+ console.error(' run: lodestar configure')
63
+ console.error(' or edit: ~/.deepseek/lodestar.toml')
64
+ process.exit(1)
65
+ }
66
+
67
+ // ── Lark client ──────────────────────────────────────────────────────────────
68
+
69
+ const client = new lark.Client({
70
+ appId: APP_ID,
71
+ appSecret: APP_SECRET,
72
+ disableTokenCache: false,
73
+ })
74
+
75
+ // ── Runtime API client ───────────────────────────────────────────────────────
76
+
77
+ const api = new RuntimeApiClient({ baseUrl: API_URL, authToken: API_TOKEN })
78
+ const msgQueue = new MessageQueue()
79
+ const threadMap = new ThreadMap()
80
+
81
+ // ── Group name cache: chat_id → name ─────────────────────────────────────────
82
+
83
+ const chatNameCache = new Map<string, string>()
84
+
85
+ // Session name → preferred chat_id (same as original — handles duplicate group names)
86
+ const SESSION_CHAT_MAP_FILE = join(STATE_DIR, 'session-chat-map.json')
87
+ const preferredChatForSession = new Map<string, string>()
88
+
89
+ function loadSessionChatMap(): void {
25
90
  try {
26
- process.kill(Number(existing), 0)
27
- console.error(`lodestar-daemon: already running (pid ${existing})`)
28
- process.exit(1)
91
+ const raw = readFileSync(SESSION_CHAT_MAP_FILE, 'utf-8')
92
+ const obj = JSON.parse(raw)
93
+ for (const [name, id] of Object.entries(obj)) {
94
+ if (typeof id === 'string') preferredChatForSession.set(name, id)
95
+ }
96
+ log(`loaded session→chat map: ${preferredChatForSession.size} bindings`)
29
97
  } catch {}
30
- } catch {}
98
+ }
99
+
100
+ function saveSessionChatMap(): void {
101
+ try {
102
+ const obj: Record<string, string> = {}
103
+ for (const [name, id] of preferredChatForSession.entries()) obj[name] = id
104
+ writeFileSync(SESSION_CHAT_MAP_FILE, JSON.stringify(obj, null, 2))
105
+ } catch (err) {
106
+ log(`save session→chat map failed: ${err}`)
107
+ }
108
+ }
109
+
110
+ function bindSessionToChat(sessionName: string, chatId: string): void {
111
+ if (preferredChatForSession.get(sessionName) === chatId) return
112
+ const prev = preferredChatForSession.get(sessionName)
113
+ preferredChatForSession.set(sessionName, chatId)
114
+ saveSessionChatMap()
115
+ log(`bound session "${sessionName}" → chat ${chatId}${prev ? ` (was ${prev})` : ''}`)
116
+ }
117
+
118
+ async function refreshChatList(): Promise<void> {
119
+ try {
120
+ let pageToken: string | undefined
121
+ do {
122
+ const res = await client.im.chat.list({
123
+ params: { page_size: 100, ...(pageToken ? { page_token: pageToken } : {}) },
124
+ })
125
+ for (const chat of res.data?.items ?? []) {
126
+ if (chat.chat_id && chat.name) {
127
+ chatNameCache.set(chat.chat_id, chat.name)
128
+ }
129
+ }
130
+ pageToken = res.data?.page_token
131
+ } while (pageToken)
132
+ log(`refreshed chat list: ${chatNameCache.size} groups`)
133
+ } catch (err) {
134
+ log(`refresh chat list failed: ${err}`)
135
+ }
136
+ }
137
+
138
+ // ── Helpers ──────────────────────────────────────────────────────────────────
139
+
140
+ function log(msg: string): void {
141
+ const line = `[${new Date().toISOString()}] ${msg}`
142
+ try {
143
+ mkdirSync(STATE_DIR, { recursive: true })
144
+ appendFileSync(LOG_FILE, line + '\n')
145
+ } catch {}
146
+ }
147
+
148
+ // ── Feishu send helpers ─────────────────────────────────────────────────────
149
+
150
+ async function getToken(): Promise<string> {
151
+ const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
155
+ })
156
+ const data = await res.json() as any
157
+ return data.tenant_access_token
158
+ }
159
+
160
+ async function addReaction(messageId: string, emojiType: string): Promise<string | undefined> {
161
+ if (!messageId) return
162
+ try {
163
+ const res: any = await client.im.messageReaction.create({
164
+ path: { message_id: messageId },
165
+ data: { reaction_type: { emoji_type: emojiType } },
166
+ })
167
+ return res?.data?.reaction_id
168
+ } catch (err) {
169
+ log(`react ${emojiType} failed on ${messageId}: ${err}`)
170
+ }
171
+ }
172
+
173
+ async function sendTextMessage(chatId: string, text: string): Promise<string | null> {
174
+ try {
175
+ const res: any = await client.im.message.create({
176
+ params: { receive_id_type: 'chat_id' },
177
+ data: {
178
+ receive_id: chatId,
179
+ msg_type: 'text',
180
+ content: JSON.stringify({ text }),
181
+ },
182
+ })
183
+ if (res && typeof res.code === 'number' && res.code !== 0) {
184
+ log(`send message rejected by Lark: chat=${chatId} code=${res.code} msg=${res.msg}`)
185
+ return null
186
+ }
187
+ return res?.data?.message_id ?? null
188
+ } catch (e) {
189
+ log(`send message failed: chat=${chatId} err=${e}`)
190
+ return null
191
+ }
192
+ }
193
+
194
+ async function sendCardMessage(chatId: string, card: object): Promise<boolean> {
195
+ try {
196
+ const res: any = await client.im.message.create({
197
+ params: { receive_id_type: 'chat_id' },
198
+ data: {
199
+ receive_id: chatId,
200
+ msg_type: 'interactive',
201
+ content: JSON.stringify(card),
202
+ },
203
+ })
204
+ if (res && typeof res.code === 'number' && res.code !== 0) {
205
+ log(`send card rejected by Lark: chat=${chatId} code=${res.code} msg=${res.msg}`)
206
+ return false
207
+ }
208
+ return true
209
+ } catch (e) {
210
+ log(`send card failed: chat=${chatId} err=${e}`)
211
+ return false
212
+ }
213
+ }
214
+
215
+ // ── Image/file download ─────────────────────────────────────────────────────
216
+
217
+ async function downloadAttachment(
218
+ messageId: string,
219
+ key: string,
220
+ type: 'image' | 'file',
221
+ name?: string,
222
+ ): Promise<string | undefined> {
223
+ try {
224
+ const token = await getToken()
225
+ const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/resources/${key}?type=${type}`
226
+ log(`downloading ${type}: ${url}`)
227
+ const res = await fetch(url, {
228
+ headers: { 'Authorization': `Bearer ${token}` },
229
+ })
230
+ if (!res.ok) {
231
+ const body = await res.text()
232
+ log(`${type} download HTTP ${res.status}: ${body.slice(0, 200)}`)
233
+ return undefined
234
+ }
235
+ const buf = Buffer.from(await res.arrayBuffer())
236
+ mkdirSync(INBOX_DIR, { recursive: true })
237
+ const safeName = name
238
+ ? name.replace(/[^a-zA-Z0-9._-]/g, '_')
239
+ : `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}.png`
240
+ const path = join(INBOX_DIR, `${Date.now()}-${safeName}`)
241
+ writeFileSync(path, buf)
242
+ log(`downloaded ${type} to ${path} (${buf.length} bytes)`)
243
+ return path
244
+ } catch (err) {
245
+ log(`${type} download failed: ${err instanceof Error ? err.message : String(err)}`)
246
+ }
247
+ return undefined
248
+ }
249
+
250
+ // ── Slash alias expansion ───────────────────────────────────────────────────
251
+
252
+ const SLASH_ALIASES: Record<string, string> = {
253
+ ap: '/oh-my-claudecode:autopilot',
254
+ ulw: '/oh-my-claudecode:ultrawork',
255
+ cl: '/oh-my-claudecode:cancel',
256
+ }
257
+
258
+ function expandSlashAlias(text: string): string {
259
+ if (!text) return text
260
+ const tokens = text.split(/\s+/).filter(Boolean)
261
+ if (tokens.length === 0) return text
262
+
263
+ const lookup = (tok: string): string | null => {
264
+ if (!tok.startsWith('/')) return null
265
+ return SLASH_ALIASES[tok.slice(1).toLowerCase()] ?? null
266
+ }
267
+
268
+ const firstHit = lookup(tokens[0])
269
+ if (firstHit) return [firstHit, ...tokens.slice(1)].join(' ').trim()
270
+
271
+ if (tokens.length > 1) {
272
+ const lastHit = lookup(tokens[tokens.length - 1])
273
+ if (lastHit) return [lastHit, ...tokens.slice(0, -1)].join(' ').trim()
274
+ }
275
+
276
+ return text
277
+ }
278
+
279
+ // ── Project tree (ls command) ────────────────────────────────────────────────
280
+
281
+ const TREE_IGNORE = new Set([
282
+ '.git', '.claude', '.omc',
283
+ 'node_modules', '__pycache__', '.venv', 'venv',
284
+ 'dist', 'build', 'target', '.next', '.turbo', '.cache',
285
+ '.bun', '.pytest_cache', '.mypy_cache', '.ruff_cache',
286
+ ])
287
+
288
+ const TREE_MAX_LINES = 100
289
+ const TREE_FOLDER_THRESHOLD = 12
290
+
291
+ function renderProjectTreeBody(rootDir: string): string {
292
+ const lines: string[] = []
293
+ let truncated = false
294
+
295
+ function walk(dir: string, prefix: string): void {
296
+ if (lines.length >= TREE_MAX_LINES) { truncated = true; return }
297
+ let entries: { name: string; isDir: boolean }[]
298
+ try {
299
+ entries = readdirSync(dir, { withFileTypes: true })
300
+ .filter(e => !TREE_IGNORE.has(e.name))
301
+ .map(e => ({ name: e.name, isDir: e.isDirectory() }))
302
+ .sort((a, b) => {
303
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
304
+ return a.name.localeCompare(b.name)
305
+ })
306
+ } catch { return }
307
+
308
+ let visible = entries
309
+ let collapsedCount = 0
310
+ if (entries.length > TREE_FOLDER_THRESHOLD) {
311
+ visible = entries.slice(0, TREE_FOLDER_THRESHOLD)
312
+ collapsedCount = entries.length - TREE_FOLDER_THRESHOLD
313
+ }
314
+
315
+ for (let i = 0; i < visible.length; i++) {
316
+ if (lines.length >= TREE_MAX_LINES) { truncated = true; return }
317
+ const e = visible[i]
318
+ const isLastVisible = i === visible.length - 1 && collapsedCount === 0
319
+ const branch = isLastVisible ? '└── ' : '├── '
320
+ lines.push(prefix + branch + e.name + (e.isDir ? '/' : ''))
321
+ if (e.isDir) {
322
+ walk(join(dir, e.name), prefix + (isLastVisible ? ' ' : '│ '))
323
+ if (lines.length >= TREE_MAX_LINES) { truncated = true; return }
324
+ }
325
+ }
326
+ if (collapsedCount > 0) {
327
+ if (lines.length >= TREE_MAX_LINES) { truncated = true; return }
328
+ lines.push(prefix + `└── … more ${collapsedCount} 个`)
329
+ }
330
+ }
331
+
332
+ walk(rootDir, '')
333
+
334
+ if (truncated) {
335
+ if (lines.length >= TREE_MAX_LINES) lines.length = TREE_MAX_LINES - 1
336
+ lines.push('… (已截断到 100 行)')
337
+ }
338
+ return lines.join('\n')
339
+ }
340
+
341
+ // ── Provision new project ───────────────────────────────────────────────────
342
+
343
+ function provisionProject(workDir: string): void {
344
+ mkdirSync(workDir, { recursive: true })
345
+ log(`provisioned directory ${workDir}`)
346
+
347
+ // Run git init so the new project has version control from the start
348
+ try {
349
+ execSync('git init', { cwd: workDir, timeout: 10_000 })
350
+ log(`git init in ${workDir}`)
351
+ } catch (err) {
352
+ log(`git init in ${workDir} failed: ${err}`)
353
+ }
354
+ }
355
+
356
+ // ── Resolve chat_id for a session name ──────────────────────────────────────
357
+
358
+ function chatIdForSession(sessionName: string): string | null {
359
+ const preferred = preferredChatForSession.get(sessionName)
360
+ if (preferred && chatNameCache.get(preferred) === sessionName) return preferred
361
+
362
+ const matches: string[] = []
363
+ for (const [id, name] of chatNameCache.entries()) {
364
+ if (name === sessionName) matches.push(id)
365
+ }
366
+ if (matches.length === 1) return matches[0]
367
+ if (matches.length > 1) {
368
+ log(`chatIdForSession("${sessionName}"): ${matches.length} chats share this name and no binding — [${matches.join(', ')}]`)
369
+ return null
370
+ }
371
+ return null
372
+ }
373
+
374
+ // ── SSE event handling (stream output to Feishu) ────────────────────────────
375
+
376
+ // Accumulates agent_message text and sends single complete reply on turn.completed.
377
+ // Feishu PATCH endpoint doesn't support text message updates, so we accumulate
378
+ // all deltas and send once. During processing, a 👌 reaction on the user's
379
+ // message indicates the bot is working.
380
+ const streamBuffers = new Map<string, {
381
+ text: string
382
+ chatId: string
383
+ userMessageId?: string // original feishu message to react to
384
+ reasoningText: string
385
+ }>()
386
+
387
+ const sseHandler = new SseEventHandler(api)
388
+
389
+ sseHandler.onDelta = (threadId: string, delta: SseDelta) => {
390
+ if (delta.kind === 'agent_reasoning') {
391
+ const buf = streamBuffers.get(threadId)
392
+ if (buf) buf.reasoningText += delta.delta
393
+ return
394
+ }
395
+
396
+ if (delta.kind === 'agent_message') {
397
+ const buf = streamBuffers.get(threadId)
398
+ if (buf) buf.text += delta.delta
399
+ return
400
+ }
401
+ }
402
+
403
+ sseHandler.onTurnCompleted = (threadId: string, _turn: TurnInfo) => {
404
+ const buf = streamBuffers.get(threadId)
405
+ if (!buf) return
406
+
407
+ const text = buf.text.trim()
408
+ buf.text = ''
409
+ buf.reasoningText = ''
410
+
411
+ if (text) {
412
+ // Send complete reply — use card format if markdown detected
413
+ sendCardIfMarkdown(buf.chatId, text)
414
+ }
415
+
416
+ // Clear active turn
417
+ msgQueue.clearActiveTurn(threadId)
418
+
419
+ // Inject next queued message if any
420
+ const next = msgQueue.dequeue(threadId)
421
+ if (next) {
422
+ injectMessage(threadId, next)
423
+ }
424
+ }
425
+
426
+ function initStreamBuffer(threadId: string, chatId: string, userMessageId?: string): void {
427
+ streamBuffers.set(threadId, {
428
+ text: '',
429
+ chatId,
430
+ userMessageId,
431
+ reasoningText: '',
432
+ })
433
+ }
434
+
435
+ /** Send as interactive card if text has markdown, else plain text. */
436
+ async function sendCardIfMarkdown(chatId: string, text: string): Promise<void> {
437
+ // Simple markdown detection — if present, send as card for better mobile rendering
438
+ const hasMd = /(?:^#{1,6}\s|^[-*]\s|```|\*\*|`[^`]+`)/m.test(text)
439
+ if (hasMd) {
440
+ try {
441
+ const card = markdownToCard(text)
442
+ await sendCardMessageRaw(chatId, card)
443
+ return
444
+ } catch (err) {
445
+ log(`send card failed, falling back to text: ${err}`)
446
+ }
447
+ }
448
+ await sendTextMessage(chatId, text)
449
+ }
450
+
451
+ /** Simple markdown → Feishu card (v2 schema). */
452
+ function markdownToCard(text: string): string {
453
+ const MAX_LEN = 28000
454
+ const parts: string[] = []
455
+ let rest = text
456
+ while (rest.length > MAX_LEN) {
457
+ const cut = rest.lastIndexOf('\n\n', MAX_LEN)
458
+ parts.push(rest.slice(0, cut > MAX_LEN / 2 ? cut : MAX_LEN))
459
+ rest = rest.slice(cut > MAX_LEN / 2 ? cut : MAX_LEN)
460
+ }
461
+ if (rest) parts.push(rest)
462
+ const elements = parts.map(p => ({ tag: 'markdown', content: p }))
463
+ return JSON.stringify({ schema: '2.0', config: { wide_screen_mode: true }, body: { elements } })
464
+ }
465
+
466
+ /** Send a raw card JSON to feishu. */
467
+ async function sendCardMessageRaw(chatId: string, cardJson: string): Promise<void> {
468
+ try {
469
+ const res: any = await client.im.message.create({
470
+ params: { receive_id_type: 'chat_id' },
471
+ data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
472
+ })
473
+ if (res && typeof res.code === 'number' && res.code !== 0) {
474
+ log(`send card rejected: chat=${chatId} code=${res.code} msg=${res.msg}`)
475
+ }
476
+ } catch (e) {
477
+ log(`send card failed: chat=${chatId} err=${e}`)
478
+ }
479
+ }
480
+
481
+ // ── Message injection ───────────────────────────────────────────────────────
482
+
483
+ async function injectMessage(threadId: string, msg: QueuedMessage): Promise<void> {
484
+ const sessionName = threadMap.getSessionName(threadId)
485
+ if (!sessionName) {
486
+ log(`injectMessage: no session name for thread ${threadId}`)
487
+ return
488
+ }
489
+
490
+ // Initialize stream buffer for this thread's chat
491
+ const chatId = msg.chatId
492
+ initStreamBuffer(threadId, chatId)
493
+
494
+ let prompt = `[飞书] ${msg.sender} (${msg.messageId}): ${msg.text}`
495
+ if (msg.filePath) {
496
+ prompt += `\n\n附件已下载到: ${msg.filePath}`
497
+ }
498
+
499
+ // Build the full prompt with file context
500
+ // For images, tell the model to read the file
501
+ if (msg.filePath && /\.(png|jpe?g|gif|webp|bmp)$/i.test(msg.filePath)) {
502
+ prompt += `\n\n(这是一张图片,请使用 read 工具查看: ${msg.filePath})`
503
+ }
504
+
505
+ try {
506
+ if (msgQueue.hasActiveTurn(threadId)) {
507
+ // Interrupt current turn and inject new message
508
+ const activeTurnId = msgQueue.getActiveTurnId(threadId)
509
+ if (activeTurnId) {
510
+ await api.interruptTurn(threadId, activeTurnId).catch(err =>
511
+ log(`interrupt failed for ${threadId}/${activeTurnId}: ${err}`))
512
+ }
513
+ }
514
+
515
+ const turnRes = await api.createTurn(threadId, {
516
+ prompt,
517
+ auto_approve: true,
518
+ mode: 'yolo',
519
+ })
520
+
521
+ msgQueue.setActiveTurn(threadId, turnRes.turn.id)
522
+ log(`injected turn ${turnRes.turn.id} for thread "${sessionName}" (${threadId})`)
523
+ } catch (err) {
524
+ log(`injectMessage failed for thread ${threadId}: ${err}`)
525
+ msgQueue.clearActiveTurn(threadId)
526
+ }
527
+ }
528
+
529
+ // ── Control command handlers ────────────────────────────────────────────────
530
+
531
+ async function handleHi(sessionName: string, chatId: string, workDir: string): Promise<void> {
532
+ // Check if we already have a thread for this session
533
+ let threadId = threadMap.getThreadId(sessionName)
534
+
535
+ if (threadId) {
536
+ // Thread exists — show status
537
+ const threadInfo = await api.getThreadInfo(threadId)
538
+ if (threadInfo) {
539
+ const lines = [
540
+ `🟢 DeepSeek 已在运行`,
541
+ `session: ${sessionName}`,
542
+ `thread: ${threadInfo.id}`,
543
+ `workspace: ${threadInfo.workspace}`,
544
+ `status: ${threadInfo.archived ? '已归档 (发送 hi 创建新会话)' : '活跃'}`,
545
+ ]
546
+ if (threadInfo.archived) {
547
+ lines.push('')
548
+ lines.push('该会话已归档。发送 "hi" 将创建新的会话。')
549
+ // Clear the mapping for archived threads so next hi creates a new one
550
+ threadMap.removeThreadId(sessionName)
551
+ threadId = undefined
552
+ }
553
+ await sendTextMessage(chatId, lines.join('\n'))
554
+ } else {
555
+ // Thread may have been deleted — clear mapping
556
+ threadMap.removeThreadId(sessionName)
557
+ threadId = undefined
558
+ }
559
+ if (threadId) return
560
+ }
561
+
562
+ // No thread — create a new one
563
+ const isNewProject = !existsSync(workDir)
564
+ if (isNewProject) {
565
+ await sendTextMessage(chatId, `🆕 目录 ~/${sessionName} 不存在,正在创建新项目...`)
566
+ try {
567
+ provisionProject(workDir)
568
+ } catch (e) {
569
+ await sendTextMessage(chatId, `❌ 创建项目失败: ${e}`)
570
+ log(`provisionProject failed for ${workDir}: ${e}`)
571
+ return
572
+ }
573
+ }
574
+
575
+ const groupName = chatNameCache.get(chatId) ?? sessionName
576
+ const systemPrompt = buildSystemPrompt(groupName, chatId, workDir)
577
+
578
+ try {
579
+ const thread = await api.createThread({
580
+ title: sessionName,
581
+ workspace: workDir,
582
+ mode: 'yolo',
583
+ auto_approve: true,
584
+ system_prompt: systemPrompt,
585
+ model: '', // use default from config.toml
586
+ })
587
+
588
+ threadMap.setThreadId(sessionName, thread.id)
589
+ log(`created thread ${thread.id} for session "${sessionName}"`)
590
+
591
+ // Initialize SSE stream buffer so model output flows to feishu
592
+ initStreamBuffer(thread.id, chatId)
593
+
594
+ // Start SSE stream for this thread
595
+ sseHandler.connect(thread.id).catch(err =>
596
+ log(`SSE connect failed for ${thread.id}: ${err}`))
597
+
598
+ const sent = await sendTextMessage(chatId, [
599
+ `✅ DeepSeek 已就绪,可以开始对话`,
600
+ `session: ${sessionName}`,
601
+ `workspace: ${workDir}`,
602
+ isNewProject ? `已初始化 git 仓库` : '',
603
+ '',
604
+ `指令: hi 状态 | restart 重启 | kill 关闭 | clear 清上下文`,
605
+ ].filter(Boolean).join('\n'))
606
+ log(`sent greeting to ${chatId}: ${sent ? 'OK' : 'FAIL'}`)
607
+ } catch (err) {
608
+ log(`createThread failed for "${sessionName}": ${err}`)
609
+ await sendTextMessage(chatId, `❌ 启动失败: ${err}`)
610
+ }
611
+ }
612
+
613
+ async function handleKill(sessionName: string, chatId: string): Promise<void> {
614
+ const threadId = threadMap.getThreadId(sessionName)
615
+ if (!threadId) {
616
+ await sendTextMessage(chatId, `⚠️ 没有运行中的会话: ${sessionName}`)
617
+ return
618
+ }
619
+
620
+ try {
621
+ await api.archiveThread(threadId)
622
+ threadMap.removeThreadId(sessionName)
623
+ sseHandler.disconnect(threadId)
624
+ msgQueue.clearThread(threadId)
625
+ streamBuffers.delete(threadId)
626
+ await sendTextMessage(chatId, `🔴 会话已关闭 (session: ${sessionName})`)
627
+ log(`archived thread ${threadId} for session "${sessionName}"`)
628
+ } catch (err) {
629
+ log(`archiveThread failed for ${threadId}: ${err}`)
630
+ await sendTextMessage(chatId, `⚠️ 关闭失败: ${err}`)
631
+ }
632
+ }
633
+
634
+ async function handleRestart(sessionName: string, chatId: string, workDir: string): Promise<void> {
635
+ const threadId = threadMap.getThreadId(sessionName)
636
+ if (!threadId) {
637
+ // No thread — treat like hi (fresh start)
638
+ await handleHi(sessionName, chatId, workDir)
639
+ return
640
+ }
641
+
642
+ try {
643
+ // Interrupt any active turn first
644
+ const activeTurnId = msgQueue.getActiveTurnId(threadId)
645
+ if (activeTurnId) {
646
+ await api.interruptTurn(threadId, activeTurnId).catch(() => {})
647
+ msgQueue.clearActiveTurn(threadId)
648
+ }
649
+
650
+ // Fork: creates a new thread preserving the session context
651
+ const newThread = await api.forkThread(threadId)
652
+
653
+ // Archive the old thread
654
+ await api.archiveThread(threadId).catch(err =>
655
+ log(`archive old thread failed: ${err}`))
656
+
657
+ // Clean up old
658
+ sseHandler.disconnect(threadId)
659
+ msgQueue.clearThread(threadId)
660
+ streamBuffers.delete(threadId)
661
+
662
+ // Set up new
663
+ threadMap.setThreadId(sessionName, newThread.id)
664
+ initStreamBuffer(newThread.id, chatId)
665
+ sseHandler.connect(newThread.id).catch(err =>
666
+ log(`SSE connect failed for ${newThread.id}: ${err}`))
667
+
668
+ await sendTextMessage(chatId, `✅ 会话已重启 (session: ${sessionName})`)
669
+ log(`restarted session "${sessionName}": ${threadId} → ${newThread.id}`)
670
+ } catch (err) {
671
+ log(`handleRestart failed for "${sessionName}": ${err}`)
672
+ await sendTextMessage(chatId, `❌ 重启失败: ${err}`)
673
+ }
674
+ }
675
+
676
+ async function handleClear(sessionName: string, chatId: string, workDir: string): Promise<void> {
677
+ const oldThreadId = threadMap.getThreadId(sessionName)
678
+ if (!oldThreadId) {
679
+ await sendTextMessage(chatId, `⚠️ 没有运行中的会话: ${sessionName}`)
680
+ return
681
+ }
682
+
683
+ try {
684
+ // Archive old thread
685
+ await api.archiveThread(oldThreadId).catch(err => log(`archive old thread failed: ${err}`))
686
+ sseHandler.disconnect(oldThreadId)
687
+ msgQueue.clearThread(oldThreadId)
688
+ streamBuffers.delete(oldThreadId)
31
689
 
32
- mkdirSync(dirname(PID_FILE), { recursive: true })
33
- writeFileSync(PID_FILE, String(process.pid))
690
+ // Create fresh thread with same workspace + system_prompt
691
+ const groupName = chatNameCache.get(chatId) ?? sessionName
692
+ const systemPrompt = buildSystemPrompt(groupName, chatId, workDir)
34
693
 
35
- const cleanup = () => { try { unlinkSync(PID_FILE) } catch {} }
36
- process.on('exit', cleanup)
37
- process.on('SIGTERM', () => { log('SIGTERM'); cleanup(); process.exit(0) })
38
- process.on('SIGINT', () => { log('SIGINT'); cleanup(); process.exit(0) })
39
- process.on('unhandledRejection', e => log(`unhandledRejection: ${e}`))
40
- process.on('uncaughtException', e => log(`uncaughtException: ${e}`))
694
+ const newThread = await api.createThread({
695
+ title: sessionName,
696
+ workspace: workDir,
697
+ mode: 'yolo',
698
+ auto_approve: true,
699
+ system_prompt: systemPrompt,
700
+ model: '',
701
+ })
41
702
 
42
- // ── Session registry ────────────────────────────────────────────────────
43
- const sessions = new Map<string, Session>() // key = chatId
703
+ threadMap.setThreadId(sessionName, newThread.id)
704
+ initStreamBuffer(newThread.id, chatId)
705
+ sseHandler.connect(newThread.id).catch(err =>
706
+ log(`SSE connect failed for ${newThread.id}: ${err}`))
44
707
 
45
- function sessionFor(chatId: string, sessionName: string): Session {
46
- let s = sessions.get(chatId)
47
- if (!s) {
48
- s = new Session(sessionName, chatId)
49
- sessions.set(chatId, s)
708
+ await sendTextMessage(chatId, `🧹 上下文已清空,全新会话开始 (session: ${sessionName})`)
709
+ log(`cleared session "${sessionName}": ${oldThreadId} → ${newThread.id}`)
710
+ } catch (err) {
711
+ log(`handleClear failed for "${sessionName}": ${err}`)
712
+ await sendTextMessage(chatId, `⚠️ 清理失败: ${err}`)
50
713
  }
51
- return s
52
714
  }
53
715
 
54
- // ── Inbound message handler ─────────────────────────────────────────────
716
+ // ── Card action handler (permission approve/deny) ───────────────────────────
717
+
718
+ const processedActions = new Set<string>()
719
+
720
+ async function handleCardAction(data: any): Promise<any> {
721
+ const action = data?.action
722
+ const value = action?.value
723
+ if (!value?.action || !value?.request_id) return
724
+
725
+ const requestId = value.request_id as string
726
+ const behavior = value.action as string // 'allow' or 'deny'
727
+
728
+ const dedup = `${requestId}:${behavior}`
729
+ if (processedActions.has(dedup)) return
730
+ processedActions.add(dedup)
731
+ if (processedActions.size > 100) {
732
+ const arr = [...processedActions]
733
+ for (let i = 0; i < 50; i++) processedActions.delete(arr[i])
734
+ }
735
+
736
+ log(`card action: ${behavior} ${requestId}`)
737
+
738
+ try {
739
+ if (behavior === 'allow' || behavior === 'deny') {
740
+ await api.handleApproval(requestId, behavior)
741
+ }
742
+ } catch (err) {
743
+ log(`handleApproval failed for ${requestId}: ${err}`)
744
+ }
745
+
746
+ // Build a response card (simplified — toast confirmation)
747
+ const resultText = behavior === 'allow' ? '✅ 已批准' : '❌ 已拒绝'
748
+ return {
749
+ toast: {
750
+ type: behavior === 'allow' ? 'success' : 'error',
751
+ content: resultText,
752
+ },
753
+ }
754
+ }
755
+
756
+ // ── Event handler ────────────────────────────────────────────────────────────
757
+
758
+ const processedMessages = new Set<string>()
55
759
  const STALE_THRESHOLD_MS = 10_000
56
- const seenMessageIds = new Set<string>()
57
760
 
58
761
  async function handleMessage(data: any): Promise<void> {
59
762
  const message = data?.message
60
763
  if (!message) return
61
764
 
62
- const msgId = message.message_id as string | undefined
63
- if (msgId && seenMessageIds.has(msgId)) return
765
+ const msgId = message.message_id
766
+ if (msgId && processedMessages.has(msgId)) return
64
767
  if (msgId) {
65
- seenMessageIds.add(msgId)
66
- if (seenMessageIds.size > 200) {
67
- const arr = [...seenMessageIds]
68
- seenMessageIds.clear()
69
- for (const id of arr.slice(-100)) seenMessageIds.add(id)
768
+ processedMessages.add(msgId)
769
+ if (processedMessages.size > 100) {
770
+ const arr = [...processedMessages]
771
+ for (let i = 0; i < 50; i++) processedMessages.delete(arr[i])
70
772
  }
71
773
  }
72
774
 
73
- // Drop replays of stale messages (Lark redelivers unacked events on reconnect).
74
- const createTime = Number(message.create_time ?? 0)
75
- if (createTime > 0 && Date.now() - createTime > STALE_THRESHOLD_MS) {
76
- log(`drop stale message ${msgId} age=${Math.round((Date.now() - createTime) / 1000)}s`)
77
- if (msgId) void feishu.addReaction(msgId, 'CrossMark')
775
+ const chatId = message.chat_id
776
+ const msgType = message.message_type
777
+
778
+ // Drop stale messages
779
+ const createTimeMs = Number(message.create_time ?? 0)
780
+ if (createTimeMs > 0) {
781
+ const ageMs = Date.now() - createTimeMs
782
+ if (ageMs > STALE_THRESHOLD_MS) {
783
+ const ageSec = Math.round(ageMs / 1000)
784
+ let preview = `(${msgType})`
785
+ try {
786
+ const obj = JSON.parse(message.content ?? '{}')
787
+ const raw = (msgType === 'text' ? obj.text ?? '' : '').trim()
788
+ if (raw) preview = raw.length > 10 ? raw.slice(0, 10) + '…' : raw
789
+ } catch {}
790
+ log(`dropping stale message ${msgId} from ${chatId}, age=${ageSec}s, preview="${preview}"`)
791
+ void addReaction(msgId, 'CrossMark')
792
+ return
793
+ }
794
+ }
795
+
796
+ // Immediate seen signal
797
+ void addReaction(msgId, 'OK')
798
+
799
+ let contentObj: any = {}
800
+ try { contentObj = JSON.parse(message.content ?? '{}') } catch {}
801
+ const text = (msgType === 'text' ? contentObj.text ?? '' : '').trim()
802
+ const cmd = text.toLowerCase()
803
+
804
+ const isOpt = /^opt[1-5]$/.test(cmd)
805
+ const lsMatch = cmd.match(/^ls(?:\s+(.+))?$/)
806
+ const isLs = !!lsMatch
807
+ const isControl = cmd === 'hi' || cmd === 'restart' || cmd === 'kill' || cmd === 'clear' || isOpt || isLs
808
+
809
+ // Non-control messages: inject as turn
810
+ if (!isControl) {
811
+ const groupName = chatNameCache.get(chatId)
812
+ if (groupName) bindSessionToChat(groupName, chatId)
813
+
814
+ const sessionName = groupName ? sanitizeSessionName(groupName) : null
815
+ const threadId = sessionName ? threadMap.getThreadId(sessionName) : undefined
816
+
817
+ if (!threadId || !sessionName) {
818
+ log(`no thread for message from chat ${chatId} (group: ${groupName ?? 'unknown'})`)
819
+ return
820
+ }
821
+
822
+ // Download attachments
823
+ let filePath: string | undefined
824
+ if (msgType === 'image' && contentObj.image_key) {
825
+ filePath = await downloadAttachment(message.message_id, contentObj.image_key, 'image')
826
+ } else if (msgType === 'file' && contentObj.file_key) {
827
+ filePath = await downloadAttachment(message.message_id, contentObj.file_key, 'file', contentObj.file_name)
828
+ }
829
+
830
+ const expandedText = expandSlashAlias(text)
831
+ if (expandedText !== text) log(`expanded slash alias: "${text}" → "${expandedText}"`)
832
+
833
+ const msg: QueuedMessage = {
834
+ chatId,
835
+ messageId: message.message_id,
836
+ sender: data.sender?.sender_id?.open_id ?? 'unknown',
837
+ text: expandedText || `(${msgType})`,
838
+ filePath,
839
+ timestamp: Date.now(),
840
+ }
841
+
842
+ // Inject immediately if no active turn, otherwise interrupt + inject
843
+ injectMessage(threadId, msg)
844
+
845
+ // Stamp last user message time for dashboard
846
+ try { writeFileSync(join(STATE_DIR, 'last_user_message'), String(Math.floor(Date.now() / 1000))) } catch {}
78
847
  return
79
848
  }
80
- if (msgId) void feishu.addReaction(msgId, 'OK')
81
849
 
82
- const chatId = message.chat_id as string
83
- let groupName = feishu.chatNameCache.get(chatId)
850
+ // Control commands below: only text messages
851
+ if (msgType !== 'text') return
852
+
853
+ // Resolve group name
854
+ let groupName = chatNameCache.get(chatId)
84
855
  if (!groupName) {
85
- await feishu.refreshChatList()
86
- groupName = feishu.chatNameCache.get(chatId)
856
+ await refreshChatList()
857
+ groupName = chatNameCache.get(chatId)
87
858
  }
88
859
  if (!groupName) {
89
- log(`unknown chat ${chatId}, dropping message`)
90
- await feishu.sendText(chatId, '❌ 无法识别群名,请确认机器人已加入并稍后重试')
860
+ await new Promise(r => setTimeout(r, 2000))
861
+ await refreshChatList()
862
+ groupName = chatNameCache.get(chatId)
863
+ }
864
+ if (!groupName) {
865
+ try {
866
+ const res = await client.im.chat.get({ path: { chat_id: chatId } })
867
+ groupName = res.data?.name ?? undefined
868
+ if (groupName) chatNameCache.set(chatId, groupName)
869
+ } catch {}
870
+ }
871
+ if (!groupName) {
872
+ log(`received "${cmd}" from unknown group ${chatId}, cannot resolve name`)
873
+ await sendTextMessage(chatId, '❌ 无法识别群名,请确认机器人已加入群聊')
91
874
  return
92
875
  }
93
- const sessionName = feishu.sanitizeSessionName(groupName)
94
- feishu.bindSessionToChat(sessionName, chatId)
95
- const session = sessionFor(chatId, sessionName)
96
876
 
97
- let contentObj: any = {}
98
- try { contentObj = JSON.parse(message.content ?? '{}') } catch {}
99
- const msgType = message.message_type as string
100
- let text = (msgType === 'text' ? contentObj.text ?? '' : '').trim()
877
+ const sessionName = sanitizeSessionName(groupName)
878
+ const workDir = join(PROJECTS_ROOT, sessionName)
879
+
880
+ bindSessionToChat(sessionName, chatId)
881
+ log(`received "${cmd}" in group "${groupName}" (${chatId})`)
101
882
 
102
- // Text-only control commands — intercept before any work that would
103
- // forward to Claude (download / spawn / interrupt). Exact match,
104
- // case-insensitive: `hi` `kill` `restart` `clear`. Bare words are
105
- // reserved globally by user request — typing "hi" as a literal
106
- // greeting will trigger the dashboard, not reach Claude.
107
- if (msgType === 'text' && text) {
108
- if (await session.runCommand(text)) return
883
+ if (cmd === 'hi') {
884
+ await handleHi(sessionName, chatId, workDir)
885
+ } else if (cmd === 'kill') {
886
+ await handleKill(sessionName, chatId)
887
+ } else if (cmd === 'restart') {
888
+ await handleRestart(sessionName, chatId, workDir)
889
+ } else if (cmd === 'clear') {
890
+ await handleClear(sessionName, chatId, workDir)
891
+ } else if (isOpt) {
892
+ // opt1~5 — steer to active turn
893
+ const threadId = threadMap.getThreadId(sessionName)
894
+ if (!threadId || !msgQueue.hasActiveTurn(threadId)) {
895
+ await sendTextMessage(chatId, `⚠️ 没有活跃的会话可以追加选项`)
896
+ return
897
+ }
898
+ const turnId = msgQueue.getActiveTurnId(threadId)!
899
+ try {
900
+ await api.steerTurn(threadId, turnId, text)
901
+ await sendTextMessage(chatId, `✅ 已追加选项: ${text}`)
902
+ } catch (err) {
903
+ log(`steerTurn failed: ${err}`)
904
+ }
905
+ } else if (isLs) {
906
+ const subPath = lsMatch?.[1] ?? ''
907
+ const target = subPath ? join(workDir, subPath) : workDir
908
+ if (!existsSync(target)) {
909
+ await sendTextMessage(chatId, `❌ 路径不存在: ${subPath || '~/' + sessionName}`)
910
+ return
911
+ }
912
+ const tree = renderProjectTreeBody(target)
913
+ const header = subPath
914
+ ? `📁 ~/${sessionName}/${subPath}`
915
+ : `📁 ~/${sessionName}`
916
+ await sendTextMessage(chatId, `${header}\n\`\`\`\n${tree}\n\`\`\``)
109
917
  }
918
+ }
919
+
920
+ // ── PID management ───────────────────────────────────────────────────────────
921
+
922
+ function writePid(): void {
923
+ mkdirSync(STATE_DIR, { recursive: true })
924
+ writeFileSync(PID_FILE, String(process.pid))
925
+ }
926
+
927
+ function cleanPid(): void {
928
+ try { unlinkSync(PID_FILE) } catch {}
929
+ }
110
930
 
111
- let filePath: string | undefined
112
- if (msgType === 'image' && contentObj.image_key) {
113
- filePath = await feishu.downloadAttachment(message.message_id, contentObj.image_key, 'image')
114
- } else if (msgType === 'file' && contentObj.file_key) {
115
- filePath = await feishu.downloadAttachment(message.message_id, contentObj.file_key, 'file', contentObj.file_name)
116
- if (!text) text = `(file: ${contentObj.file_name})`
931
+ try {
932
+ const existingPid = readFileSync(PID_FILE, 'utf8').trim()
933
+ try {
934
+ process.kill(Number(existingPid), 0)
935
+ console.error(`feishu-daemon: already running (pid ${existingPid}). Use "kill ${existingPid}" to stop it first.`)
936
+ process.exit(1)
937
+ } catch {}
938
+ } catch {}
939
+
940
+ // ── Boot ─────────────────────────────────────────────────────────────────────
941
+
942
+ process.on('unhandledRejection', err => log(`unhandled rejection: ${err}`))
943
+ process.on('uncaughtException', err => log(`uncaught exception: ${err}`))
944
+
945
+ process.on('SIGTERM', () => {
946
+ log('received SIGTERM, shutting down')
947
+ sseHandler.disconnectAll()
948
+ stopEmailWorker()
949
+ cleanPid()
950
+ process.exit(0)
951
+ })
952
+
953
+ process.on('SIGINT', () => {
954
+ log('received SIGINT, shutting down')
955
+ sseHandler.disconnectAll()
956
+ stopEmailWorker()
957
+ cleanPid()
958
+ process.exit(0)
959
+ })
960
+
961
+ function ensureStateDir(): void {
962
+ mkdirSync(STATE_DIR, { recursive: true })
963
+ mkdirSync(INBOX_DIR, { recursive: true })
964
+ mkdirSync(join(STATE_DIR, 'messages'), { recursive: true }) // for backward compat
965
+
966
+ // Install feishu-mcp.ts to state dir if not present
967
+ const mcpDest = join(STATE_DIR, 'feishu-mcp.ts')
968
+ const mcpSrc = join(dirname(new URL(import.meta.url).pathname), 'feishu-mcp.ts')
969
+ if (!existsSync(mcpDest) && existsSync(mcpSrc)) {
970
+ try {
971
+ copyFileSync(mcpSrc, mcpDest)
972
+ log('installed feishu-mcp.ts to state dir')
973
+ } catch (err) {
974
+ log(`install feishu-mcp.ts failed: ${err}`)
975
+ }
117
976
  }
977
+ }
978
+
979
+ // ── WS reconnect with exponential backoff ───────────────────────────────────
118
980
 
119
- if (!text && !filePath) return
120
- await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [])
981
+ // Module-level pong tracker (shared across WS reconnects, single watchdog timer)
982
+ let lastPongAt = Date.now()
983
+ let watchdogTimer: ReturnType<typeof setInterval> | null = null
984
+
985
+ function startWatchdog(): void {
986
+ if (watchdogTimer) return // only one timer
987
+ watchdogTimer = setInterval(() => {
988
+ const idle = Date.now() - lastPongAt
989
+ if (idle > 180 * 1000) {
990
+ log(`[watchdog] no WS pong for ${Math.round(idle / 1000)}s, triggering reconnect`)
991
+ reconnectWs()
992
+ }
993
+ }, 30 * 1000)
121
994
  }
122
995
 
123
- // ── Card action handler ────────────────────────────────────────────────
124
- async function handleCardAction(data: any): Promise<any> {
125
- const action = data?.action
126
- const value = action?.value
127
- if (!value?.kind) return
128
- const chatId = data?.context?.open_chat_id ?? ''
129
- const userId = data?.operator?.open_id ?? ''
130
- const session = sessions.get(chatId)
131
- if (!session) return { toast: { type: 'error', content: '会话不存在,请先发消息启动' } }
132
-
133
- switch (value.kind) {
134
- case 'permission':
135
- await session.onPermissionDecision(value.request_id, value.decision, userId)
136
- return { toast: { type: value.decision === 'deny' ? 'error' : 'success', content: '已处理' } }
137
- case 'console':
138
- await session.onConsoleAction(value.action)
139
- return { toast: { type: 'info', content: value.action } }
140
- case 'menu':
141
- await session.onUserMessage(`(menu choice ${value.choice + 1})`)
142
- return { toast: { type: 'success', content: 'OK' } }
143
- }
144
- return { toast: { type: 'info', content: 'unknown action' } }
145
- }
146
-
147
- // ── WebSocket boot ─────────────────────────────────────────────────────
148
- function fmt(m: any[]): string {
149
- return m.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')
150
- }
151
-
152
- async function boot(): Promise<void> {
153
- log(`lodestar-daemon: pid ${process.pid} starting`)
154
- feishu.loadSessionChatMap()
155
- await feishu.refreshChatList()
156
- setInterval(() => { void feishu.refreshChatList() }, 5 * 60 * 1000)
157
-
158
- // Lark WSClient sends pings every ~120s but doesn't verify pongs. On a
159
- // half-open TCP (NAT idle-kill, network blip) the socket stays OPEN and
160
- // 'close' never fires — we'd go silently deaf. Stamp every inbound pong
161
- // and exit(1) after 180s of silence so systemd reconnects us.
162
- let lastPongAt = Date.now()
996
+ function createWsClient(): { wsClient: lark.WSClient; dispatcher: lark.EventDispatcher } {
997
+ const fmt = (msg: any[]) =>
998
+ msg.map(m => typeof m === 'string' ? m : JSON.stringify(m)).join(' ')
999
+
163
1000
  const wsLogger = {
164
- error: (m: any[]) => log(`[ws-sdk error] ${fmt(m)}`),
165
- warn: (m: any[]) => log(`[ws-sdk warn] ${fmt(m)}`),
166
- info: (m: any[]) => log(`[ws-sdk] ${fmt(m)}`),
167
- debug: (_m: any[]) => { /* drop */ },
168
- trace: (m: any[]) => {
169
- if (Array.isArray(m) && m[0] === '[ws]' && m[1] === 'receive pong') {
1001
+ error: (msg: any[]) => log(`[ws-sdk error] ${fmt(msg)}`),
1002
+ warn: (msg: any[]) => log(`[ws-sdk warn] ${fmt(msg)}`),
1003
+ info: (msg: any[]) => log(`[ws-sdk] ${fmt(msg)}`),
1004
+ debug: (_msg: any[]) => {},
1005
+ trace: (msg: any[]) => {
1006
+ if (Array.isArray(msg) && msg[0] === '[ws]' && msg[1] === 'receive pong') {
170
1007
  lastPongAt = Date.now()
171
1008
  }
172
1009
  },
173
1010
  }
174
- setInterval(() => {
175
- const idle = Date.now() - lastPongAt
176
- if (idle > 180_000) {
177
- log(`[watchdog] no WS pong for ${Math.round(idle / 1000)}s — exit for systemd restart`)
178
- process.exit(1)
179
- }
180
- }, 30_000)
181
1011
 
182
- const ws = new lark.WSClient({
183
- appId: config.feishu.app_id,
184
- appSecret: config.feishu.app_secret,
185
- loggerLevel: lark.LoggerLevel.trace,
1012
+ const wsClient = new lark.WSClient({
1013
+ appId: APP_ID!,
1014
+ appSecret: APP_SECRET!,
1015
+ loggerLevel: lark.LoggerLevel.info,
186
1016
  logger: wsLogger,
187
1017
  })
1018
+
188
1019
  const dispatcher = new lark.EventDispatcher({})
1020
+
189
1021
  dispatcher.register({
190
- 'im.message.receive_v1': async (d: any) => {
191
- try { await handleMessage(d) } catch (e) { log(`handleMessage: ${e}`) }
1022
+ 'im.message.receive_v1': async (data: any) => {
1023
+ try { await handleMessage(data) } catch (err) {
1024
+ log(`handler error: ${err}`)
1025
+ }
192
1026
  },
193
1027
  })
1028
+
194
1029
  dispatcher.register({
195
- 'card.action.trigger': async (d: any) => {
196
- try { return await handleCardAction(d) } catch (e) { log(`handleCardAction: ${e}`) }
1030
+ 'card.action.trigger': async (data: any) => {
1031
+ try { return await handleCardAction(data) } catch (err) {
1032
+ log(`card action error: ${err}`)
1033
+ }
197
1034
  },
198
1035
  })
199
- ws.start({ eventDispatcher: dispatcher })
200
- log(`lodestar-daemon: WS started, watching ${feishu.chatNameCache.size} groups`)
1036
+
1037
+ return { wsClient, dispatcher }
1038
+ }
1039
+
1040
+ let wsClientInstance: lark.WSClient | null = null
1041
+ let wsReconnectAttempt = 0
1042
+ let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null
1043
+
1044
+ function startWs(): void {
1045
+ startWatchdog()
1046
+ const { wsClient, dispatcher } = createWsClient()
1047
+ wsClientInstance = wsClient
1048
+ wsClient.start({ eventDispatcher: dispatcher })
1049
+ wsReconnectAttempt = 0
1050
+ lastPongAt = Date.now()
1051
+ log(`WS connected, watching ${chatNameCache.size} groups`)
1052
+ }
1053
+
1054
+ function reconnectWs(): void {
1055
+ if (wsReconnectTimer) return // already scheduled
1056
+
1057
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (cap)
1058
+ wsReconnectAttempt++
1059
+ const delay = Math.min(1000 * Math.pow(2, wsReconnectAttempt - 1), 60_000)
1060
+
1061
+ log(`WS reconnect attempt ${wsReconnectAttempt} in ${delay}ms`)
1062
+
1063
+ // After 5 minutes of failed reconnects, send Feishu notification
1064
+ if (wsReconnectAttempt >= 8) {
1065
+ for (const [chatId] of chatNameCache) {
1066
+ sendTextMessage(chatId, '⚠️ 飞书连接不稳定,正在重连...').catch(() => {})
1067
+ }
1068
+ }
1069
+
1070
+ wsReconnectTimer = setTimeout(() => {
1071
+ wsReconnectTimer = null
1072
+ try {
1073
+ if (wsClientInstance) {
1074
+ try { (wsClientInstance as any).stop?.() } catch {}
1075
+ }
1076
+ startWs()
1077
+ } catch (err) {
1078
+ log(`WS reconnect failed: ${err}`)
1079
+ // Schedule next attempt
1080
+ reconnectWs()
1081
+ }
1082
+ }, delay)
1083
+ }
1084
+
1085
+ async function boot() {
1086
+ writePid()
1087
+ ensureStateDir()
1088
+ log('starting...')
1089
+
1090
+ // Write runtime token for utility scripts
1091
+ try {
1092
+ const tokenFile = join(STATE_DIR, '.runtime_token')
1093
+ if (API_TOKEN) writeFileSync(tokenFile, API_TOKEN)
1094
+ } catch {}
1095
+
1096
+ // Inject feishu MCP config into ~/.deepseek/mcp.json
1097
+ try {
1098
+ injectFeishuMcpConfig()
1099
+ } catch (err) {
1100
+ log(`mcp config inject failed: ${err}`)
1101
+ }
1102
+
1103
+ loadSessionChatMap()
1104
+
1105
+ // Check Runtime API health
1106
+ const apiReady = await api.healthCheck()
1107
+ if (!apiReady) {
1108
+ log('WARNING: deepseek serve --http not reachable at ' + API_URL)
1109
+ log(' daemon will start but threads cannot be created until serve is up')
1110
+ log(' start with: deepseek serve --http --port 7878')
1111
+ } else {
1112
+ log(`deepseek serve --http ready at ${API_URL}`)
1113
+ }
1114
+
1115
+ await refreshChatList()
1116
+ setInterval(() => {
1117
+ void refreshChatList()
1118
+ }, 5 * 60 * 1000)
1119
+
1120
+ // Reconnect SSE for existing threads
1121
+ for (const [sessionName, threadId] of threadMap.entries()) {
1122
+ sseHandler.connect(threadId).catch(err =>
1123
+ log(`SSE reconnect failed for ${threadId}: ${err}`))
1124
+ }
1125
+
1126
+ // Start email worker
1127
+ startEmailWorker(log, sendTextMessage, chatNameCache).catch(err =>
1128
+ log(`email-worker boot failed: ${err}`))
1129
+
1130
+ // Start status dashboard
1131
+ startStatusDashboard(log)
1132
+
1133
+ // Start WS connection
1134
+ startWs()
1135
+
1136
+ log(`daemon running (pid ${process.pid}), watching for messages in ${chatNameCache.size} groups...`)
201
1137
  }
202
1138
 
203
- boot().catch(e => { log(`boot fatal: ${e}`); process.exit(1) })
1139
+ boot().catch(err => { log(`fatal: ${err}`); process.exit(1) })