@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 +12 -5
- package/package.json +1 -1
- package/src/cardkit.ts +46 -0
- package/src/cards.ts +121 -7
- package/src/feishu.ts +44 -0
- package/src/session.ts +137 -7
- package/src/usage.ts +201 -0
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 =
|
|
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
|
-
|
|
158
|
-
await session.onAskMessageAnswer(text,
|
|
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
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
|
-
|
|
584
|
-
|
|
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
|
|
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
|
-
|
|
652
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
+
}
|