@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 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 = () => { try { unlinkSync(PID_FILE) } catch {} }
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 = 10_000
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
- const userId = message.sender?.sender_id?.open_id ?? ''
119
- await session.onAskMessageAnswer(text, userId)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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
- /** Final-state info for an AskUserQuestion panel. Mutually-exclusive
336
- * branches inside: either the user picked one of the SDK-provided
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
- answered?: AskAnswered,
438
+ state?: AskState,
351
439
  ): object {
352
- const primary = questions[0]
353
- const isAnswered = !!answered && (answered.optionIdx !== undefined || !!answered.customText)
354
- const headerTag = primary?.header ? ` · ${primary.header}` : ''
355
- let headerText: string
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
- if (primary) {
368
- bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
369
- // One row per option. While pending, each row is a full-width
370
- // interactive_container so the entire row is the click target.
371
- // Once answered, rows degrade to plain markdown (Feishu has no
372
- // generic non-interactive container `div` doesn't exist, it
373
- // rejects the PUT with code 300121).
374
- for (let optIdx = 0; optIdx < primary.options.length; optIdx++) {
375
- const opt = primary.options[optIdx]
376
- const isPicked = answered?.optionIdx === optIdx
377
- const desc = opt.description ? ` · ${opt.description}` : ''
378
- if (!isAnswered) {
379
- bodyElements.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: 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
- // Custom-answer hint (pending state only). Feishu schema 2.0
410
- // doesn't actually have `form` / `input` elements — `tag: form`
411
- // gets rejected with code 300315. Instead we route custom
412
- // answers through a plain chat message: daemon.handleMessage
413
- // detects a pending ask and forwards the next inbound text as
414
- // the answer, no new turn opened. This is also more Feishu-
415
- // native: the chat input is right there, no extra widget.
416
- if (!isAnswered) {
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: '_💬 也可以直接在群里回复你的答案(裸词命令 `hi`/`kill`/`restart`/`clear` 仍然优先)_',
473
+ content: `**${tag}**:${value}`,
420
474
  })
421
- } else if (answered?.customText) {
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: `✏️ **自定义回答**:${answered.customText}`,
480
+ content: `\n*— 由 ${lastUser} 回答*`,
425
481
  })
426
482
  }
427
- }
428
- // Secondary questions (rare): text-only listing. TODO when needed.
429
- for (let qi = 1; qi < questions.length; qi++) {
430
- const q = questions[qi]
431
- const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
432
- bodyElements.push({
433
- tag: 'markdown',
434
- content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
435
- })
436
- }
437
- if (isAnswered && answered?.user) {
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: `\n*— ${answered.user} 回答*`,
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
- // Collapse once answered: header carries the picked answer, body
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
- if (uptimeMs != null && uptimeMs > 0) {
528
- lines.push(`**⏱ 已运行** ${fmtUptime(uptimeMs)}`)
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\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
- export const STREAMING_OFF_SETTINGS = {
596
- config: { streaming_mode: false, summary: { content: '✅ Lodestar 完成' } },
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
  }