@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 +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 +100 -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,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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
+
}
|