@leviyuan/lodestar 0.1.7 → 0.1.9

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
@@ -91,13 +91,21 @@ async function reviveAliveSessions(): Promise<void> {
91
91
  }
92
92
 
93
93
  // ── Inbound message handler ─────────────────────────────────────────────
94
- const STALE_THRESHOLD_MS = 10_000
94
+ const STALE_THRESHOLD_MS = 5_000
95
95
  const seenMessageIds = new Set<string>()
96
96
 
97
97
  async function handleMessage(data: any): Promise<void> {
98
98
  const message = data?.message
99
99
  if (!message) return
100
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
+
101
109
  const msgId = message.message_id as string | undefined
102
110
  if (msgId && seenMessageIds.has(msgId)) return
103
111
  if (msgId) {
@@ -116,7 +124,6 @@ async function handleMessage(data: any): Promise<void> {
116
124
  if (msgId) void feishu.addReaction(msgId, 'CrossMark')
117
125
  return
118
126
  }
119
- if (msgId) void feishu.addReaction(msgId, 'OK')
120
127
 
121
128
  const chatId = message.chat_id as string
122
129
  let groupName = feishu.chatNameCache.get(chatId)
@@ -154,8 +161,8 @@ async function handleMessage(data: any): Promise<void> {
154
161
  // to text-only messages (an image attachment opens a new turn as
155
162
  // usual). Bare-word commands have already been intercepted above.
156
163
  if (msgType === 'text' && text && session.hasPendingAsk()) {
157
- const userId = message.sender?.sender_id?.open_id ?? ''
158
- await session.onAskMessageAnswer(text, userId)
164
+ if (msgId) void feishu.addReaction(msgId, 'CheckMark')
165
+ await session.onAskMessageAnswer(text, userOpenId)
159
166
  return
160
167
  }
161
168
 
@@ -168,7 +175,7 @@ async function handleMessage(data: any): Promise<void> {
168
175
  }
169
176
 
170
177
  if (!text && !filePath) return
171
- await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [])
178
+ await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [], userOpenId)
172
179
  }
173
180
 
174
181
  // ── Card action handler ────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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,
@@ -514,6 +519,20 @@ interface ConsoleOpts {
514
519
  effort?: string
515
520
  /** ms since this ClaudeProcess spawned — formatted to "1h 23m" inside. */
516
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
517
536
  /** Current context-window occupancy estimate (input + cache tokens of
518
537
  * the last assistant message). 0 if no turn has completed yet. */
519
538
  contextTokens?: number
@@ -557,9 +576,72 @@ function fmtUptime(ms: number): string {
557
576
  return `${d}d ${h % 24}h`
558
577
  }
559
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
+
560
642
  export function consoleCard(opts: ConsoleOpts): object {
561
643
  const {
562
- sessionName, status, model, effort, uptimeMs,
644
+ sessionName, status, model, effort, uptimeMs, peers, usage,
563
645
  contextTokens, contextLimit, cumStats, lastTurn, sessionId, hasSession,
564
646
  } = opts
565
647
  const statusEmoji = {
@@ -575,14 +657,23 @@ export function consoleCard(opts: ConsoleOpts): object {
575
657
  // the small Feishu card area without competing with the button row.
576
658
  const lines: string[] = [headerLine]
577
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
+ }
578
669
  if (contextTokens != null) {
579
670
  const limit = contextLimit ?? 1_000_000
580
671
  const pct = limit > 0 ? Math.round((contextTokens / limit) * 100) : 0
581
672
  lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} / ${fmtTokens(limit)} (${pct}%)`)
582
673
  }
583
- if (uptimeMs != null && uptimeMs > 0) {
584
- lines.push(`**⏱ 已运行** ${fmtUptime(uptimeMs)}`)
585
- }
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.
586
677
  if (cumStats && (cumStats.tokens > 0 || cumStats.costUsd > 0 || cumStats.turns > 0)) {
587
678
  lines.push(`**💬 累计** ${fmtTokens(cumStats.tokens)} tokens · ${fmtCost(cumStats.costUsd)} · ${cumStats.turns} turn${cumStats.turns === 1 ? '' : 's'}`)
588
679
  }
@@ -613,7 +704,15 @@ export function consoleCard(opts: ConsoleOpts): object {
613
704
  },
614
705
  body: {
615
706
  elements: [
616
- { 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
+ },
617
716
  ],
618
717
  },
619
718
  }
@@ -648,6 +747,21 @@ export function menuCard(opts: MenuOpts): object {
648
747
  }
649
748
  }
650
749
 
651
- export const STREAMING_OFF_SETTINGS = {
652
- 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
+ }
653
767
  }
package/src/feishu.ts CHANGED
@@ -204,6 +204,50 @@ export async function addReaction(messageId: string, emojiType: string): Promise
204
204
  } catch (e) { log(`feishu: addReaction ${emojiType} on ${messageId} failed: ${e}`) }
205
205
  }
206
206
 
207
+ // ── Urgent push ───────────────────────────────────────────────────────
208
+ /** Fire Feishu's "加急 — 应用内" push for an already-sent message.
209
+ * Bypasses chat-level mute and pops a full-screen prompt on the
210
+ * recipient's phone. Bot must be the original sender of the message
211
+ * AND must still be a member of the chat.
212
+ *
213
+ * Endpoint:
214
+ * PATCH /open-apis/im/v1/messages/{message_id}/urgent_app
215
+ * ?user_id_type=open_id
216
+ * body: { user_id_list: ["ou_..."] }
217
+ *
218
+ * Required app scope (either one):
219
+ * - `im:message.urgent` (「发送应用内加急消息」)
220
+ * - `im:message.urgent:app_send` (「…(历史版本)」)
221
+ *
222
+ * Limits: 50 QPS app-wide; per-recipient cap is 200 unread urgent
223
+ * messages (230023). No daily quota.
224
+ *
225
+ * Common error codes:
226
+ * 230012 — message not sent by this bot
227
+ * 230023 — recipient has 200 unread urgent already
228
+ * 230052 — missing scope / chat restricts urgent */
229
+ export async function urgentApp(messageId: string, openIds: string[]): Promise<void> {
230
+ if (!messageId) { log(`feishu: urgentApp skip — missing messageId`); return }
231
+ if (openIds.length === 0) { log(`feishu: urgentApp skip — empty openIds (msg=${messageId})`); return }
232
+ const token = await getTenantToken()
233
+ const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/urgent_app?user_id_type=open_id`
234
+ try {
235
+ const res = await fetch(url, {
236
+ method: 'PATCH',
237
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
238
+ body: JSON.stringify({ user_id_list: openIds }),
239
+ })
240
+ const json = await res.json() as any
241
+ if (json?.code !== 0) {
242
+ log(`feishu: urgentApp ${messageId} code=${json?.code} msg=${json?.msg}`)
243
+ return
244
+ }
245
+ const invalid = json.data?.invalid_user_id_list ?? []
246
+ const delivered = openIds.length - invalid.length
247
+ log(`feishu: urgentApp ${messageId} ok — delivered=${delivered}${invalid.length ? ` invalid=${invalid.length}` : ''}`)
248
+ } catch (e) { log(`feishu: urgentApp ${messageId} failed: ${e}`) }
249
+ }
250
+
207
251
  // ── Attachment download (image/file) ───────────────────────────────────
208
252
  export async function downloadAttachment(
209
253
  messageId: string, key: string, type: 'image' | 'file', name?: string,
package/src/session.ts CHANGED
@@ -16,9 +16,18 @@ import * as cards from './cards'
16
16
  import * as feishu from './feishu'
17
17
  import { log } from './log'
18
18
  import { INBOX_DIR } from './paths'
19
+ import { readUsage } from './usage'
19
20
 
20
21
  interface TurnState {
21
22
  cardId: string
23
+ /** Feishu message_id of the card — needed for urgent_app push on clean
24
+ * turn close. Kept separate from cardId because cardkit's stream APIs
25
+ * operate on card_id but the urgent_app endpoint takes message_id. */
26
+ messageId: string
27
+ /** open_id of the user who started this turn. Used to scope the
28
+ * urgent_app push so only the initiator gets pinged (in case there
29
+ * are other members in the group). Empty string → skip the ping. */
30
+ userOpenId: string
22
31
  userText: string
23
32
  thinkingText: string
24
33
  toolCount: number
@@ -73,6 +82,14 @@ interface CumStats {
73
82
  }
74
83
 
75
84
  export class Session {
85
+ /** Process-wide registry of every Session ever constructed in this daemon.
86
+ * Used by the `hi` console panel to enumerate sibling sessions across
87
+ * Feishu groups. Sessions are never removed (matches the daemon's
88
+ * `sessions` map lifecycle — one Session per chat for the daemon's
89
+ * lifetime). Callers should filter on `isRunning()` when they only
90
+ * want currently-alive Claude processes. */
91
+ static readonly all: Set<Session> = new Set()
92
+
76
93
  private proc: ClaudeProcess | null = null
77
94
  private currentTurn: TurnState | null = null
78
95
  private pendingPermissions = new Map<string, { toolUseId: string }>()
@@ -125,6 +142,7 @@ export class Session {
125
142
  public readonly chatId: string,
126
143
  private opts: SessionOpts = {},
127
144
  ) {
145
+ Session.all.add(this)
128
146
  // Restore last-known claude session_id from disk so a daemon restart
129
147
  // (systemctl, crash, watchdog) doesn't strand the user with a fresh
130
148
  // conversation when they next type `restart`.
@@ -134,6 +152,29 @@ export class Session {
134
152
  }
135
153
  }
136
154
 
155
+ /** Patch the card-level summary (the text Feishu uses for chat-list
156
+ * preview AND lock-screen push), then return when the API call has
157
+ * landed. Used right before urgent_app so the push notification's
158
+ * derived preview describes the *action that needs attention* (an
159
+ * unanswered question, a pending permission ask) rather than the
160
+ * stale assistant-text tail that patchSummaryThrottled was streaming.
161
+ * cancelSummary kills any in-flight throttled write so our explicit
162
+ * patch isn't immediately clobbered. */
163
+ private async setUrgentSummary(cardId: string, content: string): Promise<void> {
164
+ cardkit.cancelSummary(cardId)
165
+ await cardkit.patchSettings(cardId, { config: { summary: { content } } })
166
+ }
167
+
168
+ /** Minimal cross-chat snapshot for the `hi` peer-list section.
169
+ * `startedAt` stays private so this is the documented read path. */
170
+ peerSnapshot(): { name: string; status: Status; uptimeMs?: number } {
171
+ return {
172
+ name: this.sessionName,
173
+ status: this.status,
174
+ uptimeMs: this.startedAt ? (Date.now() - this.startedAt) : undefined,
175
+ }
176
+ }
177
+
137
178
  get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
138
179
  isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
139
180
 
@@ -175,12 +216,21 @@ export class Session {
175
216
  await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行`)
176
217
  return
177
218
  }
178
- this.lastSessionId = this.proc.sessionId ?? this.lastSessionId
179
- await this.proc.kill()
219
+ // Flip lifecycle state SYNCHRONOUSLY before awaiting kill — daemon's
220
+ // SIGTERM cleanup snapshots `isRunning()` and if we're still mid-
221
+ // `proc.kill()` await it'll see proc!=null and write us into the
222
+ // alive marker, which makes the next boot auto-revive a session
223
+ // the user explicitly killed. Reordering the null-out fixes that
224
+ // race (bug observed 2026-05-15: `kill` immediately followed by
225
+ // `systemctl restart` revived the killed session on boot).
226
+ log(`session "${this.sessionName}": stop (${reason})`)
227
+ const proc = this.proc
228
+ this.lastSessionId = proc.sessionId ?? this.lastSessionId
180
229
  this.proc = null
181
230
  this.currentTurn = null
182
231
  this.pendingPermissions.clear()
183
232
  this.status = 'stopped'
233
+ await proc.kill()
184
234
  await feishu.sendText(this.chatId, `🔴 ${reason} (session: ${this.sessionName})`)
185
235
  }
186
236
 
@@ -264,6 +314,14 @@ export class Session {
264
314
  model,
265
315
  effort: 'max',
266
316
  uptimeMs,
317
+ peers: [...Session.all]
318
+ .filter(s => s.isRunning())
319
+ .map(s => ({ ...s.peerSnapshot(), isCurrent: s === this })),
320
+ // Initial paint without usage → cards.ts renders the
321
+ // `_加载中…_` placeholder in the consoleUsage element. We patch
322
+ // it in below once readUsage() resolves (ccusage cold-call is
323
+ // ~5s; not worth blocking the panel on it).
324
+ usage: undefined,
267
325
  contextTokens: this.currentContextTokens(),
268
326
  cumStats: this.cumStats,
269
327
  lastTurn: this.lastTurnDelta
@@ -276,7 +334,22 @@ export class Session {
276
334
  sessionId: this.proc?.sessionId ?? this.lastSessionId,
277
335
  hasSession: this.isRunning(),
278
336
  })
279
- await feishu.sendCard(this.chatId, card)
337
+ const messageId = await feishu.sendCard(this.chatId, card)
338
+ if (!messageId) return
339
+ // Patch the usage element asynchronously so the rest of the panel
340
+ // stays responsive. We don't await; failures are logged and the
341
+ // placeholder stays visible (no fallback fabrication).
342
+ void (async () => {
343
+ try {
344
+ const cardId = await cardkit.convertMessageToCard(messageId)
345
+ const usage = await readUsage()
346
+ await cardkit.replaceElement(cardId, cards.ELEMENTS.consoleUsage, {
347
+ tag: 'markdown',
348
+ element_id: cards.ELEMENTS.consoleUsage,
349
+ content: cards.consoleUsageContent(usage),
350
+ })
351
+ } catch (e) { log(`session "${this.sessionName}": consoleUsage patch failed: ${e}`) }
352
+ })()
280
353
  }
281
354
 
282
355
  interrupt(): void {
@@ -286,7 +359,7 @@ export class Session {
286
359
  }
287
360
 
288
361
  // ── Inbound from Feishu ────────────────────────────────────────────
289
- async onUserMessage(text: string, files: string[] = []): Promise<void> {
362
+ async onUserMessage(text: string, files: string[] = [], userOpenId = ''): Promise<void> {
290
363
  if (!this.isRunning()) {
291
364
  const ok = await this.start()
292
365
  if (!ok) return
@@ -296,7 +369,7 @@ export class Session {
296
369
  this.proc!.sendInterrupt()
297
370
  await this.closeTurnCard('🛑 用户打断')
298
371
  }
299
- await this.openTurnCard(text)
372
+ await this.openTurnCard(text, userOpenId)
300
373
  this.proc!.sendUserText(text, files)
301
374
  this.status = 'working'
302
375
  }
@@ -569,7 +642,7 @@ export class Session {
569
642
  return this.lastTurnDelta?.inputTokens ?? 0
570
643
  }
571
644
 
572
- private async openTurnCard(userText: string): Promise<void> {
645
+ private async openTurnCard(userText: string, userOpenId: string): Promise<void> {
573
646
  const turn = ++this.turnCounter
574
647
  const card = cards.mainConversationCard({
575
648
  sessionName: this.sessionName,
@@ -584,6 +657,8 @@ export class Session {
584
657
  catch (e) { log(`session "${this.sessionName}": id_convert failed: ${e}`); return }
585
658
  this.currentTurn = {
586
659
  cardId,
660
+ messageId,
661
+ userOpenId,
587
662
  userText,
588
663
  thinkingText: '',
589
664
  toolCount: 0,
@@ -620,6 +695,11 @@ export class Session {
620
695
  segId,
621
696
  this.currentTurn.currentAssistantText,
622
697
  )
698
+ // Chat-list preview: tail of the latest assistant text. Feishu
699
+ // truncates anyway; ~60 chars is what shows on a typical phone
700
+ // preview line. patchSummaryThrottled is rate-limited on its own.
701
+ const tail = this.currentTurn.currentAssistantText.slice(-60)
702
+ cardkit.patchSummaryThrottled(this.currentTurn.cardId, tail)
623
703
  }
624
704
 
625
705
  private appendThinking(delta: string): void {
@@ -677,6 +757,23 @@ export class Session {
677
757
  type: 'insert_before',
678
758
  targetElementId: cards.ELEMENTS.footer,
679
759
  })
760
+ // Phone push — user has to come back and answer before Claude can
761
+ // continue. Set summary to the question text so the lock-screen
762
+ // notification preview shows what the user needs to answer.
763
+ if (this.currentTurn.userOpenId && this.currentTurn.messageId) {
764
+ const turn = this.currentTurn
765
+ const q0 = questions[0]?.question?.trim() ?? ''
766
+ const truncated = q0.length > 40 ? q0.slice(0, 40) + '…' : q0
767
+ const summary = questions.length > 1
768
+ ? `❓ 待回答 ${questions.length} 题${truncated ? `: ${truncated}` : ''}`
769
+ : truncated
770
+ ? `❓ ${truncated}`
771
+ : '❓ 等你回答问题'
772
+ void (async () => {
773
+ await this.setUrgentSummary(turn.cardId, summary)
774
+ await feishu.urgentApp(turn.messageId, [turn.userOpenId])
775
+ })()
776
+ }
680
777
  return
681
778
  }
682
779
  // Pending Task* panels still show the *pre-op* todo mirror so users
@@ -852,6 +949,22 @@ export class Session {
852
949
  this.pendingPermissions.set(req.request_id, { toolUseId })
853
950
  const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
854
951
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
952
+ // Phone push — Claude is blocked until the user approves/denies.
953
+ // Set summary to "🔐 等审批: <tool>(<input summary>)" so the lock-
954
+ // screen notification shows which tool needs approval.
955
+ if (turn.userOpenId && turn.messageId) {
956
+ const inputSummary = cards.summarizeToolInput(meta.name, meta.input)
957
+ const tail = inputSummary && inputSummary.length > 30
958
+ ? inputSummary.slice(0, 30) + '…'
959
+ : inputSummary
960
+ const summary = tail
961
+ ? `🔐 等审批: ${meta.name} · ${tail}`
962
+ : `🔐 等审批: ${meta.name}`
963
+ void (async () => {
964
+ await this.setUrgentSummary(turn.cardId, summary)
965
+ await feishu.urgentApp(turn.messageId, [turn.userOpenId])
966
+ })()
967
+ }
855
968
  }
856
969
 
857
970
  private async closeTurnCard(suffix?: string): Promise<void> {
@@ -896,9 +1009,26 @@ export class Session {
896
1009
  const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
897
1010
  const footer = `⏱ ${elapsed}s${suffix ? ' · ' + suffix : ''}${sendNote} · ✅ done`
898
1011
  await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
899
- await cardkit.patchSettings(cardId, cards.STREAMING_OFF_SETTINGS)
1012
+ // Final chat-list preview: clean finish shows "⏱ Xs · NK tokens";
1013
+ // interrupted shows the suffix instead (no usage event landed).
1014
+ // cancelSummary kills any in-flight throttled write so a stale
1015
+ // mid-stream tail can't clobber this terminal summary.
1016
+ cardkit.cancelSummary(cardId)
1017
+ await cardkit.patchSettings(cardId, cards.streamingOffSettings({
1018
+ durationSec: elapsed,
1019
+ tokens: suffix ? undefined : this.lastTurnDelta?.tokens,
1020
+ suffix,
1021
+ }))
900
1022
  await cardkit.dispose(cardId)
901
1023
 
1024
+ // Phone push on clean turn close so the user knows Claude is done
1025
+ // even with the chat backgrounded. Skip on interrupts (no real
1026
+ // completion) and when we don't know who to ping. Fire-and-forget;
1027
+ // urgent_app failures are non-fatal and already logged in feishu.ts.
1028
+ if (!suffix && turn.userOpenId && turn.messageId) {
1029
+ void feishu.urgentApp(turn.messageId, [turn.userOpenId])
1030
+ }
1031
+
902
1032
  // Fire uploads sequentially AFTER the card is sealed so each file
903
1033
  // posts as its own Feishu message below the conversation card.
904
1034
  // Path gate: workDir (Claude's project sandbox), the inbox where
package/src/usage.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Subscription usage snapshot for the `hi` console panel.
3
+ *
4
+ * Source: the `ccusage` CLI (https://github.com/ryoppippi/ccusage), which
5
+ * parses Claude Code's local JSONL transcripts on demand. We shell out
6
+ * twice in parallel and cache the merged result for CACHE_TTL_MS.
7
+ *
8
+ * - `blocks --active --token-limit max` → current 5h billing block.
9
+ * `tokenLimitStatus.limit` is ccusage's "peak historical block"
10
+ * value, used as the denominator for the 5h percentage. NOTE:
11
+ * this is consumption relative to your own heaviest 5h ever —
12
+ * NOT the Anthropic tier quota (which we have no way to read
13
+ * without OAuth roundtrips). It's an internally-consistent burn
14
+ * indicator, not an official quota gauge.
15
+ *
16
+ * - `weekly --order desc` → list of weekly aggregates, newest first.
17
+ * ccusage's weekly doesn't expose tokenLimitStatus, so we compute
18
+ * the same "peak historical week" ratio locally.
19
+ *
20
+ * Failures stay visible (no fallback fabrication):
21
+ * - ccusage not on PATH → `installed: false` → card renders install hint.
22
+ * - ccusage runs but yields nothing → `fiveHour: null`, `weekly: null`.
23
+ */
24
+
25
+ import { spawn } from 'node:child_process'
26
+ import { log } from './log'
27
+
28
+ const CCUSAGE_BIN = 'ccusage'
29
+ const CACHE_TTL_MS = 60_000
30
+ const SPAWN_TIMEOUT_MS = 15_000
31
+
32
+ export interface FiveHourBlock {
33
+ costUsd: number
34
+ totalTokens: number
35
+ /** End of the current 5h billing window per ccusage. */
36
+ windowEndsAt: Date
37
+ /** Tokens/min over the current window, if ccusage reported one. */
38
+ burnRatePerMin: number | null
39
+ /** Consumption vs. user's historical peak 5h block (0–100). Null
40
+ * when ccusage hasn't built a peak yet (very new install). */
41
+ percentUsed: number | null
42
+ /** Minutes left in this 5h window per ccusage's projection. */
43
+ remainingMinutes: number | null
44
+ }
45
+
46
+ export interface WeeklyAggregate {
47
+ /** ISO date of this week's start, format ccusage chose (Sun by default). */
48
+ weekStart: string
49
+ costUsd: number
50
+ totalTokens: number
51
+ /** Consumption vs. user's historical peak week (0–100). Null when
52
+ * there's no prior week to compare against. */
53
+ percentUsed: number | null
54
+ /** Fractional days remaining until end of week (start + 7d). */
55
+ remainingDays: number | null
56
+ }
57
+
58
+ export type UsageSnapshot =
59
+ | { installed: false }
60
+ | {
61
+ installed: true
62
+ fiveHour: FiveHourBlock | null
63
+ weekly: WeeklyAggregate | null
64
+ /** When this snapshot was computed. */
65
+ fetchedAt: number
66
+ }
67
+
68
+ function clampPct(v: number): number {
69
+ if (!isFinite(v)) return 0
70
+ return Math.max(0, Math.min(100, v))
71
+ }
72
+
73
+ let cache: { data: UsageSnapshot; at: number } | null = null
74
+ let inFlight: Promise<UsageSnapshot> | null = null
75
+
76
+ /** `null` = not on PATH (ENOENT); `undefined` = ran but failed (timeout,
77
+ * non-zero exit, JSON parse error). Distinct so the caller can render
78
+ * different UX. */
79
+ type RunResult = any | null | undefined
80
+
81
+ function runCcusage(args: string[]): Promise<RunResult> {
82
+ return new Promise((resolve) => {
83
+ let stdout = ''
84
+ let stderr = ''
85
+ let proc
86
+ try {
87
+ proc = spawn(CCUSAGE_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
88
+ } catch (e: any) {
89
+ if (e?.code === 'ENOENT') return resolve(null)
90
+ log(`ccusage spawn threw: ${e}`)
91
+ return resolve(undefined)
92
+ }
93
+
94
+ const timer = setTimeout(() => {
95
+ proc.kill('SIGTERM')
96
+ log(`ccusage ${args.join(' ')}: timeout after ${SPAWN_TIMEOUT_MS}ms`)
97
+ }, SPAWN_TIMEOUT_MS)
98
+
99
+ proc.on('error', (err: any) => {
100
+ clearTimeout(timer)
101
+ if (err?.code === 'ENOENT') resolve(null)
102
+ else { log(`ccusage error: ${err}`); resolve(undefined) }
103
+ })
104
+ proc.stdout!.on('data', (b) => { stdout += b.toString() })
105
+ proc.stderr!.on('data', (b) => { stderr += b.toString() })
106
+ proc.on('close', (code) => {
107
+ clearTimeout(timer)
108
+ if (code !== 0) {
109
+ log(`ccusage ${args.join(' ')}: exit ${code} stderr=${stderr.slice(0, 200)}`)
110
+ return resolve(undefined)
111
+ }
112
+ try { resolve(JSON.parse(stdout)) }
113
+ catch (e) { log(`ccusage JSON parse: ${e}`); resolve(undefined) }
114
+ })
115
+ })
116
+ }
117
+
118
+ async function fetchUsage(): Promise<UsageSnapshot> {
119
+ const [blocks, weekly] = await Promise.all([
120
+ // --active filters to the current 5h block (cheaper to parse).
121
+ // --token-limit max derives a cap from the user's peak historical
122
+ // block so ccusage emits `tokenLimitStatus`, giving us a numerator+
123
+ // denominator without us reading every block ourselves.
124
+ runCcusage(['blocks', '--json', '--active', '--token-limit', 'max']),
125
+ runCcusage(['weekly', '--json', '--order', 'desc']),
126
+ ])
127
+
128
+ if (blocks === null || weekly === null) return { installed: false }
129
+
130
+ let fiveHour: FiveHourBlock | null = null
131
+ if (blocks && Array.isArray(blocks.blocks)) {
132
+ const active = blocks.blocks.find((b: any) => b?.isActive && !b?.isGap)
133
+ if (active) {
134
+ const totalTokens = Number(active.totalTokens) || 0
135
+ const limit = Number(active.tokenLimitStatus?.limit) || 0
136
+ fiveHour = {
137
+ costUsd: Number(active.costUSD) || 0,
138
+ totalTokens,
139
+ windowEndsAt: new Date(active.endTime),
140
+ burnRatePerMin: typeof active.burnRate?.tokensPerMinute === 'number'
141
+ ? active.burnRate.tokensPerMinute : null,
142
+ percentUsed: limit > 0 ? clampPct((totalTokens / limit) * 100) : null,
143
+ remainingMinutes: typeof active.projection?.remainingMinutes === 'number'
144
+ ? active.projection.remainingMinutes : null,
145
+ }
146
+ }
147
+ }
148
+
149
+ let wk: WeeklyAggregate | null = null
150
+ if (weekly && Array.isArray(weekly.weekly) && weekly.weekly.length > 0) {
151
+ const current = weekly.weekly[0]
152
+ const totalTokens = Number(current.totalTokens) || 0
153
+ // Peak historical week (excluding the current one — comparing
154
+ // against itself would always read 100%). When this is the only
155
+ // recorded week we leave percentUsed null.
156
+ const peakTokens = weekly.weekly.slice(1).reduce(
157
+ (m: number, w: any) => Math.max(m, Number(w?.totalTokens) || 0), 0)
158
+ const percentUsed = peakTokens > 0 ? clampPct((totalTokens / peakTokens) * 100) : null
159
+ // Week end = weekStart + 7 days. ccusage emits weekStart as YYYY-MM-DD;
160
+ // parse as UTC so DST/timezone shifts don't drift the countdown.
161
+ const weekStartIso = String(current.week ?? '')
162
+ let remainingDays: number | null = null
163
+ if (weekStartIso) {
164
+ const start = new Date(weekStartIso + 'T00:00:00Z')
165
+ if (!isNaN(start.getTime())) {
166
+ const endMs = start.getTime() + 7 * 24 * 60 * 60 * 1000
167
+ remainingDays = Math.max(0, (endMs - Date.now()) / (24 * 60 * 60 * 1000))
168
+ }
169
+ }
170
+ wk = {
171
+ weekStart: weekStartIso,
172
+ costUsd: Number(current.totalCost) || 0,
173
+ totalTokens,
174
+ percentUsed,
175
+ remainingDays,
176
+ }
177
+ }
178
+
179
+ return { installed: true, fiveHour, weekly: wk, fetchedAt: Date.now() }
180
+ }
181
+
182
+ /** Returns a usage snapshot. Cached for CACHE_TTL_MS; concurrent callers
183
+ * dedupe to a single in-flight ccusage run. First call after stale-out
184
+ * pays the full ccusage cost (~5s on this machine); subsequent reads are
185
+ * instant. Never throws — returns `{ installed: false }` if ccusage is
186
+ * missing, or an empty `{ installed: true, fiveHour: null, ... }` if it
187
+ * runs but yields no data. */
188
+ export async function readUsage(): Promise<UsageSnapshot> {
189
+ if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.data
190
+ if (inFlight) return inFlight
191
+ inFlight = fetchUsage().then(d => {
192
+ cache = { data: d, at: Date.now() }
193
+ inFlight = null
194
+ return d
195
+ }).catch(e => {
196
+ log(`usage: fetchUsage threw: ${e}`)
197
+ inFlight = null
198
+ return cache?.data ?? { installed: false }
199
+ })
200
+ return inFlight
201
+ }