@leviyuan/lodestar 0.2.2 → 0.2.4
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/package.json +1 -1
- package/src/cards.ts +19 -7
- package/src/feishu.ts +71 -18
- package/src/session.ts +108 -39
- package/src/usage.ts +69 -5
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -171,7 +171,6 @@ interface MainCardOpts {
|
|
|
171
171
|
turn: number
|
|
172
172
|
model?: string
|
|
173
173
|
effort?: string
|
|
174
|
-
userText: string
|
|
175
174
|
/** What started this turn. `'scheduled'` adds a top-of-card banner so
|
|
176
175
|
* the user can tell a cron-fired wakeup apart from one of their own
|
|
177
176
|
* messages — the user's message bubble is otherwise the only visual
|
|
@@ -200,7 +199,8 @@ export function mainConversationCard(opts: MainCardOpts): object {
|
|
|
200
199
|
// panels are inserted between them in real time as Claude streams.
|
|
201
200
|
// Note: empty-string content is rejected by CardKit PUT so the
|
|
202
201
|
// thinking element starts with a single space placeholder; the first
|
|
203
|
-
// real append overwrites it.
|
|
202
|
+
// real append overwrites it. No echo of the user's message inside
|
|
203
|
+
// the card — the chat bubble above already shows it.
|
|
204
204
|
elements: [
|
|
205
205
|
...banner,
|
|
206
206
|
{ tag: 'markdown', element_id: ELEMENTS.thinking, content: ' ' },
|
|
@@ -595,6 +595,15 @@ function fmtResetIn(date: Date | null): string {
|
|
|
595
595
|
return `${Math.round(ms / (24 * 60 * 60 * 1000))}d`
|
|
596
596
|
}
|
|
597
597
|
|
|
598
|
+
/** Human-readable "time since" — clamps sub-minute values to "刚刚". */
|
|
599
|
+
function fmtAgo(timestamp: number): string {
|
|
600
|
+
const ms = Date.now() - timestamp
|
|
601
|
+
if (ms < 60_000) return '刚刚'
|
|
602
|
+
if (ms < 60 * 60 * 1000) return `${Math.round(ms / 60_000)}m 前`
|
|
603
|
+
if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h 前`
|
|
604
|
+
return `${Math.round(ms / (24 * 60 * 60 * 1000))}d 前`
|
|
605
|
+
}
|
|
606
|
+
|
|
598
607
|
const PEER_STATUS_EMOJI: Record<string, string> = {
|
|
599
608
|
idle: '🟢', working: '⚙️', awaiting_permission: '🔐',
|
|
600
609
|
starting: '🚀', stopped: '⚪',
|
|
@@ -623,19 +632,22 @@ export function consoleUsageContent(
|
|
|
623
632
|
case 'network':
|
|
624
633
|
return `**📊 订阅额度** 拉取失败${usage.reason ? ' — `' + usage.reason + '`' : ''}`
|
|
625
634
|
}
|
|
626
|
-
// state === 'ok'
|
|
635
|
+
// state === 'ok' —— stale 时 head 加 "缓存 Xm 前",重置时间加 `~`
|
|
636
|
+
// 前缀,沿用 omchud HUD 的 stale 标记约定。
|
|
637
|
+
const staleNote = usage.stale ? ` _· 缓存 ${fmtAgo(usage.fetchedAt)}_` : ''
|
|
638
|
+
const resetPrefix = usage.stale ? '~' : ''
|
|
627
639
|
const head = usage.subscriptionType
|
|
628
|
-
? `**📊 订阅额度** · ${usage.subscriptionType}`
|
|
629
|
-
:
|
|
640
|
+
? `**📊 订阅额度** · ${usage.subscriptionType}${staleNote}`
|
|
641
|
+
: `**📊 订阅额度**${staleNote}`
|
|
630
642
|
const lines: string[] = [head]
|
|
631
643
|
if (usage.fiveHour) {
|
|
632
644
|
const parts = [`${Math.round(usage.fiveHour.percent)}%`]
|
|
633
|
-
if (usage.fiveHour.resetsAt) parts.push(`重置 ${fmtResetIn(usage.fiveHour.resetsAt)}`)
|
|
645
|
+
if (usage.fiveHour.resetsAt) parts.push(`重置 ${resetPrefix}${fmtResetIn(usage.fiveHour.resetsAt)}`)
|
|
634
646
|
lines.push(` · 5h ${parts.join(' · ')}`)
|
|
635
647
|
}
|
|
636
648
|
if (usage.weekly) {
|
|
637
649
|
const parts = [`${Math.round(usage.weekly.percent)}%`]
|
|
638
|
-
if (usage.weekly.resetsAt) parts.push(`重置 ${fmtResetIn(usage.weekly.resetsAt)}`)
|
|
650
|
+
if (usage.weekly.resetsAt) parts.push(`重置 ${resetPrefix}${fmtResetIn(usage.weekly.resetsAt)}`)
|
|
639
651
|
lines.push(` · 7d ${parts.join(' · ')}`)
|
|
640
652
|
}
|
|
641
653
|
return lines.length === 1 ? '**📊 订阅额度** _无数据_' : lines.join('\n')
|
package/src/feishu.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import * as lark from '@larksuiteoapi/node-sdk'
|
|
10
10
|
import { execSync } from 'node:child_process'
|
|
11
|
+
import { randomUUID } from 'node:crypto'
|
|
11
12
|
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
12
13
|
import { homedir } from 'node:os'
|
|
13
14
|
import { basename, extname, join } from 'node:path'
|
|
@@ -165,32 +166,84 @@ export async function refreshChatList(): Promise<void> {
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
// ── Outbound: text + card ──────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
169
|
+
/** Delay schedule for sendText/sendCard SDK retries. Three attempts total
|
|
170
|
+
* (the leading 0 is the eager first try). Tuned for the bun+axios+lark-SDK
|
|
171
|
+
* ECONNREFUSED transient we've been seeing — by ~5s the socket pool
|
|
172
|
+
* usually recovers. Business errors (Feishu code != 0) are NOT retried;
|
|
173
|
+
* only thrown network errors are. */
|
|
174
|
+
const SEND_RETRY_DELAYS_MS = [0, 1000, 4000]
|
|
175
|
+
|
|
176
|
+
async function sendViaSdkWithRetry(
|
|
177
|
+
what: 'text' | 'card',
|
|
178
|
+
chatId: string,
|
|
179
|
+
msgType: 'text' | 'interactive',
|
|
180
|
+
content: string,
|
|
181
|
+
): Promise<string | null> {
|
|
182
|
+
// Same uuid across retries → Feishu dedupes on its side so a successful-
|
|
183
|
+
// but-response-lost first attempt doesn't produce a duplicate message.
|
|
184
|
+
const uuid = randomUUID()
|
|
185
|
+
let lastErr: unknown = null
|
|
186
|
+
for (let i = 0; i < SEND_RETRY_DELAYS_MS.length; i++) {
|
|
187
|
+
if (SEND_RETRY_DELAYS_MS[i] > 0) {
|
|
188
|
+
await new Promise(r => setTimeout(r, SEND_RETRY_DELAYS_MS[i]))
|
|
177
189
|
}
|
|
178
|
-
|
|
179
|
-
|
|
190
|
+
try {
|
|
191
|
+
const res: any = await client.im.message.create({
|
|
192
|
+
params: { receive_id_type: 'chat_id' },
|
|
193
|
+
data: { receive_id: chatId, msg_type: msgType, content, uuid },
|
|
194
|
+
})
|
|
195
|
+
if (res?.code && res.code !== 0) {
|
|
196
|
+
log(`feishu: send${what === 'text' ? 'Text' : 'Card'} rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
return res?.data?.message_id ?? null
|
|
200
|
+
} catch (e) {
|
|
201
|
+
lastErr = e
|
|
202
|
+
log(`feishu: send${what === 'text' ? 'Text' : 'Card'} attempt ${i + 1}/${SEND_RETRY_DELAYS_MS.length} chat=${chatId} failed: ${e}`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
log(`feishu: send${what === 'text' ? 'Text' : 'Card'} chat=${chatId} EXHAUSTED ${SEND_RETRY_DELAYS_MS.length} retries: ${lastErr}`)
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function sendText(chatId: string, text: string): Promise<string | null> {
|
|
210
|
+
return sendViaSdkWithRetry('text', chatId, 'text', JSON.stringify({ text }))
|
|
180
211
|
}
|
|
181
212
|
|
|
182
213
|
export async function sendCard(chatId: string, card: object): Promise<string | null> {
|
|
214
|
+
return sendViaSdkWithRetry('card', chatId, 'interactive', JSON.stringify(card))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Last-resort text send that bypasses the lark SDK and uses raw fetch
|
|
218
|
+
* (which is what cardkit.ts uses and has never had stability issues on
|
|
219
|
+
* this runtime). Used by callers that need to *surface a failure when
|
|
220
|
+
* the SDK send path itself is the broken thing* — e.g. `openTurnCard`'s
|
|
221
|
+
* `sendCard` exhausted retries on ECONNREFUSED and we still owe the
|
|
222
|
+
* user a visible "your message was lost, please retry" notice. Do not
|
|
223
|
+
* use this as a general-purpose send; it's the failure-surfacing
|
|
224
|
+
* channel, not a silent fallback. */
|
|
225
|
+
export async function sendTextRaw(chatId: string, text: string): Promise<string | null> {
|
|
183
226
|
try {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
227
|
+
const token = await getTenantToken()
|
|
228
|
+
const res = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id', {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
231
|
+
body: JSON.stringify({
|
|
232
|
+
receive_id: chatId,
|
|
233
|
+
msg_type: 'text',
|
|
234
|
+
content: JSON.stringify({ text }),
|
|
235
|
+
}),
|
|
187
236
|
})
|
|
188
|
-
|
|
189
|
-
|
|
237
|
+
const json = await res.json() as any
|
|
238
|
+
if (json?.code !== 0) {
|
|
239
|
+
log(`feishu: sendTextRaw rejected chat=${chatId} code=${json?.code} msg=${json?.msg}`)
|
|
190
240
|
return null
|
|
191
241
|
}
|
|
192
|
-
return
|
|
193
|
-
} catch (e) {
|
|
242
|
+
return json.data?.message_id ?? null
|
|
243
|
+
} catch (e) {
|
|
244
|
+
log(`feishu: sendTextRaw chat=${chatId} failed: ${e}`)
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
194
247
|
}
|
|
195
248
|
|
|
196
249
|
// ── Reactions ──────────────────────────────────────────────────────────
|
package/src/session.ts
CHANGED
|
@@ -34,7 +34,6 @@ interface TurnState {
|
|
|
34
34
|
* not signal. Ask / permission urgents inside the turn still fire
|
|
35
35
|
* regardless (those genuinely need attention even mid-schedule). */
|
|
36
36
|
trigger: 'user_message' | 'scheduled'
|
|
37
|
-
userText: string
|
|
38
37
|
thinkingText: string
|
|
39
38
|
toolCount: number
|
|
40
39
|
/** `output` / `isError` are filled in by completeTool — kept on the
|
|
@@ -480,12 +479,47 @@ export class Session {
|
|
|
480
479
|
const ok = await this.start()
|
|
481
480
|
if (!ok) return
|
|
482
481
|
}
|
|
482
|
+
// Garbage-collect leftover state from a batch the SDK abandoned —
|
|
483
|
+
// most commonly an AskUserQuestion mid-turn, which makes the SDK
|
|
484
|
+
// emit `QUEUE remove × N` and drop every msg we'd already
|
|
485
|
+
// sendText'd into its queue. The daemon doesn't see those remove
|
|
486
|
+
// events, so `pendingUserMessageCount` and `pendingReactionIds`
|
|
487
|
+
// stay stuck. If the SDK is idle right now (no turn, no eager-
|
|
488
|
+
// open in flight) AND init has already fired at least once
|
|
489
|
+
// (otherwise we'd be in the bootstrap race window where
|
|
490
|
+
// leftover count IS valid — see wasBusy comment below), the
|
|
491
|
+
// leftover count is stale and must be cleared BEFORE the
|
|
492
|
+
// wasBusy computation — otherwise this fresh solo message gets
|
|
493
|
+
// falsely wrapped `<u>…</u>` and its card closes with
|
|
494
|
+
// `📨 转交新卡` instead of `✅`.
|
|
495
|
+
if (this.initCount >= 1 && !this.currentTurn && !this.openingTurn && this.pendingUserMessageCount > 0) {
|
|
496
|
+
this.pendingUserMessageCount = 0
|
|
497
|
+
// Release stale ⏳ reactions left on the abandoned batch's
|
|
498
|
+
// chat messages. addReaction callbacks still in flight will
|
|
499
|
+
// fall through to the orphan path in the wasBusy branch
|
|
500
|
+
// below (which deletes whatever rid lands after both maps
|
|
501
|
+
// are empty).
|
|
502
|
+
for (const [m, rid] of this.pendingReactionIds) {
|
|
503
|
+
if (rid) void feishu.deleteReaction(m, rid)
|
|
504
|
+
}
|
|
505
|
+
this.pendingReactionIds = new Map()
|
|
506
|
+
}
|
|
483
507
|
// Capture busy-state SYNC, before any state mutation — this decides
|
|
484
508
|
// whether the message will visibly queue (gets the OneSecond → later
|
|
485
509
|
// CheckMark lifecycle reactions on its Feishu chat message) or
|
|
486
510
|
// eager-open its own card (no reaction needed; the card itself is
|
|
487
511
|
// the acknowledgement).
|
|
488
|
-
|
|
512
|
+
//
|
|
513
|
+
// `pendingUserMessageCount > 0` catches the bootstrap race: daemon
|
|
514
|
+
// just spawned, `initCount` is still 0 so no card is open yet, but
|
|
515
|
+
// we've already sendText'd a previous user message into the SDK.
|
|
516
|
+
// The next message lands in the SAME merged-batch SDK queue, so
|
|
517
|
+
// it IS mid-flight from the SDK's perspective — without this
|
|
518
|
+
// check, the daemon would mark it as solo (no `<u>` wrap, no ⏳
|
|
519
|
+
// reaction) and the model would see e.g. "123" + "321" + "1"
|
|
520
|
+
// glued into a single string "1233211" (2026-05-16 accumulator
|
|
521
|
+
// bug).
|
|
522
|
+
const wasBusy = this.currentTurn !== null || this.openingTurn || this.pendingUserMessageCount > 0
|
|
489
523
|
this.pendingUserMessageCount++
|
|
490
524
|
this.lastUserOpenId = userOpenId
|
|
491
525
|
// When the SDK will merge this msg with siblings into a multi-
|
|
@@ -496,8 +530,9 @@ export class Session {
|
|
|
496
530
|
// chars and `<u>1</u><u>45</u>` became "145" to the model
|
|
497
531
|
// (2026-05-16 accumulator test). HTML-tag wrap is visible but
|
|
498
532
|
// models parse `<tag>` boundaries very reliably from training.
|
|
499
|
-
//
|
|
500
|
-
// merge, no need. Contract
|
|
533
|
+
// Only the very first solo message of a fresh SDK turn slot
|
|
534
|
+
// skips the wrap — no sibling, no merge, no need. Contract
|
|
535
|
+
// declared in CHANNEL_INSTRUCTIONS.
|
|
501
536
|
const wireText = wasBusy ? `<u>${text}</u>` : text
|
|
502
537
|
this.proc!.sendUserText(wireText, files)
|
|
503
538
|
if (wasBusy && msgId) {
|
|
@@ -509,11 +544,34 @@ export class Session {
|
|
|
509
544
|
this.pendingReactionIds.set(msgId, '')
|
|
510
545
|
void (async () => {
|
|
511
546
|
const rid = await feishu.addReaction(msgId, 'OneSecond')
|
|
512
|
-
if (rid
|
|
547
|
+
if (!rid) return
|
|
548
|
+
if (this.pendingReactionIds.has(msgId)) {
|
|
513
549
|
this.pendingReactionIds.set(msgId, rid)
|
|
550
|
+
} else if (this.currentBatchReactionIds.has(msgId)) {
|
|
551
|
+
// Init handler renamed the sentinel into the batch map while
|
|
552
|
+
// addReaction was in flight — record the rid there so the
|
|
553
|
+
// batch's close-time deleteReaction sees it.
|
|
554
|
+
this.currentBatchReactionIds.set(msgId, rid)
|
|
555
|
+
} else {
|
|
556
|
+
// Orphan: both maps cleared (closeTurnCard already released
|
|
557
|
+
// them) before our add returned. The reaction is now stuck
|
|
558
|
+
// on the Feishu message with no one tracking it — delete
|
|
559
|
+
// directly so the user doesn't see a stale ⏳ forever.
|
|
560
|
+
// (Observed bug 2026-05-16: 8 OneSeconds added during a M0
|
|
561
|
+
// turn, 2 addReaction callbacks landed after close fired the
|
|
562
|
+
// release loop, those rids never made it back into either
|
|
563
|
+
// map → 2 stuck ⏳ in chat.)
|
|
564
|
+
void feishu.deleteReaction(msgId, rid)
|
|
514
565
|
}
|
|
515
566
|
})()
|
|
516
567
|
}
|
|
568
|
+
// Mid-turn user messages don't touch the in-flight card — the SDK
|
|
569
|
+
// queues them and dequeues them on its next turn boundary, at
|
|
570
|
+
// which point `result` closes the current card with `📨 转交新卡`
|
|
571
|
+
// and `init` opens a fresh card for the merged batch turn. The
|
|
572
|
+
// user's own message bubble in the chat (plus the OneSecond ⏳
|
|
573
|
+
// reaction added above) is the only mid-flight feedback they get;
|
|
574
|
+
// no card edit, no echo inside the card.
|
|
517
575
|
if (!this.currentTurn && !this.openingTurn && this.initCount >= 1) {
|
|
518
576
|
// Eager open: this message is going to be processed solo (no current
|
|
519
577
|
// turn to merge with on the SDK side, so SDK runs it as its own turn).
|
|
@@ -524,7 +582,7 @@ export class Session {
|
|
|
524
582
|
this.openingTurn = true
|
|
525
583
|
this.pendingUserMessageCount--
|
|
526
584
|
try {
|
|
527
|
-
await this.openTurnCard(
|
|
585
|
+
await this.openTurnCard(userOpenId, 'user_message')
|
|
528
586
|
this.status = 'working'
|
|
529
587
|
} finally {
|
|
530
588
|
this.openingTurn = false
|
|
@@ -744,31 +802,22 @@ export class Session {
|
|
|
744
802
|
}
|
|
745
803
|
this.initCount++
|
|
746
804
|
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
// init
|
|
751
|
-
//
|
|
752
|
-
//
|
|
805
|
+
// Boot init (initCount === 1) is claimed by `onUserMessage`'s
|
|
806
|
+
// eager-open path — if a user message landed before the init
|
|
807
|
+
// arrived, it sits in `pendingUserMessageCount` and we drain it
|
|
808
|
+
// below; otherwise the init opens nothing. Subsequent inits
|
|
809
|
+
// (initCount >= 2) mark the start of an SDK-initiated turn:
|
|
810
|
+
// either the SDK is draining the type-ahead queue we fed it via
|
|
811
|
+
// `sendUserText` (isUserBatch), or it's a CronCreate /
|
|
812
|
+
// ScheduleWakeup fire from idle (isScheduledFire).
|
|
753
813
|
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
// same user message and the SDK emitted an init#≥2 we don't need
|
|
761
|
-
// to act on. The init handler ALSO claims `openingTurn` for its
|
|
762
|
-
// own async open so a user message landing during the open
|
|
763
|
-
// doesn't spawn a duplicate card.
|
|
814
|
+
// SDK-driven rotation puts the boundary HERE: the previous
|
|
815
|
+
// turn's `result` already closed the in-flight card with
|
|
816
|
+
// `📨 转交新卡` (because pendingUserMessageCount > 0). Now we
|
|
817
|
+
// open a fresh card whose top panel shows the queued messages.
|
|
818
|
+
// currentTurn should be null at this point (result null'd it);
|
|
819
|
+
// the openingTurn guard catches the eager-open vs init race.
|
|
764
820
|
if (this.currentTurn || this.openingTurn) return
|
|
765
|
-
// `pendingUserMessageCount > 0` ⇒ SDK is about to fire an init for a
|
|
766
|
-
// merged batch of one-or-more user messages we already sendText'd
|
|
767
|
-
// (the eager-open path didn't claim them because a turn was still
|
|
768
|
-
// running at the time). Claim the ENTIRE count here — the SDK
|
|
769
|
-
// collapses them into ONE turn, so only one card opens; any further
|
|
770
|
-
// messages that arrive after this point will start a fresh count
|
|
771
|
-
// and a fresh batch.
|
|
772
821
|
const isUserBatch = this.pendingUserMessageCount > 0
|
|
773
822
|
const isScheduledFire = !isUserBatch && this.initCount > 1
|
|
774
823
|
if (!isUserBatch && !isScheduledFire) return
|
|
@@ -784,11 +833,7 @@ export class Session {
|
|
|
784
833
|
this.openingTurn = true
|
|
785
834
|
void (async () => {
|
|
786
835
|
try {
|
|
787
|
-
await this.openTurnCard(
|
|
788
|
-
isUserBatch ? '' : '⏰ 定时唤醒',
|
|
789
|
-
userOpenId,
|
|
790
|
-
isUserBatch ? 'user_message' : 'scheduled',
|
|
791
|
-
)
|
|
836
|
+
await this.openTurnCard(userOpenId, isUserBatch ? 'user_message' : 'scheduled')
|
|
792
837
|
this.status = 'working'
|
|
793
838
|
} finally {
|
|
794
839
|
this.openingTurn = false
|
|
@@ -816,7 +861,14 @@ export class Session {
|
|
|
816
861
|
})
|
|
817
862
|
p.on('result', () => {
|
|
818
863
|
this.accumulateResultStats()
|
|
819
|
-
|
|
864
|
+
// SDK-driven rotation: if any mid-turn user messages stacked up
|
|
865
|
+
// (the SDK is about to dequeue them into a fresh merged-batch
|
|
866
|
+
// turn), close the in-flight card with `📨 转交新卡` so the user
|
|
867
|
+
// sees the cut. The next `init` for that batch turn will open a
|
|
868
|
+
// new card whose top panel echoes those queued messages. No
|
|
869
|
+
// pending → natural ✅ close.
|
|
870
|
+
const suffix = this.pendingUserMessageCount > 0 ? '📨 转交新卡' : undefined
|
|
871
|
+
void this.closeTurnCard(suffix)
|
|
820
872
|
this.status = 'idle'
|
|
821
873
|
})
|
|
822
874
|
p.on('exit', ({ code, signal, expected }: any) => {
|
|
@@ -880,17 +932,35 @@ export class Session {
|
|
|
880
932
|
return this.proc?.lastContextWindow ?? 200_000
|
|
881
933
|
}
|
|
882
934
|
|
|
883
|
-
private async openTurnCard(
|
|
935
|
+
private async openTurnCard(userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
|
|
884
936
|
const turn = ++this.turnCounter
|
|
885
937
|
const card = cards.mainConversationCard({
|
|
886
938
|
sessionName: this.sessionName,
|
|
887
939
|
turn,
|
|
888
940
|
effort: 'max',
|
|
889
|
-
userText,
|
|
890
941
|
kind: trigger,
|
|
891
942
|
})
|
|
892
943
|
const messageId = await feishu.sendCard(this.chatId, card)
|
|
893
|
-
if (!messageId) {
|
|
944
|
+
if (!messageId) {
|
|
945
|
+
log(`session "${this.sessionName}": openTurnCard sendCard EXHAUSTED retries — surfacing via raw text`)
|
|
946
|
+
// sendCard already retried 3× through the SDK. If it still came back
|
|
947
|
+
// null we're either on a sustained SDK-axios outage or a Feishu
|
|
948
|
+
// business reject. Either way the user just sent us a message and
|
|
949
|
+
// it's gone into a black hole — surface that explicitly so they
|
|
950
|
+
// know to resend instead of waiting for a reply that won't come.
|
|
951
|
+
// Use raw fetch (not sendText) because if the SDK is the broken
|
|
952
|
+
// thing we'd be doomed to silence otherwise.
|
|
953
|
+
await feishu.sendTextRaw(
|
|
954
|
+
this.chatId,
|
|
955
|
+
'❌ 创建对话卡片失败 (Feishu SDK 重试 3 次后仍连不上)。你这条消息没能送到 Claude,请稍后重发。',
|
|
956
|
+
)
|
|
957
|
+
// Halt Claude — we already wrote the user text to its stdin in
|
|
958
|
+
// onUserMessage, but with no card to stream into the response would
|
|
959
|
+
// be lost. Interrupt now so the model doesn't burn tokens producing
|
|
960
|
+
// an answer that has nowhere to land.
|
|
961
|
+
this.proc?.sendInterrupt()
|
|
962
|
+
return
|
|
963
|
+
}
|
|
894
964
|
let cardId: string
|
|
895
965
|
try { cardId = await cardkit.convertMessageToCard(messageId) }
|
|
896
966
|
catch (e) { log(`session "${this.sessionName}": id_convert failed: ${e}`); return }
|
|
@@ -899,7 +969,6 @@ export class Session {
|
|
|
899
969
|
messageId,
|
|
900
970
|
userOpenId,
|
|
901
971
|
trigger,
|
|
902
|
-
userText,
|
|
903
972
|
thinkingText: '',
|
|
904
973
|
toolCount: 0,
|
|
905
974
|
toolByUseId: new Map(),
|
package/src/usage.ts
CHANGED
|
@@ -26,6 +26,18 @@
|
|
|
26
26
|
* 调用共享同一份快照,不打 API。in-flight 去重保证并发的多个
|
|
27
27
|
* 群同时唤出控制台时只触发一次后台请求。
|
|
28
28
|
*
|
|
29
|
+
* Stale fallback (照 omchud HUD 规则): 单独记最后一次成功拉到的
|
|
30
|
+
* `state:'ok'` 快照,本次拉取失败 (network/rate_limited/auth_failed)
|
|
31
|
+
* 且距上次成功 <= MAX_STALE_MS (15 分钟) 时,返回上次的 ok 快照并打
|
|
32
|
+
* `stale:true` 标签,卡片层加 "缓存 Xm 前" 提示。这是 no_fallbacks
|
|
33
|
+
* 规则的显式例外 —— 用户明确要求订阅额度面板用缓存兜底,因为短暂
|
|
34
|
+
* 网络抖动里把面板上的数字抹成红色"拉取失败"信息密度反而更低。
|
|
35
|
+
*
|
|
36
|
+
* 429 指数退避: 收到 rate_limited 时增加 rateLimitedCount,下次允许
|
|
37
|
+
* 实拉的时间设为 now + CACHE_TTL_MS * 2^(count-1),封顶 5 分钟。
|
|
38
|
+
* 退避窗口内的 readUsage 直接走 stale fallback,不打 API。任何非 429
|
|
39
|
+
* 的响应 (ok / network / auth_failed) 都会重置计数器。
|
|
40
|
+
*
|
|
29
41
|
* 参考实现: oh-my-claudecode HUD `src/hud/usage-api.ts`。这里只保留
|
|
30
42
|
* Lodestar 用得到的最小子集 —— 不处理 keychain、不处理第三方网关
|
|
31
43
|
* (z.ai / MiniMax)、不处理 enterprise 货币换算、不做多文件 cache 与
|
|
@@ -42,6 +54,11 @@ const TOKEN_REFRESH_URL = 'https://platform.claude.com/v1/oauth/token'
|
|
|
42
54
|
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
43
55
|
const API_TIMEOUT_MS = 10_000
|
|
44
56
|
const CACHE_TTL_MS = 60_000
|
|
57
|
+
/** 失败时回退到上次成功快照的最大年龄。超过此值就不再用缓存兜底,
|
|
58
|
+
* 显示真实失败状态 —— 跟 omchud HUD 的 MAX_STALE_DATA_MS 对齐。 */
|
|
59
|
+
const MAX_STALE_MS = 15 * 60 * 1000
|
|
60
|
+
/** 429 退避封顶,跟 omchud HUD 的 MAX_RATE_LIMITED_BACKOFF_MS 对齐。 */
|
|
61
|
+
const RATE_LIMITED_MAX_BACKOFF_MS = 5 * 60 * 1000
|
|
45
62
|
|
|
46
63
|
function credentialsPath(): string {
|
|
47
64
|
return join(homedir(), '.claude', '.credentials.json')
|
|
@@ -73,10 +90,29 @@ export type UsageSnapshot =
|
|
|
73
90
|
fiveHour: UsageWindow | null
|
|
74
91
|
weekly: UsageWindow | null
|
|
75
92
|
fetchedAt: number
|
|
93
|
+
/** true 时本快照不是这次实拉的,而是 lastOk 兜底回来的旧数据。
|
|
94
|
+
* 卡片层据此显示 "缓存" 标记 + 重置时间加 `~` 前缀。 */
|
|
95
|
+
stale?: boolean
|
|
76
96
|
}
|
|
77
97
|
|
|
98
|
+
type UsageSnapshotOk = Extract<UsageSnapshot, { state: 'ok' }>
|
|
99
|
+
|
|
78
100
|
let cache: { data: UsageSnapshot; at: number } | null = null
|
|
101
|
+
/** 最近一次 state:'ok' 的快照,用于失败时兜底。和 cache 分开存:
|
|
102
|
+
* cache 是短时去重 (60s),lastOk 是长尾兜底 (15min)。 */
|
|
103
|
+
let lastOk: { snapshot: UsageSnapshotOk; at: number } | null = null
|
|
79
104
|
let inFlight: Promise<UsageSnapshot> | null = null
|
|
105
|
+
/** 连续 429 计数,用于指数退避;遇到任何非 429 响应就重置为 0。 */
|
|
106
|
+
let rateLimitedCount = 0
|
|
107
|
+
/** 在这个时间戳之前不打 API,直接走 stale fallback。 */
|
|
108
|
+
let rateLimitedUntil = 0
|
|
109
|
+
|
|
110
|
+
function rateLimitedBackoffMs(count: number): number {
|
|
111
|
+
return Math.min(
|
|
112
|
+
CACHE_TTL_MS * Math.pow(2, Math.max(0, count - 1)),
|
|
113
|
+
RATE_LIMITED_MAX_BACKOFF_MS,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
80
116
|
|
|
81
117
|
function readCredentials(): OAuthCredentials | null {
|
|
82
118
|
const path = credentialsPath()
|
|
@@ -246,18 +282,46 @@ async function fetchUsage(): Promise<UsageSnapshot> {
|
|
|
246
282
|
}
|
|
247
283
|
}
|
|
248
284
|
|
|
285
|
+
/** 失败快照 → 如果 MAX_STALE_MS 内还有 lastOk,就返回 lastOk 的副本
|
|
286
|
+
* (打 stale 标);否则透传失败快照。state:'ok' 走 fast path 原样返回。 */
|
|
287
|
+
function withStaleFallback(snapshot: UsageSnapshot): UsageSnapshot {
|
|
288
|
+
if (snapshot.state === 'ok') return snapshot
|
|
289
|
+
if (lastOk && Date.now() - lastOk.at < MAX_STALE_MS) {
|
|
290
|
+
return { ...lastOk.snapshot, stale: true }
|
|
291
|
+
}
|
|
292
|
+
return snapshot
|
|
293
|
+
}
|
|
294
|
+
|
|
249
295
|
/** 返回订阅额度快照。CACHE_TTL_MS 内的重复调用读缓存;并发请求去重为
|
|
250
|
-
* 单次后台 fetch
|
|
251
|
-
*
|
|
296
|
+
* 单次后台 fetch。拉取失败但 lastOk 仍在 MAX_STALE_MS 内时,回退到
|
|
297
|
+
* lastOk 并打 stale 标。连续 429 走指数退避,退避窗口内不打 API。
|
|
298
|
+
* 永不抛出 —— 失败状态由 `state` 字段表达,卡片层按 state 分支渲染。 */
|
|
252
299
|
export async function readUsage(): Promise<UsageSnapshot> {
|
|
253
|
-
|
|
300
|
+
// 429 退避窗口内不打 API。cache 里可能存的就是 rate_limited 失败态,
|
|
301
|
+
// withStaleFallback 会自动用 lastOk 顶上(15min 内)。
|
|
302
|
+
if (Date.now() < rateLimitedUntil) {
|
|
303
|
+
return withStaleFallback(cache?.data ?? { state: 'rate_limited' })
|
|
304
|
+
}
|
|
305
|
+
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return withStaleFallback(cache.data)
|
|
254
306
|
if (inFlight) return inFlight
|
|
255
307
|
inFlight = fetchUsage()
|
|
256
|
-
.then(d => {
|
|
308
|
+
.then(d => {
|
|
309
|
+
cache = { data: d, at: Date.now() }
|
|
310
|
+
if (d.state === 'ok') lastOk = { snapshot: d, at: Date.now() }
|
|
311
|
+
if (d.state === 'rate_limited') {
|
|
312
|
+
rateLimitedCount += 1
|
|
313
|
+
rateLimitedUntil = Date.now() + rateLimitedBackoffMs(rateLimitedCount)
|
|
314
|
+
} else {
|
|
315
|
+
rateLimitedCount = 0
|
|
316
|
+
rateLimitedUntil = 0
|
|
317
|
+
}
|
|
318
|
+
inFlight = null
|
|
319
|
+
return withStaleFallback(d)
|
|
320
|
+
})
|
|
257
321
|
.catch(e => {
|
|
258
322
|
log(`usage: fetchUsage threw: ${e}`)
|
|
259
323
|
inFlight = null
|
|
260
|
-
return
|
|
324
|
+
return withStaleFallback({ state: 'network', reason: String(e) })
|
|
261
325
|
})
|
|
262
326
|
return inFlight
|
|
263
327
|
}
|