@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/README.md +76 -67
- package/cli.ts +176 -0
- package/config.ts +135 -0
- package/daemon.ts +1080 -144
- package/email-worker.ts +534 -0
- package/env-bootstrap.ts +7 -0
- package/feishu-mcp.ts +482 -0
- package/package.json +36 -37
- package/runtime-api.ts +569 -0
- package/scripts/runtime-thread.sh +91 -0
- package/status-dashboard.ts +733 -0
- package/src/cardkit.ts +0 -215
- package/src/cards.ts +0 -304
- package/src/claude-process.ts +0 -301
- package/src/config.ts +0 -83
- package/src/feishu.ts +0 -365
- package/src/instructions.ts +0 -22
- package/src/log.ts +0 -11
- package/src/paths.ts +0 -41
- package/src/session.ts +0 -447
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
|
-
}
|