@leviyuan/lodestar 0.1.7 → 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
@@ -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.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,
@@ -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,16 @@ export class Session {
134
152
  }
135
153
  }
136
154
 
155
+ /** Minimal cross-chat snapshot for the `hi` peer-list section.
156
+ * `startedAt` stays private so this is the documented read path. */
157
+ peerSnapshot(): { name: string; status: Status; uptimeMs?: number } {
158
+ return {
159
+ name: this.sessionName,
160
+ status: this.status,
161
+ uptimeMs: this.startedAt ? (Date.now() - this.startedAt) : undefined,
162
+ }
163
+ }
164
+
137
165
  get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
138
166
  isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
139
167
 
@@ -175,12 +203,21 @@ export class Session {
175
203
  await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行`)
176
204
  return
177
205
  }
178
- this.lastSessionId = this.proc.sessionId ?? this.lastSessionId
179
- await this.proc.kill()
206
+ // Flip lifecycle state SYNCHRONOUSLY before awaiting kill — daemon's
207
+ // SIGTERM cleanup snapshots `isRunning()` and if we're still mid-
208
+ // `proc.kill()` await it'll see proc!=null and write us into the
209
+ // alive marker, which makes the next boot auto-revive a session
210
+ // the user explicitly killed. Reordering the null-out fixes that
211
+ // race (bug observed 2026-05-15: `kill` immediately followed by
212
+ // `systemctl restart` revived the killed session on boot).
213
+ log(`session "${this.sessionName}": stop (${reason})`)
214
+ const proc = this.proc
215
+ this.lastSessionId = proc.sessionId ?? this.lastSessionId
180
216
  this.proc = null
181
217
  this.currentTurn = null
182
218
  this.pendingPermissions.clear()
183
219
  this.status = 'stopped'
220
+ await proc.kill()
184
221
  await feishu.sendText(this.chatId, `🔴 ${reason} (session: ${this.sessionName})`)
185
222
  }
186
223
 
@@ -264,6 +301,14 @@ export class Session {
264
301
  model,
265
302
  effort: 'max',
266
303
  uptimeMs,
304
+ peers: [...Session.all]
305
+ .filter(s => s.isRunning())
306
+ .map(s => ({ ...s.peerSnapshot(), isCurrent: s === this })),
307
+ // Initial paint without usage → cards.ts renders the
308
+ // `_加载中…_` placeholder in the consoleUsage element. We patch
309
+ // it in below once readUsage() resolves (ccusage cold-call is
310
+ // ~5s; not worth blocking the panel on it).
311
+ usage: undefined,
267
312
  contextTokens: this.currentContextTokens(),
268
313
  cumStats: this.cumStats,
269
314
  lastTurn: this.lastTurnDelta
@@ -276,7 +321,22 @@ export class Session {
276
321
  sessionId: this.proc?.sessionId ?? this.lastSessionId,
277
322
  hasSession: this.isRunning(),
278
323
  })
279
- await feishu.sendCard(this.chatId, card)
324
+ const messageId = await feishu.sendCard(this.chatId, card)
325
+ if (!messageId) return
326
+ // Patch the usage element asynchronously so the rest of the panel
327
+ // stays responsive. We don't await; failures are logged and the
328
+ // placeholder stays visible (no fallback fabrication).
329
+ void (async () => {
330
+ try {
331
+ const cardId = await cardkit.convertMessageToCard(messageId)
332
+ const usage = await readUsage()
333
+ await cardkit.replaceElement(cardId, cards.ELEMENTS.consoleUsage, {
334
+ tag: 'markdown',
335
+ element_id: cards.ELEMENTS.consoleUsage,
336
+ content: cards.consoleUsageContent(usage),
337
+ })
338
+ } catch (e) { log(`session "${this.sessionName}": consoleUsage patch failed: ${e}`) }
339
+ })()
280
340
  }
281
341
 
282
342
  interrupt(): void {
@@ -286,7 +346,7 @@ export class Session {
286
346
  }
287
347
 
288
348
  // ── Inbound from Feishu ────────────────────────────────────────────
289
- async onUserMessage(text: string, files: string[] = []): Promise<void> {
349
+ async onUserMessage(text: string, files: string[] = [], userOpenId = ''): Promise<void> {
290
350
  if (!this.isRunning()) {
291
351
  const ok = await this.start()
292
352
  if (!ok) return
@@ -296,7 +356,7 @@ export class Session {
296
356
  this.proc!.sendInterrupt()
297
357
  await this.closeTurnCard('🛑 用户打断')
298
358
  }
299
- await this.openTurnCard(text)
359
+ await this.openTurnCard(text, userOpenId)
300
360
  this.proc!.sendUserText(text, files)
301
361
  this.status = 'working'
302
362
  }
@@ -569,7 +629,7 @@ export class Session {
569
629
  return this.lastTurnDelta?.inputTokens ?? 0
570
630
  }
571
631
 
572
- private async openTurnCard(userText: string): Promise<void> {
632
+ private async openTurnCard(userText: string, userOpenId: string): Promise<void> {
573
633
  const turn = ++this.turnCounter
574
634
  const card = cards.mainConversationCard({
575
635
  sessionName: this.sessionName,
@@ -584,6 +644,8 @@ export class Session {
584
644
  catch (e) { log(`session "${this.sessionName}": id_convert failed: ${e}`); return }
585
645
  this.currentTurn = {
586
646
  cardId,
647
+ messageId,
648
+ userOpenId,
587
649
  userText,
588
650
  thinkingText: '',
589
651
  toolCount: 0,
@@ -620,6 +682,11 @@ export class Session {
620
682
  segId,
621
683
  this.currentTurn.currentAssistantText,
622
684
  )
685
+ // Chat-list preview: tail of the latest assistant text. Feishu
686
+ // truncates anyway; ~60 chars is what shows on a typical phone
687
+ // preview line. patchSummaryThrottled is rate-limited on its own.
688
+ const tail = this.currentTurn.currentAssistantText.slice(-60)
689
+ cardkit.patchSummaryThrottled(this.currentTurn.cardId, tail)
623
690
  }
624
691
 
625
692
  private appendThinking(delta: string): void {
@@ -677,6 +744,11 @@ export class Session {
677
744
  type: 'insert_before',
678
745
  targetElementId: cards.ELEMENTS.footer,
679
746
  })
747
+ // Phone push — user has to come back and answer before Claude can
748
+ // continue. urgentApp no-ops when userOpenId is empty.
749
+ if (this.currentTurn.userOpenId && this.currentTurn.messageId) {
750
+ void feishu.urgentApp(this.currentTurn.messageId, [this.currentTurn.userOpenId])
751
+ }
680
752
  return
681
753
  }
682
754
  // Pending Task* panels still show the *pre-op* todo mirror so users
@@ -852,6 +924,10 @@ export class Session {
852
924
  this.pendingPermissions.set(req.request_id, { toolUseId })
853
925
  const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
854
926
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
927
+ // Phone push — Claude is blocked until the user approves/denies.
928
+ if (turn.userOpenId && turn.messageId) {
929
+ void feishu.urgentApp(turn.messageId, [turn.userOpenId])
930
+ }
855
931
  }
856
932
 
857
933
  private async closeTurnCard(suffix?: string): Promise<void> {
@@ -896,9 +972,26 @@ export class Session {
896
972
  const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
897
973
  const footer = `⏱ ${elapsed}s${suffix ? ' · ' + suffix : ''}${sendNote} · ✅ done`
898
974
  await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
899
- await cardkit.patchSettings(cardId, cards.STREAMING_OFF_SETTINGS)
975
+ // Final chat-list preview: clean finish shows "⏱ Xs · NK tokens";
976
+ // interrupted shows the suffix instead (no usage event landed).
977
+ // cancelSummary kills any in-flight throttled write so a stale
978
+ // mid-stream tail can't clobber this terminal summary.
979
+ cardkit.cancelSummary(cardId)
980
+ await cardkit.patchSettings(cardId, cards.streamingOffSettings({
981
+ durationSec: elapsed,
982
+ tokens: suffix ? undefined : this.lastTurnDelta?.tokens,
983
+ suffix,
984
+ }))
900
985
  await cardkit.dispose(cardId)
901
986
 
987
+ // Phone push on clean turn close so the user knows Claude is done
988
+ // even with the chat backgrounded. Skip on interrupts (no real
989
+ // completion) and when we don't know who to ping. Fire-and-forget;
990
+ // urgent_app failures are non-fatal and already logged in feishu.ts.
991
+ if (!suffix && turn.userOpenId && turn.messageId) {
992
+ void feishu.urgentApp(turn.messageId, [turn.userOpenId])
993
+ }
994
+
902
995
  // Fire uploads sequentially AFTER the card is sealed so each file
903
996
  // posts as its own Feishu message below the conversation card.
904
997
  // 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
+ }