@leviyuan/lodestar 0.1.6 → 0.1.8
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 +57 -6
- package/package.json +1 -1
- package/src/cardkit.ts +46 -0
- package/src/cards.ts +263 -93
- package/src/feishu.ts +76 -2
- package/src/paths.ts +6 -0
- package/src/session.ts +220 -98
- package/src/usage.ts +201 -0
package/daemon.ts
CHANGED
|
@@ -32,7 +32,19 @@ try {
|
|
|
32
32
|
mkdirSync(dirname(PID_FILE), { recursive: true })
|
|
33
33
|
writeFileSync(PID_FILE, String(process.pid))
|
|
34
34
|
|
|
35
|
-
const cleanup = () => {
|
|
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
|
+
}
|
|
36
48
|
process.on('exit', cleanup)
|
|
37
49
|
process.on('SIGTERM', () => { log('SIGTERM'); cleanup(); process.exit(0) })
|
|
38
50
|
process.on('SIGINT', () => { log('SIGINT'); cleanup(); process.exit(0) })
|
|
@@ -51,14 +63,49 @@ function sessionFor(chatId: string, sessionName: string): Session {
|
|
|
51
63
|
return s
|
|
52
64
|
}
|
|
53
65
|
|
|
66
|
+
/** Auto-restart any session that was alive when the previous daemon
|
|
67
|
+
* went down. Driven by the marker file written in `cleanup` — that
|
|
68
|
+
* file ONLY lists sessions that were running, so anything the user
|
|
69
|
+
* had explicitly `kill`-ed before shutdown is intentionally absent
|
|
70
|
+
* and stays stopped. Each revived session is `restart(true)`-ed so
|
|
71
|
+
* the SDK gets `--resume <claudeSessionId>` and the in-flight
|
|
72
|
+
* conversation continues without the user typing anything. */
|
|
73
|
+
async function reviveAliveSessions(): Promise<void> {
|
|
74
|
+
const names = feishu.readAndConsumeAliveMarker()
|
|
75
|
+
if (names.length === 0) return
|
|
76
|
+
log(`revive: ${names.length} session(s) marked alive on shutdown: ${names.join(', ')}`)
|
|
77
|
+
for (const sessionName of names) {
|
|
78
|
+
const chatId = feishu.chatIdForSession(sessionName)
|
|
79
|
+
if (!chatId) {
|
|
80
|
+
log(`revive: no chatId binding for "${sessionName}", skip`)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
const session = sessionFor(chatId, sessionName)
|
|
84
|
+
try {
|
|
85
|
+
await session.restart(true)
|
|
86
|
+
log(`revive: spawned "${sessionName}" (chat ${chatId.slice(0, 8)}…)`)
|
|
87
|
+
} catch (e) {
|
|
88
|
+
log(`revive: restart "${sessionName}" failed: ${e}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
54
93
|
// ── Inbound message handler ─────────────────────────────────────────────
|
|
55
|
-
const STALE_THRESHOLD_MS =
|
|
94
|
+
const STALE_THRESHOLD_MS = 5_000
|
|
56
95
|
const seenMessageIds = new Set<string>()
|
|
57
96
|
|
|
58
97
|
async function handleMessage(data: any): Promise<void> {
|
|
59
98
|
const message = data?.message
|
|
60
99
|
if (!message) return
|
|
61
100
|
|
|
101
|
+
// Feishu's im.message.receive_v1 event puts `sender` at the event
|
|
102
|
+
// root, sibling of `message` — NOT inside `message` (we had this
|
|
103
|
+
// wrong before, which silently emptied userOpenId and skipped every
|
|
104
|
+
// urgent_app push). Try root first, fall back to nested in case the
|
|
105
|
+
// SDK wraps the payload differently.
|
|
106
|
+
const senderId = data?.sender?.sender_id ?? data?.event?.sender?.sender_id ?? message?.sender?.sender_id
|
|
107
|
+
const userOpenId: string = senderId?.open_id ?? ''
|
|
108
|
+
|
|
62
109
|
const msgId = message.message_id as string | undefined
|
|
63
110
|
if (msgId && seenMessageIds.has(msgId)) return
|
|
64
111
|
if (msgId) {
|
|
@@ -77,7 +124,6 @@ async function handleMessage(data: any): Promise<void> {
|
|
|
77
124
|
if (msgId) void feishu.addReaction(msgId, 'CrossMark')
|
|
78
125
|
return
|
|
79
126
|
}
|
|
80
|
-
if (msgId) void feishu.addReaction(msgId, 'OK')
|
|
81
127
|
|
|
82
128
|
const chatId = message.chat_id as string
|
|
83
129
|
let groupName = feishu.chatNameCache.get(chatId)
|
|
@@ -115,8 +161,8 @@ async function handleMessage(data: any): Promise<void> {
|
|
|
115
161
|
// to text-only messages (an image attachment opens a new turn as
|
|
116
162
|
// usual). Bare-word commands have already been intercepted above.
|
|
117
163
|
if (msgType === 'text' && text && session.hasPendingAsk()) {
|
|
118
|
-
|
|
119
|
-
await session.onAskMessageAnswer(text,
|
|
164
|
+
if (msgId) void feishu.addReaction(msgId, 'CheckMark')
|
|
165
|
+
await session.onAskMessageAnswer(text, userOpenId)
|
|
120
166
|
return
|
|
121
167
|
}
|
|
122
168
|
|
|
@@ -129,7 +175,7 @@ async function handleMessage(data: any): Promise<void> {
|
|
|
129
175
|
}
|
|
130
176
|
|
|
131
177
|
if (!text && !filePath) return
|
|
132
|
-
await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [])
|
|
178
|
+
await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [], userOpenId)
|
|
133
179
|
}
|
|
134
180
|
|
|
135
181
|
// ── Card action handler ────────────────────────────────────────────────
|
|
@@ -225,6 +271,11 @@ async function boot(): Promise<void> {
|
|
|
225
271
|
})
|
|
226
272
|
ws.start({ eventDispatcher: dispatcher })
|
|
227
273
|
log(`lodestar-daemon: WS started, watching ${feishu.chatNameCache.size} groups`)
|
|
274
|
+
|
|
275
|
+
// Auto-revive sessions that were running when we last went down.
|
|
276
|
+
// Runs AFTER the WS is up so any 🔁 revive message lands in the
|
|
277
|
+
// right chat instead of disappearing into the void.
|
|
278
|
+
await reviveAliveSessions()
|
|
228
279
|
}
|
|
229
280
|
|
|
230
281
|
boot().catch(e => { log(`boot fatal: ${e}`); process.exit(1) })
|
package/package.json
CHANGED
package/src/cardkit.ts
CHANGED
|
@@ -35,6 +35,14 @@ interface CardState {
|
|
|
35
35
|
|
|
36
36
|
const cards = new Map<string, CardState>()
|
|
37
37
|
|
|
38
|
+
interface SummaryState {
|
|
39
|
+
latest: string
|
|
40
|
+
lastSent: string
|
|
41
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
42
|
+
}
|
|
43
|
+
const summaryStates = new Map<string, SummaryState>()
|
|
44
|
+
const SUMMARY_FLUSH_MS = 1500
|
|
45
|
+
|
|
38
46
|
function state(cardId: string): CardState {
|
|
39
47
|
let s = cards.get(cardId)
|
|
40
48
|
if (!s) {
|
|
@@ -190,6 +198,43 @@ export function deleteElement(cardId: string, elementId: string): Promise<void>
|
|
|
190
198
|
return s.queue
|
|
191
199
|
}
|
|
192
200
|
|
|
201
|
+
/** Throttled card-summary update. The summary text is what Feishu shows
|
|
202
|
+
* in the chat list as the message preview. We coalesce writes on a
|
|
203
|
+
* SUMMARY_FLUSH_MS window so streaming assistant deltas don't blow up
|
|
204
|
+
* the settings-PATCH endpoint. Whitespace is collapsed and the input
|
|
205
|
+
* is trimmed; empty content is ignored. */
|
|
206
|
+
export function patchSummaryThrottled(cardId: string, content: string): void {
|
|
207
|
+
const trimmed = (content ?? '').replace(/\s+/g, ' ').trim()
|
|
208
|
+
if (!trimmed) return
|
|
209
|
+
let s = summaryStates.get(cardId)
|
|
210
|
+
if (!s) {
|
|
211
|
+
s = { latest: trimmed, lastSent: '', timer: null }
|
|
212
|
+
summaryStates.set(cardId, s)
|
|
213
|
+
} else {
|
|
214
|
+
s.latest = trimmed
|
|
215
|
+
}
|
|
216
|
+
if (s.timer) return
|
|
217
|
+
s.timer = setTimeout(() => {
|
|
218
|
+
const st = summaryStates.get(cardId)
|
|
219
|
+
if (!st) return
|
|
220
|
+
st.timer = null
|
|
221
|
+
if (st.latest === st.lastSent) return
|
|
222
|
+
const toSend = st.latest
|
|
223
|
+
st.lastSent = toSend
|
|
224
|
+
void patchSettings(cardId, { config: { summary: { content: toSend } } })
|
|
225
|
+
}, SUMMARY_FLUSH_MS)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Cancel any pending throttled summary write. Call before emitting
|
|
229
|
+
* a terminal summary (e.g. "✅ ⏱ 12.3s · 4.2K tokens") so a stale
|
|
230
|
+
* mid-stream tail can't fire after and clobber the final preview. */
|
|
231
|
+
export function cancelSummary(cardId: string): void {
|
|
232
|
+
const s = summaryStates.get(cardId)
|
|
233
|
+
if (!s) return
|
|
234
|
+
if (s.timer) { clearTimeout(s.timer); s.timer = null }
|
|
235
|
+
summaryStates.delete(cardId)
|
|
236
|
+
}
|
|
237
|
+
|
|
193
238
|
/** Patch settings — used to flip streaming_mode off when a turn finishes. */
|
|
194
239
|
export function patchSettings(cardId: string, settings: object): Promise<void> {
|
|
195
240
|
const s = state(cardId)
|
|
@@ -212,4 +257,5 @@ export async function dispose(cardId: string): Promise<void> {
|
|
|
212
257
|
await flush(cardId)
|
|
213
258
|
await s.queue
|
|
214
259
|
cards.delete(cardId)
|
|
260
|
+
cancelSummary(cardId)
|
|
215
261
|
}
|
package/src/cards.ts
CHANGED
|
@@ -17,6 +17,11 @@ export const ELEMENTS = {
|
|
|
17
17
|
* and the next assistant chunk opens a new one, so element order in the
|
|
18
18
|
* card matches Claude's emission order. */
|
|
19
19
|
assistant: (i: number) => `assistant_${i}`,
|
|
20
|
+
/** Console (hi) card — the subscription-usage row is rendered as its
|
|
21
|
+
* own element so we can replace it after the initial card lands,
|
|
22
|
+
* decoupling the slow ccusage fetch from the rest of the panel's
|
|
23
|
+
* synchronous data. */
|
|
24
|
+
consoleUsage: 'console_usage',
|
|
20
25
|
} as const
|
|
21
26
|
|
|
22
27
|
/** Minimal projection of an SDK task — used by Session's local mirror,
|
|
@@ -332,121 +337,177 @@ export interface AskQuestion {
|
|
|
332
337
|
* the first question only and a text-only listing for the rest (an
|
|
333
338
|
* acceptable limitation — these are rare in practice and we can lift
|
|
334
339
|
* it once the UX is validated). */
|
|
335
|
-
/**
|
|
336
|
-
*
|
|
337
|
-
* options (`optionIdx`), or they typed a free-form answer
|
|
338
|
-
* (`customText`). When both are absent the panel is still pending. */
|
|
340
|
+
/** Per-question final-state. Mutually-exclusive branches: option pick
|
|
341
|
+
* vs. free-form custom text. */
|
|
339
342
|
export interface AskAnswered {
|
|
340
343
|
optionIdx?: number
|
|
341
344
|
customText?: string
|
|
342
345
|
user?: string
|
|
343
346
|
}
|
|
344
347
|
|
|
348
|
+
/** State the panel renders against. `currentIdx` undefined → terminal
|
|
349
|
+
* (every question answered). Otherwise it's the question currently on
|
|
350
|
+
* screen; everything in `answered` is history. */
|
|
351
|
+
export interface AskState {
|
|
352
|
+
currentIdx?: number
|
|
353
|
+
answered: Map<number, AskAnswered>
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Render one question's body — either as clickable interactive_container
|
|
357
|
+
* rows (when picked === undefined) or as plain markdown summary
|
|
358
|
+
* (already-answered, shown in history-panel context). */
|
|
359
|
+
function renderAskQuestionBody(
|
|
360
|
+
q: AskQuestion,
|
|
361
|
+
toolUseId: string,
|
|
362
|
+
questionIdx: number,
|
|
363
|
+
picked?: AskAnswered,
|
|
364
|
+
): any[] {
|
|
365
|
+
const els: any[] = []
|
|
366
|
+
els.push({ tag: 'markdown', content: `**${q.question}**` })
|
|
367
|
+
for (let oi = 0; oi < q.options.length; oi++) {
|
|
368
|
+
const opt = q.options[oi]
|
|
369
|
+
const desc = opt.description ? ` · ${opt.description}` : ''
|
|
370
|
+
if (picked) {
|
|
371
|
+
const isPicked = picked.optionIdx === oi
|
|
372
|
+
els.push({
|
|
373
|
+
tag: 'markdown',
|
|
374
|
+
content: isPicked
|
|
375
|
+
? `✅ **${opt.label}**${desc}`
|
|
376
|
+
: `~~◯ ${opt.label}${desc}~~`,
|
|
377
|
+
})
|
|
378
|
+
} else {
|
|
379
|
+
els.push({
|
|
380
|
+
tag: 'interactive_container',
|
|
381
|
+
background_style: 'default',
|
|
382
|
+
has_border: true,
|
|
383
|
+
corner_radius: '6px',
|
|
384
|
+
padding: '8px 12px',
|
|
385
|
+
margin: '4px 0px 4px 0px',
|
|
386
|
+
behaviors: [{
|
|
387
|
+
type: 'callback',
|
|
388
|
+
value: {
|
|
389
|
+
kind: 'ask',
|
|
390
|
+
tool_use_id: toolUseId,
|
|
391
|
+
question_idx: questionIdx,
|
|
392
|
+
option_idx: oi,
|
|
393
|
+
},
|
|
394
|
+
}],
|
|
395
|
+
elements: [{ tag: 'markdown', content: `**${opt.label}**${desc}` }],
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (picked?.customText) {
|
|
400
|
+
els.push({ tag: 'markdown', content: `✏️ **自定义回答**:${picked.customText}` })
|
|
401
|
+
}
|
|
402
|
+
return els
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Folded "📜 已答 N 题" panel — option C from the multi-question
|
|
406
|
+
* design discussion. Returns null when there's no history to show. */
|
|
407
|
+
function renderAskHistoryPanel(
|
|
408
|
+
questions: AskQuestion[],
|
|
409
|
+
answered: Map<number, AskAnswered>,
|
|
410
|
+
): any | null {
|
|
411
|
+
if (answered.size === 0) return null
|
|
412
|
+
const lines: string[] = []
|
|
413
|
+
const sortedIdx = [...answered.keys()].sort((a, b) => a - b)
|
|
414
|
+
for (const idx of sortedIdx) {
|
|
415
|
+
const q = questions[idx]
|
|
416
|
+
const a = answered.get(idx)!
|
|
417
|
+
const tag = q?.header ?? `Q${idx + 1}`
|
|
418
|
+
const value = a.customText
|
|
419
|
+
?? (a.optionIdx !== undefined ? q?.options[a.optionIdx]?.label : undefined)
|
|
420
|
+
?? '?'
|
|
421
|
+
lines.push(`- ✅ **${tag}**:${value}`)
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
tag: 'collapsible_panel',
|
|
425
|
+
header: {
|
|
426
|
+
title: { tag: 'plain_text', content: `📜 已答 ${answered.size} 题(点击展开)` },
|
|
427
|
+
},
|
|
428
|
+
expanded: false,
|
|
429
|
+
elements: [{ tag: 'markdown', content: lines.join('\n') }],
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
345
433
|
export function askUserQuestionElement(
|
|
346
434
|
i: number,
|
|
347
435
|
toolUseId: string,
|
|
348
436
|
questions: AskQuestion[],
|
|
349
437
|
status: '🤔' | '✅' | '❌' = '🤔',
|
|
350
|
-
|
|
438
|
+
state?: AskState,
|
|
351
439
|
): object {
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
if (isAnswered) {
|
|
357
|
-
// Surface the picked answer in the header so a collapsed panel
|
|
358
|
-
// still tells the reader what was chosen.
|
|
359
|
-
const picked = answered!.customText
|
|
360
|
-
?? (answered!.optionIdx !== undefined ? primary?.options[answered!.optionIdx]?.label : undefined)
|
|
361
|
-
?? '?'
|
|
362
|
-
headerText = `${status} 已回答${headerTag}:${picked}`
|
|
363
|
-
} else {
|
|
364
|
-
headerText = `${status} 🤔 AskUserQuestion${headerTag}`
|
|
365
|
-
}
|
|
440
|
+
const total = questions.length
|
|
441
|
+
const answered = state?.answered ?? new Map<number, AskAnswered>()
|
|
442
|
+
const currentIdx = state?.currentIdx
|
|
443
|
+
const isTerminal = currentIdx === undefined && answered.size > 0
|
|
366
444
|
const bodyElements: any[] = []
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
padding: '8px 12px',
|
|
385
|
-
margin: '4px 0px 4px 0px',
|
|
386
|
-
behaviors: [{
|
|
387
|
-
type: 'callback',
|
|
388
|
-
value: {
|
|
389
|
-
kind: 'ask',
|
|
390
|
-
tool_use_id: toolUseId,
|
|
391
|
-
question_idx: 0,
|
|
392
|
-
option_idx: optIdx,
|
|
393
|
-
},
|
|
394
|
-
}],
|
|
395
|
-
elements: [{ tag: 'markdown', content: `**${opt.label}**${desc}` }],
|
|
396
|
-
})
|
|
397
|
-
} else if (isPicked) {
|
|
398
|
-
bodyElements.push({
|
|
399
|
-
tag: 'markdown',
|
|
400
|
-
content: `✅ **${opt.label}**${desc}`,
|
|
401
|
-
})
|
|
402
|
-
} else {
|
|
403
|
-
bodyElements.push({
|
|
404
|
-
tag: 'markdown',
|
|
405
|
-
content: `~~◯ ${opt.label}${desc}~~`,
|
|
406
|
-
})
|
|
407
|
-
}
|
|
445
|
+
let headerText: string
|
|
446
|
+
|
|
447
|
+
if (isTerminal) {
|
|
448
|
+
// All questions resolved — collapse and roll up answers in header
|
|
449
|
+
// + body. Single-question case keeps the old "已回答:xxx" header
|
|
450
|
+
// style; multi-question gets a "已回答 · N 题" count and a flat
|
|
451
|
+
// listing of Q→A pairs in the body.
|
|
452
|
+
if (total === 1) {
|
|
453
|
+
const q0 = questions[0]
|
|
454
|
+
const a0 = answered.get(0)
|
|
455
|
+
const value = a0?.customText
|
|
456
|
+
?? (a0?.optionIdx !== undefined ? q0?.options[a0.optionIdx]?.label : undefined)
|
|
457
|
+
?? '?'
|
|
458
|
+
const headerTag = q0?.header ? ` · ${q0.header}` : ''
|
|
459
|
+
headerText = `${status} 已回答${headerTag}:${value}`
|
|
460
|
+
} else {
|
|
461
|
+
headerText = `${status} 已回答 · ${total} 题`
|
|
408
462
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
463
|
+
const sortedIdx = [...answered.keys()].sort((a, b) => a - b)
|
|
464
|
+
for (const idx of sortedIdx) {
|
|
465
|
+
const q = questions[idx]
|
|
466
|
+
const a = answered.get(idx)!
|
|
467
|
+
const tag = q?.header ?? `Q${idx + 1}`
|
|
468
|
+
const value = a.customText
|
|
469
|
+
?? (a.optionIdx !== undefined ? q?.options[a.optionIdx]?.label : undefined)
|
|
470
|
+
?? '?'
|
|
417
471
|
bodyElements.push({
|
|
418
472
|
tag: 'markdown',
|
|
419
|
-
content:
|
|
473
|
+
content: `**${tag}**:${value}`,
|
|
420
474
|
})
|
|
421
|
-
}
|
|
475
|
+
}
|
|
476
|
+
const lastUser = [...answered.values()].reverse().find(a => a.user)?.user
|
|
477
|
+
if (lastUser) {
|
|
422
478
|
bodyElements.push({
|
|
423
479
|
tag: 'markdown',
|
|
424
|
-
content:
|
|
480
|
+
content: `\n*— 由 ${lastUser} 回答*`,
|
|
425
481
|
})
|
|
426
482
|
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
483
|
+
} else if (currentIdx !== undefined && questions[currentIdx]) {
|
|
484
|
+
// In-progress: render current question + folded history above.
|
|
485
|
+
// Progress tag in header lets the user see how many are left,
|
|
486
|
+
// even with the history panel folded.
|
|
487
|
+
const q = questions[currentIdx]
|
|
488
|
+
const headerTag = q.header ? ` · ${q.header}` : ''
|
|
489
|
+
const progress = total > 1 ? ` (${currentIdx + 1}/${total})` : ''
|
|
490
|
+
headerText = `${status} 🤔 AskUserQuestion${headerTag}${progress}`
|
|
491
|
+
const history = renderAskHistoryPanel(questions, answered)
|
|
492
|
+
if (history) bodyElements.push(history)
|
|
493
|
+
bodyElements.push(...renderAskQuestionBody(q, toolUseId, currentIdx))
|
|
438
494
|
bodyElements.push({
|
|
439
495
|
tag: 'markdown',
|
|
440
|
-
content:
|
|
496
|
+
content: '_💬 也可以直接在群里回复你的答案(裸词命令 `hi`/`kill`/`restart`/`clear` 仍然优先)_',
|
|
441
497
|
})
|
|
498
|
+
} else {
|
|
499
|
+
// Defensive fallback — neither answered nor a valid currentIdx.
|
|
500
|
+
headerText = `${status} 🤔 AskUserQuestion`
|
|
501
|
+
if (questions[0]) {
|
|
502
|
+
bodyElements.push({ tag: 'markdown', content: `**${questions[0].question}**` })
|
|
503
|
+
}
|
|
442
504
|
}
|
|
505
|
+
|
|
443
506
|
return {
|
|
444
507
|
tag: 'collapsible_panel',
|
|
445
508
|
element_id: ELEMENTS.tool(i),
|
|
446
509
|
header: { title: { tag: 'plain_text', content: headerText } },
|
|
447
|
-
|
|
448
|
-
// is just history at that point.
|
|
449
|
-
expanded: !isAnswered,
|
|
510
|
+
expanded: !isTerminal,
|
|
450
511
|
elements: bodyElements,
|
|
451
512
|
}
|
|
452
513
|
}
|
|
@@ -458,6 +519,20 @@ interface ConsoleOpts {
|
|
|
458
519
|
effort?: string
|
|
459
520
|
/** ms since this ClaudeProcess spawned — formatted to "1h 23m" inside. */
|
|
460
521
|
uptimeMs?: number
|
|
522
|
+
/** All sessions currently running Claude across every Feishu group
|
|
523
|
+
* this daemon owns. Each entry is a sibling project. Empty/undefined
|
|
524
|
+
* → omit the section. The session matching this card's chat is
|
|
525
|
+
* flagged `isCurrent` so the row can be marked. */
|
|
526
|
+
peers?: Array<{
|
|
527
|
+
name: string
|
|
528
|
+
isCurrent: boolean
|
|
529
|
+
status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
|
|
530
|
+
uptimeMs?: number
|
|
531
|
+
}>
|
|
532
|
+
/** Subscription usage snapshot from ccusage. When `installed: false`
|
|
533
|
+
* the row renders an install hint; otherwise we surface the current
|
|
534
|
+
* 5h billing block + this week's aggregate. Undefined → omit row. */
|
|
535
|
+
usage?: import('./usage').UsageSnapshot
|
|
461
536
|
/** Current context-window occupancy estimate (input + cache tokens of
|
|
462
537
|
* the last assistant message). 0 if no turn has completed yet. */
|
|
463
538
|
contextTokens?: number
|
|
@@ -501,9 +576,72 @@ function fmtUptime(ms: number): string {
|
|
|
501
576
|
return `${d}d ${h % 24}h`
|
|
502
577
|
}
|
|
503
578
|
|
|
579
|
+
/** Human-readable "time until" — null/past dates collapse to '已重置'. */
|
|
580
|
+
function fmtResetIn(date: Date | null): string {
|
|
581
|
+
if (!date) return '?'
|
|
582
|
+
const ms = date.getTime() - Date.now()
|
|
583
|
+
if (ms <= 0) return '已重置'
|
|
584
|
+
if (ms < 60 * 60 * 1000) return `${Math.max(1, Math.round(ms / 60_000))}m`
|
|
585
|
+
if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h`
|
|
586
|
+
return `${Math.round(ms / (24 * 60 * 60 * 1000))}d`
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const PEER_STATUS_EMOJI: Record<string, string> = {
|
|
590
|
+
idle: '🟢', working: '⚙️', awaiting_permission: '🔐',
|
|
591
|
+
starting: '🚀', stopped: '⚪',
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Render the subscription-usage section of the console card. Pulled out
|
|
595
|
+
* of `consoleCard` so the caller can patch it in after the initial card
|
|
596
|
+
* is on screen (ccusage's first cold call is ~5s; we'd rather not block
|
|
597
|
+
* the whole panel on it). Layout intentionally splits 5h and 7d onto
|
|
598
|
+
* their own indented lines for readability on phone.
|
|
599
|
+
*
|
|
600
|
+
* `usage === undefined` → loading placeholder (initial paint).
|
|
601
|
+
* `usage === null` → permanent "no data" (treat like installed but
|
|
602
|
+
* empty; rare path).
|
|
603
|
+
* `usage.installed=false` → install hint.
|
|
604
|
+
*/
|
|
605
|
+
export function consoleUsageContent(
|
|
606
|
+
usage: import('./usage').UsageSnapshot | null | undefined,
|
|
607
|
+
): string {
|
|
608
|
+
if (usage === undefined) return '**📊 订阅额度** _加载中…_'
|
|
609
|
+
if (usage === null) return '**📊 订阅额度** _无数据_'
|
|
610
|
+
if (!usage.installed) return '**📊 订阅额度** 未装 `ccusage` — `bun i -g ccusage`'
|
|
611
|
+
// Format follows user spec: `5h X% $Y 剩Zh` / `7d X% $Y 剩Zd`.
|
|
612
|
+
// Both % values are vs. the user's own historical peak (peak block
|
|
613
|
+
// for 5h, peak week for 7d) since ccusage has no view into the
|
|
614
|
+
// actual subscription tier cap. Omit chips that the data layer
|
|
615
|
+
// couldn't supply rather than fabricate (no_fallbacks).
|
|
616
|
+
const lines: string[] = ['**📊 订阅额度**']
|
|
617
|
+
if (usage.fiveHour) {
|
|
618
|
+
const parts: string[] = []
|
|
619
|
+
if (usage.fiveHour.percentUsed != null) {
|
|
620
|
+
parts.push(`${Math.round(usage.fiveHour.percentUsed)}%`)
|
|
621
|
+
}
|
|
622
|
+
parts.push(`$${Math.round(usage.fiveHour.costUsd)}`)
|
|
623
|
+
if (usage.fiveHour.remainingMinutes != null) {
|
|
624
|
+
parts.push(`剩${(usage.fiveHour.remainingMinutes / 60).toFixed(1)}h`)
|
|
625
|
+
}
|
|
626
|
+
lines.push(` · 5h ${parts.join(' ')}`)
|
|
627
|
+
}
|
|
628
|
+
if (usage.weekly) {
|
|
629
|
+
const parts: string[] = []
|
|
630
|
+
if (usage.weekly.percentUsed != null) {
|
|
631
|
+
parts.push(`${Math.round(usage.weekly.percentUsed)}%`)
|
|
632
|
+
}
|
|
633
|
+
parts.push(`$${Math.round(usage.weekly.costUsd)}`)
|
|
634
|
+
if (usage.weekly.remainingDays != null) {
|
|
635
|
+
parts.push(`剩${usage.weekly.remainingDays.toFixed(1)}d`)
|
|
636
|
+
}
|
|
637
|
+
lines.push(` · 7d ${parts.join(' ')}`)
|
|
638
|
+
}
|
|
639
|
+
return lines.length === 1 ? '**📊 订阅额度** _无数据_' : lines.join('\n')
|
|
640
|
+
}
|
|
641
|
+
|
|
504
642
|
export function consoleCard(opts: ConsoleOpts): object {
|
|
505
643
|
const {
|
|
506
|
-
sessionName, status, model, effort, uptimeMs,
|
|
644
|
+
sessionName, status, model, effort, uptimeMs, peers, usage,
|
|
507
645
|
contextTokens, contextLimit, cumStats, lastTurn, sessionId, hasSession,
|
|
508
646
|
} = opts
|
|
509
647
|
const statusEmoji = {
|
|
@@ -519,14 +657,23 @@ export function consoleCard(opts: ConsoleOpts): object {
|
|
|
519
657
|
// the small Feishu card area without competing with the button row.
|
|
520
658
|
const lines: string[] = [headerLine]
|
|
521
659
|
|
|
660
|
+
if (peers && peers.length > 0) {
|
|
661
|
+
lines.push(`**🗂 活跃项目** (${peers.length})`)
|
|
662
|
+
for (const p of peers) {
|
|
663
|
+
const dot = PEER_STATUS_EMOJI[p.status] ?? '·'
|
|
664
|
+
const up = p.uptimeMs != null && p.uptimeMs > 0 ? ` · ${fmtUptime(p.uptimeMs)}` : ''
|
|
665
|
+
const mark = p.isCurrent ? ' ← 当前' : ''
|
|
666
|
+
lines.push(` · ${dot} \`${p.name}\`${up}${mark}`)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
522
669
|
if (contextTokens != null) {
|
|
523
670
|
const limit = contextLimit ?? 1_000_000
|
|
524
671
|
const pct = limit > 0 ? Math.round((contextTokens / limit) * 100) : 0
|
|
525
672
|
lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} / ${fmtTokens(limit)} (${pct}%)`)
|
|
526
673
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
674
|
+
void uptimeMs // session-level uptime is already shown per-project in
|
|
675
|
+
// the 活跃项目 list above (peers[].uptimeMs); the dedicated row would
|
|
676
|
+
// duplicate it for the current session.
|
|
530
677
|
if (cumStats && (cumStats.tokens > 0 || cumStats.costUsd > 0 || cumStats.turns > 0)) {
|
|
531
678
|
lines.push(`**💬 累计** ${fmtTokens(cumStats.tokens)} tokens · ${fmtCost(cumStats.costUsd)} · ${cumStats.turns} turn${cumStats.turns === 1 ? '' : 's'}`)
|
|
532
679
|
}
|
|
@@ -557,7 +704,15 @@ export function consoleCard(opts: ConsoleOpts): object {
|
|
|
557
704
|
},
|
|
558
705
|
body: {
|
|
559
706
|
elements: [
|
|
560
|
-
{ tag: 'markdown', content: lines.join('\n
|
|
707
|
+
{ tag: 'markdown', content: lines.join('\n') },
|
|
708
|
+
// Separate element so showConsole() can replace it after the
|
|
709
|
+
// ccusage fetch completes — initial paint goes out immediately
|
|
710
|
+
// with `_加载中…_`, then this row swaps to real data.
|
|
711
|
+
{
|
|
712
|
+
tag: 'markdown',
|
|
713
|
+
element_id: ELEMENTS.consoleUsage,
|
|
714
|
+
content: consoleUsageContent(usage),
|
|
715
|
+
},
|
|
561
716
|
],
|
|
562
717
|
},
|
|
563
718
|
}
|
|
@@ -592,6 +747,21 @@ export function menuCard(opts: MenuOpts): object {
|
|
|
592
747
|
}
|
|
593
748
|
}
|
|
594
749
|
|
|
595
|
-
|
|
596
|
-
|
|
750
|
+
/** Settings patch applied when a turn finishes — flips streaming off
|
|
751
|
+
* and updates the chat-list preview with `⏱ duration · NK tokens`
|
|
752
|
+
* (or just the suffix if interrupted before a result event). */
|
|
753
|
+
export function streamingOffSettings(opts: {
|
|
754
|
+
durationSec: string
|
|
755
|
+
tokens?: number
|
|
756
|
+
suffix?: string
|
|
757
|
+
}): object {
|
|
758
|
+
const parts: string[] = []
|
|
759
|
+
parts.push(opts.suffix ?? '✅')
|
|
760
|
+
parts.push(`⏱ ${opts.durationSec}s`)
|
|
761
|
+
if (opts.tokens != null && opts.tokens > 0) {
|
|
762
|
+
parts.push(`${fmtTokens(opts.tokens)} tokens`)
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
config: { streaming_mode: false, summary: { content: parts.join(' · ') } },
|
|
766
|
+
}
|
|
597
767
|
}
|