@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/src/session.ts DELETED
@@ -1,447 +0,0 @@
1
- /**
2
- * Session — 1 Feishu chat ↔ 1 Claude headless process ↔ 1 streaming card.
3
- *
4
- * Owns the ClaudeProcess lifecycle, the per-turn card state machine, and
5
- * the in-flight permission map. Wires Claude's stdout events into Card
6
- * Kit ops, and wires Feishu inbound (text + card-action callbacks) into
7
- * Claude's stdin.
8
- */
9
-
10
- import { existsSync } from 'node:fs'
11
- import { join } from 'node:path'
12
- import { ClaudeProcess, type CanUseToolRequest, type HookCallbackRequest } from './claude-process'
13
- import { CHANNEL_INSTRUCTIONS } from './instructions'
14
- import * as cardkit from './cardkit'
15
- import * as cards from './cards'
16
- import * as feishu from './feishu'
17
- import { log } from './log'
18
- import { INBOX_DIR } from './paths'
19
-
20
- interface TurnState {
21
- cardId: string
22
- userText: string
23
- thinkingText: string
24
- toolCount: number
25
- toolByUseId: Map<string, { i: number; name: string; input: any }>
26
- assistantSegmentCount: number
27
- currentAssistantSegmentId: string | null
28
- currentAssistantText: string
29
- // Per-assistant-segment cumulative text — used at turn close to strip
30
- // [[send: /path]] markers and replace each segment with a cleaned
31
- // version, then post the files as separate Feishu messages.
32
- segmentTexts: Map<string, string>
33
- startedAt: number
34
- }
35
-
36
- const SEND_MARKER_RE = /\[\[send:\s*([^\]\n]+?)\s*\]\]/g
37
-
38
- type Status = 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
39
-
40
- export interface SessionOpts {
41
- permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
42
- }
43
-
44
- export class Session {
45
- private proc: ClaudeProcess | null = null
46
- private currentTurn: TurnState | null = null
47
- private pendingPermissions = new Map<string, { messageId: string; toolName: string }>()
48
- private turnCounter = 0
49
- // Last seen sessionId — preserved across `kill`/`stop` so a later
50
- // `restart` can resume the same Claude conversation even after the
51
- // child process is gone.
52
- private lastSessionId: string | null = null
53
- private startedAt: number = 0
54
- status: Status = 'stopped'
55
-
56
- constructor(
57
- public readonly sessionName: string,
58
- public readonly chatId: string,
59
- private opts: SessionOpts = {},
60
- ) {}
61
-
62
- get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
63
- isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
64
-
65
- // ── Lifecycle ──────────────────────────────────────────────────────
66
- async start(): Promise<boolean> {
67
- if (this.isRunning()) return true
68
- if (!feishu.isAnthropicAuthenticated()) {
69
- await feishu.sendText(this.chatId, '❌ Claude 未登录 Anthropic 账号。\n请在服务器上运行 `claude auth login` 后再试。')
70
- return false
71
- }
72
- if (!existsSync(this.workDir)) {
73
- await feishu.sendText(this.chatId, `🆕 目录 ~/${this.sessionName} 不存在,正在创建…`)
74
- try { feishu.provisionProject(this.workDir) }
75
- catch (e) {
76
- await feishu.sendText(this.chatId, `❌ 创建项目失败: ${e}`)
77
- return false
78
- }
79
- }
80
-
81
- this.status = 'starting'
82
- this.proc = new ClaudeProcess({
83
- workDir: this.workDir,
84
- effort: 'max',
85
- permissionMode: this.opts.permissionMode ?? 'default',
86
- appendSystemPrompt: CHANNEL_INSTRUCTIONS,
87
- })
88
- this.wireProc(this.proc)
89
- this.proc.sendInitialize({})
90
-
91
- await feishu.sendText(this.chatId, `✅ Lodestar session "${this.sessionName}" 已就绪,发消息开始对话。`)
92
- this.status = 'idle'
93
- this.startedAt = Date.now()
94
- return true
95
- }
96
-
97
- async stop(reason = '已终止'): Promise<void> {
98
- if (!this.proc) {
99
- this.status = 'stopped'
100
- await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行`)
101
- return
102
- }
103
- this.lastSessionId = this.proc.sessionId ?? this.lastSessionId
104
- await this.proc.kill()
105
- this.proc = null
106
- this.currentTurn = null
107
- this.pendingPermissions.clear()
108
- this.status = 'stopped'
109
- await feishu.sendText(this.chatId, `🔴 ${reason} (session: ${this.sessionName})`)
110
- }
111
-
112
- async restart(resume = false): Promise<void> {
113
- const prevSessionId = this.proc?.sessionId ?? this.lastSessionId
114
- if (this.proc) {
115
- this.lastSessionId = this.proc.sessionId ?? this.lastSessionId
116
- await this.proc.kill()
117
- this.proc = null
118
- }
119
- this.currentTurn = null
120
- this.pendingPermissions.clear()
121
- if (resume && prevSessionId) {
122
- this.proc = new ClaudeProcess({
123
- workDir: this.workDir,
124
- effort: 'max',
125
- permissionMode: 'default',
126
- resumeSessionId: prevSessionId,
127
- appendSystemPrompt: CHANNEL_INSTRUCTIONS,
128
- })
129
- this.wireProc(this.proc)
130
- this.proc.sendInitialize({})
131
- this.status = 'idle'
132
- this.startedAt = Date.now()
133
- await feishu.sendText(this.chatId, `🔁 已重启并恢复 session=${prevSessionId.slice(0, 8)}…`)
134
- } else {
135
- await this.start()
136
- }
137
- }
138
-
139
- /** Run a bare-text control command (`hi`, `kill`, `restart`, `clear`).
140
- * Returns true if the command was consumed (don't forward to Claude).
141
- * Exact match, case-insensitive, ignores trailing whitespace.
142
- *
143
- * Trade-off (user-confirmed 2026-05-15): the four words are reserved
144
- * globally — typing "hi" as a literal greeting will show the console
145
- * card instead of reaching Claude. The ergonomic win (no slash, no
146
- * shift key, one-handed phone use) outweighs the collision in this
147
- * product's private-bot use case. */
148
- async runCommand(raw: string): Promise<boolean> {
149
- switch (raw.trim().toLowerCase()) {
150
- case 'hi':
151
- if (!this.isRunning()) {
152
- const ok = await this.start()
153
- if (!ok) return true
154
- }
155
- await this.showConsole()
156
- return true
157
- case 'kill':
158
- await this.stop()
159
- return true
160
- case 'restart':
161
- await this.restart(true)
162
- return true
163
- case 'clear':
164
- await this.restart(false)
165
- return true
166
- }
167
- return false
168
- }
169
-
170
- async showConsole(): Promise<void> {
171
- const uptime = this.startedAt
172
- ? `${Math.round((Date.now() - this.startedAt) / 1000)}s`
173
- : undefined
174
- const card = cards.consoleCard({
175
- sessionName: this.sessionName,
176
- status: this.status,
177
- effort: 'max',
178
- uptime,
179
- hasSession: this.isRunning(),
180
- })
181
- await feishu.sendCard(this.chatId, card)
182
- }
183
-
184
- interrupt(): void {
185
- if (!this.proc) return
186
- log(`session "${this.sessionName}": interrupt`)
187
- this.proc.sendInterrupt()
188
- }
189
-
190
- // ── Inbound from Feishu ────────────────────────────────────────────
191
- async onUserMessage(text: string, files: string[] = []): Promise<void> {
192
- if (!this.isRunning()) {
193
- const ok = await this.start()
194
- if (!ok) return
195
- }
196
- if (this.currentTurn) {
197
- log(`session "${this.sessionName}": new turn arriving mid-flight, interrupting`)
198
- this.proc!.sendInterrupt()
199
- await this.closeTurnCard('🛑 用户打断')
200
- }
201
- await this.openTurnCard(text)
202
- this.proc!.sendUserText(text, files)
203
- this.status = 'working'
204
- }
205
-
206
- async onPermissionDecision(
207
- requestId: string,
208
- decision: 'allow' | 'allow_always' | 'deny',
209
- user: string,
210
- ): Promise<void> {
211
- const pending = this.pendingPermissions.get(requestId)
212
- if (!pending) { log(`session "${this.sessionName}": stray permission ${requestId}`); return }
213
- this.pendingPermissions.delete(requestId)
214
-
215
- const resolved = cards.permissionResolvedCard(pending.toolName, decision, user)
216
- await feishu.patchCardMessage(pending.messageId, resolved)
217
-
218
- const claudeDecision = decision === 'deny' ? 'deny' : 'allow'
219
- this.proc?.sendPermissionResponse(requestId, claudeDecision)
220
-
221
- if (decision === 'allow_always') {
222
- this.proc?.sendSetPermissionMode('acceptEdits')
223
- }
224
-
225
- if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
226
- this.status = 'working'
227
- }
228
- }
229
-
230
- async onConsoleAction(action: string): Promise<void> {
231
- log(`session "${this.sessionName}": console action=${action}`)
232
- switch (action) {
233
- case 'interrupt': this.interrupt(); break
234
- case 'clear': await this.restart(false); break
235
- case 'stop': await this.stop(); break
236
- case 'start': await this.start(); break
237
- case 'resume': await this.restart(true); break
238
- case 'ls': await feishu.sendText(this.chatId, `📁 ${this.workDir}`); break
239
- }
240
- }
241
-
242
- // ── Wiring Claude → Feishu ─────────────────────────────────────────
243
- private wireProc(p: ClaudeProcess): void {
244
- p.on('assistant_text', ({ text }: { text: string }) => {
245
- this.appendAssistant(text)
246
- })
247
- p.on('thinking', ({ text }: { text: string }) => {
248
- this.appendThinking(text)
249
- })
250
- p.on('tool_use', ({ id, name, input }: { id: string; name: string; input: any }) => {
251
- this.addTool(id, name, input)
252
- })
253
- p.on('tool_result', ({ tool_use_id, content, is_error }: any) => {
254
- this.completeTool(tool_use_id, content, is_error)
255
- })
256
- p.on('can_use_tool', (req: CanUseToolRequest) => {
257
- void this.renderPermission(req)
258
- })
259
- p.on('hook_callback', (req: HookCallbackRequest) => {
260
- // No hooks registered → fail-safe ack.
261
- this.proc?.sendHookResponse(req.request_id, {})
262
- })
263
- p.on('result', () => {
264
- void this.closeTurnCard()
265
- this.status = 'idle'
266
- })
267
- p.on('exit', ({ code, signal, expected }: any) => {
268
- log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
269
- this.proc = null
270
- this.currentTurn = null
271
- this.status = 'stopped'
272
- if (!expected && code !== 0 && signal !== 'SIGTERM') {
273
- void feishu.sendText(this.chatId, `⚠️ Claude 异常退出 (code=${code}, signal=${signal})。回复任意消息将重新启动。`)
274
- }
275
- })
276
- }
277
-
278
- private async openTurnCard(userText: string): Promise<void> {
279
- const turn = ++this.turnCounter
280
- const card = cards.mainConversationCard({
281
- sessionName: this.sessionName,
282
- turn,
283
- effort: 'max',
284
- userText,
285
- })
286
- const messageId = await feishu.sendCard(this.chatId, card)
287
- if (!messageId) { log(`session "${this.sessionName}": openTurnCard sendCard failed`); return }
288
- let cardId: string
289
- try { cardId = await cardkit.convertMessageToCard(messageId) }
290
- catch (e) { log(`session "${this.sessionName}": id_convert failed: ${e}`); return }
291
- this.currentTurn = {
292
- cardId,
293
- userText,
294
- thinkingText: '',
295
- toolCount: 0,
296
- toolByUseId: new Map(),
297
- assistantSegmentCount: 0,
298
- currentAssistantSegmentId: null,
299
- currentAssistantText: '',
300
- segmentTexts: new Map(),
301
- startedAt: Date.now(),
302
- }
303
- }
304
-
305
- // Stream-event handlers are intentionally SYNCHRONOUS. Every cardkit op
306
- // is queued (per-card Promise chain in cardkit.ts), so we fire-and-
307
- // forget here and rely on enqueue source order — that way no `await`
308
- // can yield mid-handler and let `closeTurnCard` (or another event) race
309
- // and mutate `this.currentTurn` underfoot.
310
- private appendAssistant(delta: string): void {
311
- if (!this.currentTurn) return
312
- if (!this.currentTurn.currentAssistantSegmentId) {
313
- const i = this.currentTurn.assistantSegmentCount++
314
- const segId = cards.ELEMENTS.assistant(i)
315
- this.currentTurn.currentAssistantSegmentId = segId
316
- this.currentTurn.currentAssistantText = ''
317
- void cardkit.addElement(this.currentTurn.cardId, cards.assistantSegmentElement(i), {
318
- type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
319
- })
320
- }
321
- this.currentTurn.currentAssistantText += delta
322
- const segId = this.currentTurn.currentAssistantSegmentId
323
- this.currentTurn.segmentTexts.set(segId, this.currentTurn.currentAssistantText)
324
- cardkit.streamTextThrottled(
325
- this.currentTurn.cardId,
326
- segId,
327
- this.currentTurn.currentAssistantText,
328
- )
329
- }
330
-
331
- private appendThinking(delta: string): void {
332
- if (!this.currentTurn) return
333
- this.currentTurn.thinkingText += delta
334
- cardkit.streamTextThrottled(
335
- this.currentTurn.cardId,
336
- cards.ELEMENTS.thinking,
337
- this.currentTurn.thinkingText,
338
- )
339
- }
340
-
341
- private addTool(toolUseId: string, name: string, input: any): void {
342
- if (!this.currentTurn) return
343
- // Close current assistant segment (if any) so the tool panel renders
344
- // AFTER it in card body order. Flush queues the segment's last
345
- // buffered delta before the tool element is inserted.
346
- if (this.currentTurn.currentAssistantSegmentId) {
347
- void cardkit.flush(this.currentTurn.cardId)
348
- this.currentTurn.currentAssistantSegmentId = null
349
- this.currentTurn.currentAssistantText = ''
350
- }
351
- const i = this.currentTurn.toolCount++
352
- this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
353
- const el = cards.toolCallElement(i, name, input, null, '⏳')
354
- void cardkit.addElement(this.currentTurn.cardId, el, {
355
- type: 'insert_before',
356
- targetElementId: cards.ELEMENTS.footer,
357
- })
358
- }
359
-
360
- private completeTool(toolUseId: string, content: any, isError: boolean): void {
361
- if (!this.currentTurn) return
362
- const meta = this.currentTurn.toolByUseId.get(toolUseId)
363
- if (!meta) return
364
- const output = typeof content === 'string'
365
- ? content
366
- : Array.isArray(content)
367
- ? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
368
- : JSON.stringify(content)
369
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅')
370
- void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
371
- }
372
-
373
- private async renderPermission(req: CanUseToolRequest): Promise<void> {
374
- this.status = 'awaiting_permission'
375
- const card = cards.permissionCard({
376
- sessionName: this.sessionName,
377
- toolName: req.tool_name,
378
- description: `工具 \`${req.tool_name}\` 想在 ~/${this.sessionName} 执行操作`,
379
- inputPreview: JSON.stringify(req.input ?? {}),
380
- requestId: req.request_id,
381
- })
382
- const messageId = await feishu.sendCard(this.chatId, card)
383
- if (!messageId) {
384
- log(`session "${this.sessionName}": permission card send failed; auto-deny`)
385
- this.proc?.sendPermissionResponse(req.request_id, 'deny')
386
- return
387
- }
388
- this.pendingPermissions.set(req.request_id, { messageId, toolName: req.tool_name })
389
- }
390
-
391
- private async closeTurnCard(suffix?: string): Promise<void> {
392
- // CRITICAL: capture-and-null in a single synchronous block at entry
393
- // so a parallel `closeTurnCard` (e.g. result event firing while
394
- // onUserMessage is awaiting an interrupt) can't double-process the
395
- // same turn — second caller observes null and bails. The promised
396
- // sync-handler invariant only protects callers that take the turn
397
- // off the table BEFORE their first await.
398
- const turn = this.currentTurn
399
- if (!turn) return
400
- this.currentTurn = null
401
- const elapsed = ((Date.now() - turn.startedAt) / 1000).toFixed(1)
402
- const cardId = turn.cardId
403
- const thinkingText = turn.thinkingText
404
- const segmentTexts = turn.segmentTexts
405
- await cardkit.flush(cardId)
406
-
407
- // [[send: /abs/path]] markers — strip them from each assistant
408
- // segment and collect paths to upload after the card finalizes.
409
- const sendPaths: string[] = []
410
- for (const [segId, fullText] of segmentTexts) {
411
- let changed = false
412
- const cleaned = fullText.replace(SEND_MARKER_RE, (_m, p1) => {
413
- changed = true
414
- const p = String(p1).trim()
415
- if (p.startsWith('/')) sendPaths.push(p)
416
- else log(`session "${this.sessionName}": ignore non-absolute send path: ${p}`)
417
- return ''
418
- })
419
- if (changed) {
420
- const finalText = cleaned.trim() || ' '
421
- await cardkit.replaceElement(cardId, segId, {
422
- tag: 'markdown', element_id: segId, content: finalText,
423
- })
424
- }
425
- }
426
-
427
- if (thinkingText.trim()) {
428
- await cardkit.replaceElement(cardId, cards.ELEMENTS.thinking, cards.thinkingCollapsedPanel(thinkingText))
429
- }
430
- const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
431
- const footer = `⏱ ${elapsed}s${suffix ? ' · ' + suffix : ''}${sendNote} · ✅ done`
432
- await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
433
- await cardkit.patchSettings(cardId, cards.STREAMING_OFF_SETTINGS)
434
- await cardkit.dispose(cardId)
435
-
436
- // Fire uploads sequentially AFTER the card is sealed so each file
437
- // posts as its own Feishu message below the conversation card.
438
- // Path gate: workDir (Claude's project sandbox), the inbox where
439
- // user-uploaded attachments land, and the /tmp/lodestar- namespace
440
- // for ad-hoc artifacts. Anything outside is refused — see
441
- // feishu.isPathAllowed.
442
- const allowedRoots = [this.workDir, INBOX_DIR, '/tmp/lodestar-']
443
- for (const p of sendPaths) {
444
- await feishu.uploadAndSend(this.chatId, p, allowedRoots)
445
- }
446
- }
447
- }