@leviyuan/lodestar 0.2.8 → 0.3.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": "@leviyuan/lodestar",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -28,23 +28,32 @@
28
28
  "lodestar"
29
29
  ],
30
30
  "scripts": {
31
- "start": "bun daemon.ts"
31
+ "start": "bun daemon.ts",
32
+ "build:daemon": "bun build cli.ts --target=node --minify --banner='#!/usr/bin/env node' --outfile=dist/lodestar.js",
33
+ "build:setup": "bun build src/setup-cli.ts --target=node --minify --banner='#!/usr/bin/env node' --outfile=dist/lodestar-setup.js",
34
+ "build": "bun run build:daemon && bun run build:setup",
35
+ "prepublishOnly": "bun run build",
36
+ "postinstall": "node scripts/postinstall.cjs"
32
37
  },
33
38
  "bin": {
34
- "lodestar-daemon": "./daemon.ts"
39
+ "lodestar-daemon": "./dist/lodestar.js",
40
+ "lodestar-setup": "./dist/lodestar-setup.js"
35
41
  },
36
42
  "files": [
37
- "daemon.ts",
38
- "src/",
43
+ "dist/",
44
+ "scripts/postinstall.cjs",
39
45
  "README.md",
40
46
  "LICENSE"
41
47
  ],
42
48
  "engines": {
43
- "bun": ">=1.0.0"
49
+ "node": ">=18"
44
50
  },
45
51
  "dependencies": {
46
52
  "@larksuiteoapi/node-sdk": "^1.44.0"
47
53
  },
54
+ "peerDependencies": {
55
+ "@anthropic-ai/claude-code": "*"
56
+ },
48
57
  "devDependencies": {
49
58
  "@types/bun": "latest",
50
59
  "typescript": "^5.5.0"
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // Auto-launch lodestar-setup right after `npm i -g`. Tricky because npm
3
+ // 7+ default pipes postinstall stdio into its own log — `console.log`
4
+ // is invisible, plain `stdio:'inherit'` spawn inherits the same pipe.
5
+ // Two escape routes here:
6
+ //
7
+ // • Windows: spawn `cmd /c start "" cmd /k node <bundle>` to open a
8
+ // NEW console window. The new window has its own real terminal,
9
+ // completely outside npm's pipe; `/k` keeps the window open after
10
+ // the wizard exits so the user can read the success message.
11
+ //
12
+ // • Linux/macOS: fs.openSync('/dev/tty','r+') gives a fd attached to
13
+ // the user's real terminal. Pass it as the spawned child's stdio
14
+ // and it inherits the terminal directly, bypassing npm.
15
+ //
16
+ // Banner output goes to the same terminal device, not console.log, so
17
+ // the user sees "✓ Lodestar 已安装" even though npm captures stdout.
18
+ //
19
+ // If both escape routes fail (no console / CI / weird sandbox), we
20
+ // fall back to a hint, and the daemon entry (cli.ts) auto-triggers the
21
+ // wizard on first run regardless.
22
+
23
+ const { spawn } = require('child_process')
24
+ const fs = require('fs')
25
+ const path = require('path')
26
+
27
+ const setupBundle = path.join(__dirname, '..', 'dist', 'lodestar-setup.js')
28
+
29
+ function termWrite (msg) {
30
+ try {
31
+ if (process.platform === 'win32') {
32
+ const fd = fs.openSync('\\\\.\\CONOUT$', 'w')
33
+ fs.writeSync(fd, msg)
34
+ fs.closeSync(fd)
35
+ } else {
36
+ fs.writeFileSync('/dev/tty', msg)
37
+ }
38
+ } catch {
39
+ // No console accessible — write to stdout (npm likely swallows it).
40
+ try { process.stdout.write(msg) } catch {}
41
+ }
42
+ }
43
+
44
+ function fallback (reason) {
45
+ if (reason) termWrite(` \x1b[2m(${reason})\x1b[0m\n`)
46
+ termWrite(' 下一步: 在 cmd / PowerShell 里跑 \x1b[32mlodestar-daemon\x1b[0m')
47
+ termWrite(' (会自动拉起向导)\n')
48
+ termWrite(' 或者只跑向导: \x1b[32mlodestar-setup\x1b[0m\n\n')
49
+ process.exit(0)
50
+ }
51
+
52
+ termWrite('\n \x1b[1m\x1b[36m✓ Lodestar 已安装\x1b[0m\n\n')
53
+
54
+ // Path 1: npm started us with --foreground-scripts. Our own stdio is
55
+ // already a real TTY. Just inherit-spawn the wizard, same window.
56
+ if (process.stdout.isTTY && process.stdin.isTTY) {
57
+ termWrite(' \x1b[2m启动配置向导...\x1b[0m\n\n')
58
+ const child = spawn(process.execPath, [setupBundle], { stdio: 'inherit' })
59
+ child.on('exit', (code) => process.exit(code == null ? 0 : code))
60
+ child.on('error', (e) => fallback(`spawn 失败: ${e.message}`))
61
+ return
62
+ }
63
+
64
+ // Path 2: Windows. Open a NEW console window — that window is outside
65
+ // npm's stdio pipe and has a real terminal of its own. `cmd /k` keeps
66
+ // it open after the wizard exits so the success message is readable.
67
+ if (process.platform === 'win32') {
68
+ termWrite(' \x1b[2m启动配置向导 (新窗口) ...\x1b[0m\n')
69
+ termWrite(' \x1b[2m向导会在另一个 cmd 窗口里跑, 完成后按 Enter / 输入 exit 关掉它即可。\x1b[0m\n\n')
70
+ try {
71
+ const child = spawn(
72
+ 'cmd.exe',
73
+ ['/c', 'start', 'Lodestar Setup', 'cmd', '/k', process.execPath, setupBundle],
74
+ { detached: true, stdio: 'ignore', windowsHide: false },
75
+ )
76
+ child.unref()
77
+ process.exit(0)
78
+ } catch (e) {
79
+ fallback(`新窗口启动失败: ${e.message}`)
80
+ }
81
+ return
82
+ }
83
+
84
+ // Path 3: Linux / macOS. Open the user's controlling terminal directly
85
+ // as a fd and pass it as child stdio. Bypasses npm's pipe in-place,
86
+ // same terminal window.
87
+ try {
88
+ const ttyFd = fs.openSync('/dev/tty', 'r+')
89
+ termWrite(' \x1b[2m启动配置向导...\x1b[0m\n\n')
90
+ const child = spawn(process.execPath, [setupBundle], {
91
+ stdio: [ttyFd, ttyFd, ttyFd],
92
+ })
93
+ child.on('exit', (code) => process.exit(code == null ? 0 : code))
94
+ child.on('error', (e) => fallback(`spawn 失败: ${e.message}`))
95
+ } catch (e) {
96
+ fallback(`/dev/tty 打不开: ${e.message}`)
97
+ }
package/daemon.ts DELETED
@@ -1,353 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Lodestar 2.0 daemon — Feishu (Lark) ↔ Claude Code headless bridge.
4
- *
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.
8
- *
9
- * Run: bun daemon.ts
10
- * Stop: SIGTERM
11
- */
12
-
13
- import * as lark from '@larksuiteoapi/node-sdk'
14
- import { chmodSync, existsSync, 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 { DEBUG_CTX_FILE, DEBUG_SOCK_FILE, PID_FILE } from './src/paths'
21
-
22
- // ── PID guard ───────────────────────────────────────────────────────────
23
- try {
24
- const existing = readFileSync(PID_FILE, 'utf8').trim()
25
- try {
26
- process.kill(Number(existing), 0)
27
- console.error(`lodestar-daemon: already running (pid ${existing})`)
28
- process.exit(1)
29
- } catch {}
30
- } catch {}
31
-
32
- mkdirSync(dirname(PID_FILE), { recursive: true })
33
- writeFileSync(PID_FILE, String(process.pid))
34
-
35
- const cleanup = () => {
36
- // Snapshot which sessions are still alive so the next boot can
37
- // revive them — only the ones still running at shutdown, NOT
38
- // anything the user already `kill`-ed (those are absent from the
39
- // sessions Map filter below and stay stopped after restart).
40
- try {
41
- const alive: string[] = []
42
- for (const s of sessions.values()) if (s.isRunning()) alive.push(s.sessionName)
43
- feishu.writeAliveMarker(alive)
44
- if (alive.length > 0) log(`alive marker: [${alive.join(', ')}]`)
45
- } catch (e) { log(`alive marker write failed: ${e}`) }
46
- try { unlinkSync(PID_FILE) } catch {}
47
- try { unlinkSync(DEBUG_SOCK_FILE) } catch {}
48
- }
49
- process.on('exit', cleanup)
50
- process.on('SIGTERM', () => { log('SIGTERM'); cleanup(); process.exit(0) })
51
- process.on('SIGINT', () => { log('SIGINT'); cleanup(); process.exit(0) })
52
- process.on('unhandledRejection', e => log(`unhandledRejection: ${e}`))
53
- process.on('uncaughtException', e => log(`uncaughtException: ${e}`))
54
-
55
- // ── Session registry ────────────────────────────────────────────────────
56
- const sessions = new Map<string, Session>() // key = chatId
57
-
58
- function sessionFor(chatId: string, sessionName: string): Session {
59
- let s = sessions.get(chatId)
60
- if (!s) {
61
- s = new Session(sessionName, chatId)
62
- sessions.set(chatId, s)
63
- }
64
- return s
65
- }
66
-
67
- /** Auto-restart any session that was alive when the previous daemon
68
- * went down. Driven by the marker file written in `cleanup` — that
69
- * file ONLY lists sessions that were running, so anything the user
70
- * had explicitly `kill`-ed before shutdown is intentionally absent
71
- * and stays stopped. Each revived session is `restart(true)`-ed so
72
- * the SDK gets `--resume <claudeSessionId>` and the in-flight
73
- * conversation continues without the user typing anything. */
74
- async function reviveAliveSessions(): Promise<void> {
75
- const names = feishu.readAndConsumeAliveMarker()
76
- if (names.length === 0) return
77
- log(`revive: ${names.length} session(s) marked alive on shutdown: ${names.join(', ')}`)
78
- for (const sessionName of names) {
79
- const chatId = feishu.chatIdForSession(sessionName)
80
- if (!chatId) {
81
- log(`revive: no chatId binding for "${sessionName}", skip`)
82
- continue
83
- }
84
- const session = sessionFor(chatId, sessionName)
85
- try {
86
- await session.restart(true)
87
- log(`revive: spawned "${sessionName}" (chat ${chatId.slice(0, 8)}…)`)
88
- } catch (e) {
89
- log(`revive: restart "${sessionName}" failed: ${e}`)
90
- }
91
- }
92
- }
93
-
94
- // ── Inbound message handler ─────────────────────────────────────────────
95
- const STALE_THRESHOLD_MS = 5_000
96
- const seenMessageIds = new Set<string>()
97
-
98
- async function handleMessage(data: any): Promise<void> {
99
- const message = data?.message
100
- if (!message) return
101
-
102
- // Feishu's im.message.receive_v1 event puts `sender` at the event
103
- // root, sibling of `message` — NOT inside `message` (we had this
104
- // wrong before, which silently emptied userOpenId and skipped every
105
- // urgent_app push). Try root first, fall back to nested in case the
106
- // SDK wraps the payload differently.
107
- const senderId = data?.sender?.sender_id ?? data?.event?.sender?.sender_id ?? message?.sender?.sender_id
108
- const userOpenId: string = senderId?.open_id ?? ''
109
-
110
- const msgId = message.message_id as string | undefined
111
- if (msgId && seenMessageIds.has(msgId)) return
112
- if (msgId) {
113
- seenMessageIds.add(msgId)
114
- if (seenMessageIds.size > 200) {
115
- const arr = [...seenMessageIds]
116
- seenMessageIds.clear()
117
- for (const id of arr.slice(-100)) seenMessageIds.add(id)
118
- }
119
- }
120
-
121
- // Drop replays of stale messages (Lark redelivers unacked events on reconnect).
122
- const createTime = Number(message.create_time ?? 0)
123
- if (createTime > 0 && Date.now() - createTime > STALE_THRESHOLD_MS) {
124
- log(`drop stale message ${msgId} age=${Math.round((Date.now() - createTime) / 1000)}s`)
125
- if (msgId) void feishu.addReaction(msgId, 'CrossMark')
126
- return
127
- }
128
-
129
- const chatId = message.chat_id as string
130
-
131
- // `[DEBUG]` prefix — seed the inject context with the real chat/sender
132
- // captured from a live WS event, then strip the prefix and continue as
133
- // normal. The injector script (scripts/test-inject.ts) reads this
134
- // context to replay arbitrary messages without the user touching Feishu.
135
- let contentObjForDebug: any = {}
136
- try { contentObjForDebug = JSON.parse(message.content ?? '{}') } catch {}
137
- const debugTextRaw = (message.message_type === 'text' ? contentObjForDebug.text ?? '' : '')
138
- if (typeof debugTextRaw === 'string' && debugTextRaw.startsWith('[DEBUG]')) {
139
- try {
140
- writeFileSync(DEBUG_CTX_FILE, JSON.stringify({
141
- chat_id: chatId,
142
- sender_open_id: userOpenId,
143
- seeded_at: new Date().toISOString(),
144
- seeded_msg_id: msgId ?? '',
145
- }, null, 2))
146
- log(`debug: seeded inject context chat=${chatId.slice(0, 8)}… sender=${userOpenId.slice(0, 8)}…`)
147
- } catch (e) { log(`debug: seed context failed: ${e}`) }
148
- const stripped = debugTextRaw.slice('[DEBUG]'.length)
149
- contentObjForDebug.text = stripped
150
- message.content = JSON.stringify(contentObjForDebug)
151
- }
152
-
153
- let groupName = feishu.chatNameCache.get(chatId)
154
- if (!groupName) {
155
- await feishu.refreshChatList()
156
- groupName = feishu.chatNameCache.get(chatId)
157
- }
158
- if (!groupName) {
159
- log(`unknown chat ${chatId}, dropping message`)
160
- await feishu.sendText(chatId, '❌ 无法识别群名,请确认机器人已加入并稍后重试')
161
- return
162
- }
163
- const sessionName = feishu.sanitizeSessionName(groupName)
164
- feishu.bindSessionToChat(sessionName, chatId)
165
- const session = sessionFor(chatId, sessionName)
166
-
167
- let contentObj: any = {}
168
- try { contentObj = JSON.parse(message.content ?? '{}') } catch {}
169
- const msgType = message.message_type as string
170
- let text = (msgType === 'text' ? contentObj.text ?? '' : '').trim()
171
-
172
- // Text-only control commands — intercept before any work that would
173
- // forward to Claude (download / spawn / interrupt). Exact match,
174
- // case-insensitive: `hi` `kill` `restart` `clear`. Bare words are
175
- // reserved globally by user request — typing "hi" as a literal
176
- // greeting will trigger the dashboard, not reach Claude.
177
- if (msgType === 'text' && text) {
178
- if (await session.runCommand(text)) return
179
- }
180
-
181
- // Pending AskUserQuestion: route the message as a custom answer
182
- // instead of opening a new turn. This is how custom-text answers
183
- // work in this version — Feishu schema 2.0 doesn't support form/
184
- // input elements, so the chat box itself is the input. Only applies
185
- // to text-only messages (an image attachment opens a new turn as
186
- // usual). Bare-word commands have already been intercepted above.
187
- if (msgType === 'text' && text && session.hasPendingAsk()) {
188
- if (msgId) void feishu.addReaction(msgId, 'CheckMark')
189
- await session.onAskMessageAnswer(text, userOpenId)
190
- return
191
- }
192
-
193
- let filePath: string | undefined
194
- if (msgType === 'image' && contentObj.image_key) {
195
- filePath = await feishu.downloadAttachment(message.message_id, contentObj.image_key, 'image')
196
- } else if (msgType === 'file' && contentObj.file_key) {
197
- filePath = await feishu.downloadAttachment(message.message_id, contentObj.file_key, 'file', contentObj.file_name)
198
- if (!text) text = `(file: ${contentObj.file_name})`
199
- }
200
-
201
- if (!text && !filePath) return
202
- await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [], userOpenId, msgId ?? '')
203
- }
204
-
205
- // ── Card action handler ────────────────────────────────────────────────
206
- async function handleCardAction(data: any): Promise<any> {
207
- const action = data?.action
208
- const value = action?.value
209
- if (!value?.kind) return
210
- const chatId = data?.context?.open_chat_id ?? ''
211
- const userId = data?.operator?.open_id ?? ''
212
- const session = sessions.get(chatId)
213
- if (!session) return { toast: { type: 'error', content: '会话不存在,请先发消息启动' } }
214
-
215
- switch (value.kind) {
216
- case 'permission':
217
- await session.onPermissionDecision(value.request_id, value.decision, userId)
218
- return { toast: { type: value.decision === 'deny' ? 'error' : 'success', content: '已处理' } }
219
- case 'menu':
220
- await session.onUserMessage(`(menu choice ${value.choice + 1})`)
221
- return { toast: { type: 'success', content: 'OK' } }
222
- case 'ask': {
223
- // Custom-text branch: form submit packages the input under
224
- // `form_value`. Try a couple of plausible keys since the exact
225
- // shape can drift between Feishu schema versions; fall back to
226
- // empty (onAskCustomAnswer ignores blank).
227
- if (value.custom) {
228
- const fv = action?.form_value ?? action?.input ?? {}
229
- const customText: string = fv?.custom_answer ?? action?.input_value ?? ''
230
- await session.onAskCustomAnswer(value.tool_use_id, value.question_idx ?? 0, customText, userId)
231
- return { toast: { type: customText.trim() ? 'success' : 'error', content: customText.trim() ? '已回答' : '请输入答案' } }
232
- }
233
- await session.onAskAnswer(value.tool_use_id, value.question_idx ?? 0, value.option_idx, userId)
234
- return { toast: { type: 'success', content: '已回答' } }
235
- }
236
- }
237
- return { toast: { type: 'info', content: 'unknown action' } }
238
- }
239
-
240
- // ── WebSocket boot ─────────────────────────────────────────────────────
241
- function fmt(m: any[]): string {
242
- return m.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')
243
- }
244
-
245
- // ── Debug message injection ─────────────────────────────────────────────
246
- // Listens on a unix socket so scripts/test-inject.ts can replay messages
247
- // through the same `handleMessage` path that real WS events take. Seeded
248
- // by a one-time `[DEBUG]<anything>` from the real Feishu user; from then
249
- // on the injector reuses that chat_id + sender_open_id.
250
- function startDebugSocket(): void {
251
- try { if (existsSync(DEBUG_SOCK_FILE)) unlinkSync(DEBUG_SOCK_FILE) } catch {}
252
- try {
253
- Bun.serve({
254
- unix: DEBUG_SOCK_FILE,
255
- fetch: async (req: Request) => {
256
- if (req.method !== 'POST') return new Response('use POST', { status: 405 })
257
- let body: any = {}
258
- try { body = await req.json() } catch { return new Response('bad json', { status: 400 }) }
259
- if (!existsSync(DEBUG_CTX_FILE)) {
260
- return new Response('no debug context yet — send `[DEBUG]hi` from Feishu first', { status: 412 })
261
- }
262
- let ctx: any = {}
263
- try { ctx = JSON.parse(readFileSync(DEBUG_CTX_FILE, 'utf8')) } catch (e) {
264
- return new Response(`ctx read failed: ${e}`, { status: 500 })
265
- }
266
- const text: string = String(body.text ?? '')
267
- if (!text) return new Response('text required', { status: 400 })
268
- const fakeMsgId = `om_DEBUG_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
269
- const payload = {
270
- sender: { sender_id: { open_id: ctx.sender_open_id } },
271
- message: {
272
- message_id: fakeMsgId,
273
- chat_id: ctx.chat_id,
274
- message_type: 'text',
275
- content: JSON.stringify({ text }),
276
- create_time: String(Date.now()),
277
- },
278
- }
279
- log(`debug: inject text=${JSON.stringify(text).slice(0, 80)} fake_id=${fakeMsgId}`)
280
- // Don't await — match real WS dispatcher behavior (fire-and-forget per event).
281
- handleMessage(payload).catch(e => log(`debug: handleMessage rejected: ${e}`))
282
- return new Response(JSON.stringify({ ok: true, fake_msg_id: fakeMsgId }), {
283
- headers: { 'content-type': 'application/json' },
284
- })
285
- },
286
- })
287
- try { chmodSync(DEBUG_SOCK_FILE, 0o600) } catch {}
288
- log(`debug: inject socket listening at ${DEBUG_SOCK_FILE}`)
289
- } catch (e) {
290
- log(`debug: socket bind failed: ${e}`)
291
- }
292
- }
293
-
294
- async function boot(): Promise<void> {
295
- log(`lodestar-daemon: pid ${process.pid} starting`)
296
- feishu.loadSessionChatMap()
297
- feishu.loadSessionResumeMap()
298
- await feishu.refreshChatList()
299
- setInterval(() => { void feishu.refreshChatList() }, 5 * 60 * 1000)
300
-
301
- // Lark WSClient sends pings every ~120s but doesn't verify pongs. On a
302
- // half-open TCP (NAT idle-kill, network blip) the socket stays OPEN and
303
- // 'close' never fires — we'd go silently deaf. Stamp every inbound pong
304
- // and exit(1) after 180s of silence so systemd reconnects us.
305
- let lastPongAt = Date.now()
306
- const wsLogger = {
307
- error: (m: any[]) => log(`[ws-sdk error] ${fmt(m)}`),
308
- warn: (m: any[]) => log(`[ws-sdk warn] ${fmt(m)}`),
309
- info: (m: any[]) => log(`[ws-sdk] ${fmt(m)}`),
310
- debug: (_m: any[]) => { /* drop */ },
311
- trace: (m: any[]) => {
312
- if (Array.isArray(m) && m[0] === '[ws]' && m[1] === 'receive pong') {
313
- lastPongAt = Date.now()
314
- }
315
- },
316
- }
317
- setInterval(() => {
318
- const idle = Date.now() - lastPongAt
319
- if (idle > 180_000) {
320
- log(`[watchdog] no WS pong for ${Math.round(idle / 1000)}s — exit for systemd restart`)
321
- process.exit(1)
322
- }
323
- }, 30_000)
324
-
325
- const ws = new lark.WSClient({
326
- appId: config.feishu.app_id,
327
- appSecret: config.feishu.app_secret,
328
- loggerLevel: lark.LoggerLevel.trace,
329
- logger: wsLogger,
330
- })
331
- const dispatcher = new lark.EventDispatcher({})
332
- dispatcher.register({
333
- 'im.message.receive_v1': async (d: any) => {
334
- try { await handleMessage(d) } catch (e) { log(`handleMessage: ${e}`) }
335
- },
336
- })
337
- dispatcher.register({
338
- 'card.action.trigger': async (d: any) => {
339
- try { return await handleCardAction(d) } catch (e) { log(`handleCardAction: ${e}`) }
340
- },
341
- })
342
- ws.start({ eventDispatcher: dispatcher })
343
- log(`lodestar-daemon: WS started, watching ${feishu.chatNameCache.size} groups`)
344
-
345
- startDebugSocket()
346
-
347
- // Auto-revive sessions that were running when we last went down.
348
- // Runs AFTER the WS is up so any 🔁 revive message lands in the
349
- // right chat instead of disappearing into the void.
350
- await reviveAliveSessions()
351
- }
352
-
353
- boot().catch(e => { log(`boot fatal: ${e}`); process.exit(1) })