@leviyuan/lodestar 0.2.6 → 0.2.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/package.json +1 -1
- package/src/cardkit.ts +27 -8
- package/src/session.ts +129 -57
package/package.json
CHANGED
package/src/cardkit.ts
CHANGED
|
@@ -81,7 +81,12 @@ async function call(method: string, path: string, body?: object): Promise<any> {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function isStreamingClosed(e: unknown): boolean {
|
|
84
|
-
|
|
84
|
+
if (typeof e !== 'object' || e === null) return false
|
|
85
|
+
const code = (e as any).code
|
|
86
|
+
// 300309 "streaming mode is closed" — TTL already fired before our write.
|
|
87
|
+
// 200850 "card streaming timeout" — TTL fired exactly during our write.
|
|
88
|
+
// Both mean the streaming session is gone and a reopen will unstick the card.
|
|
89
|
+
return code === 300309 || code === 200850
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
/** Reopen streaming_mode on a card that Feishu auto-closed after its
|
|
@@ -98,15 +103,16 @@ async function reopenStreaming(cardId: string): Promise<void> {
|
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
/** Run `op` inside the per-card queue. If it fails with code=300309
|
|
101
|
-
* (Feishu auto-closed streaming after the 10-
|
|
102
|
-
* streaming inline and retry `op` exactly once.
|
|
103
|
-
*
|
|
104
|
-
* swallowed, matching the fire-and-forget contract every
|
|
105
|
-
* already has at the call sites. */
|
|
106
|
+
* or 200850 (Feishu auto-closed / timed-out streaming after the 10-
|
|
107
|
+
* minute TTL), reopen streaming inline and retry `op` exactly once.
|
|
108
|
+
* Anything else — other failure, reopen failure, retry failure — is
|
|
109
|
+
* logged and swallowed, matching the fire-and-forget contract every
|
|
110
|
+
* cardkit op already has at the call sites. */
|
|
106
111
|
async function withReopenOnStreamingClosed(
|
|
107
112
|
cardId: string,
|
|
108
113
|
label: string,
|
|
109
114
|
op: () => Promise<void>,
|
|
115
|
+
onFailure?: () => void,
|
|
110
116
|
): Promise<void> {
|
|
111
117
|
try {
|
|
112
118
|
await op()
|
|
@@ -114,20 +120,23 @@ async function withReopenOnStreamingClosed(
|
|
|
114
120
|
} catch (e) {
|
|
115
121
|
if (!isStreamingClosed(e)) {
|
|
116
122
|
log(`cardkit ${label} ${cardId}: ${e}`)
|
|
123
|
+
if (onFailure) onFailure()
|
|
117
124
|
return
|
|
118
125
|
}
|
|
119
|
-
log(`cardkit ${label} ${cardId}: streaming closed (
|
|
126
|
+
log(`cardkit ${label} ${cardId}: streaming closed (code=${(e as any).code}) — reopening`)
|
|
120
127
|
}
|
|
121
128
|
try {
|
|
122
129
|
await reopenStreaming(cardId)
|
|
123
130
|
} catch (re) {
|
|
124
131
|
log(`cardkit STREAMING_REOPEN_FAILED ${cardId}: ${re}`)
|
|
132
|
+
if (onFailure) onFailure()
|
|
125
133
|
return
|
|
126
134
|
}
|
|
127
135
|
try {
|
|
128
136
|
await op()
|
|
129
137
|
} catch (e2) {
|
|
130
138
|
log(`cardkit ${label} ${cardId} retry-after-reopen: ${e2}`)
|
|
139
|
+
if (onFailure) onFailure()
|
|
131
140
|
}
|
|
132
141
|
}
|
|
133
142
|
|
|
@@ -201,11 +210,20 @@ export async function flush(cardId: string): Promise<void> {
|
|
|
201
210
|
}
|
|
202
211
|
}
|
|
203
212
|
|
|
204
|
-
/** Add a new element to the card body or relative to a sibling.
|
|
213
|
+
/** Add a new element to the card body or relative to a sibling.
|
|
214
|
+
*
|
|
215
|
+
* `onFailure` fires asynchronously (after promise queue settles) if the
|
|
216
|
+
* element was NOT created — either the first attempt failed with a non-
|
|
217
|
+
* 300309 error, or the retry-after-reopen also failed. Use it to invalidate
|
|
218
|
+
* any daemon-side reference to the element you tried to add (e.g. a segment
|
|
219
|
+
* id), so subsequent writes don't keep PUTting content to a phantom element
|
|
220
|
+
* that Feishu will silently reject. Default (no callback) preserves the
|
|
221
|
+
* legacy fire-and-forget swallow behavior. */
|
|
205
222
|
export function addElement(
|
|
206
223
|
cardId: string,
|
|
207
224
|
element: object,
|
|
208
225
|
opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
|
|
226
|
+
onFailure?: () => void,
|
|
209
227
|
): Promise<void> {
|
|
210
228
|
const s = state(cardId)
|
|
211
229
|
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
@@ -220,6 +238,7 @@ export function addElement(
|
|
|
220
238
|
sequence: seq,
|
|
221
239
|
})
|
|
222
240
|
},
|
|
241
|
+
onFailure,
|
|
223
242
|
))
|
|
224
243
|
return s.queue
|
|
225
244
|
}
|
package/src/session.ts
CHANGED
|
@@ -91,11 +91,14 @@ interface LastTurnDelta {
|
|
|
91
91
|
inputTokens: number // input + cache_* (excludes output) — context-window estimate
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/** Cumulative session counters. Reset on full restart (`clear`),
|
|
95
|
-
* across resume
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
94
|
+
/** Cumulative session counters. Reset on full restart (`clear`),
|
|
95
|
+
* preserved across `restart`/resume and daemon-restart so the `hi`
|
|
96
|
+
* panel reflects the user's total spend in this conversation
|
|
97
|
+
* regardless of how many times the underlying ClaudeProcess has been
|
|
98
|
+
* respawned. Resumed conversations start counting from the resume
|
|
99
|
+
* point onward — the SDK doesn't replay historical usage on resume,
|
|
100
|
+
* so a long pre-resume conversation shows up as zero here until the
|
|
101
|
+
* first new turn lands. */
|
|
99
102
|
interface CumStats {
|
|
100
103
|
tokens: number
|
|
101
104
|
costUsd: number
|
|
@@ -115,18 +118,25 @@ export class Session {
|
|
|
115
118
|
private currentTurn: TurnState | null = null
|
|
116
119
|
/** Count of user messages we've written to Claude's stdin since the last
|
|
117
120
|
* turn opened on our side. NOT a FIFO of individual messages — the SDK
|
|
118
|
-
* batch-merges every mid-turn user message into a single
|
|
119
|
-
* once the in-flight turn finishes, so
|
|
120
|
-
* **one** init event per batch
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
121
|
+
* USUALLY batch-merges every mid-turn user message into a single
|
|
122
|
+
* combined turn once the in-flight turn finishes, so most of the time
|
|
123
|
+
* the daemon observes **one** init event per batch. Tracking a count +
|
|
124
|
+
* last-sender (rather than an Array<msg>) keeps the daemon's view
|
|
125
|
+
* loosely in sync with the SDK's dequeue semantics. Caveat verified
|
|
126
|
+
* 2026-05-17 (test1 accumulator, 8-message rapid-fire): when the first
|
|
127
|
+
* write lands in an idle SDK, that single msg gets its own turn and
|
|
128
|
+
* the rest merge into a second turn — i.e. 1+(N-1) split, not always
|
|
129
|
+
* one merge. To stay coherent with this, `drainMidTurnAndOpen` bumps
|
|
130
|
+
* the count by `batch.length` up front (covering both the first solo
|
|
131
|
+
* turn and the eventual merged tail), and the init handler resets to
|
|
132
|
+
* 0 on the first claim. If the SDK takes the merge path, the bail at
|
|
133
|
+
* `currentTurn=yes` in the init handler leaves pendingCount stale (>0)
|
|
134
|
+
* until the GC at the next `onUserMessage`; if it takes the split
|
|
135
|
+
* path, the second init sees pendingCount>0 and correctly classifies
|
|
136
|
+
* the trailing batch as user-batch (not a scheduled wakeup).
|
|
137
|
+
* Distinguishes user-msg turns from cron-fired scheduled
|
|
138
|
+
* wakeups: count > 0 ⇒ user; count === 0 ⇒ scheduled (and
|
|
139
|
+
* `initCount > 1`). */
|
|
130
140
|
private pendingUserMessageCount = 0
|
|
131
141
|
/** Mid-turn user messages buffered DAEMON-SIDE (not yet sendUserText'd
|
|
132
142
|
* to the SDK). Drained in the `result` handler by writing each to SDK
|
|
@@ -294,6 +304,27 @@ export class Session {
|
|
|
294
304
|
return true
|
|
295
305
|
}
|
|
296
306
|
|
|
307
|
+
/** Drop every ⏳ OneSecond reaction this session is currently holding
|
|
308
|
+
* on user chat messages, then empty the two tracking maps. Used by
|
|
309
|
+
* every tear-down path (proc exit, kill, restart) so reactions don't
|
|
310
|
+
* outlive the conversation that placed them — without this, a Claude
|
|
311
|
+
* crash / daemon SIGTERM leaves orphan ⏳ stuck on user messages until
|
|
312
|
+
* Feishu's UI eventually GCs them (which it doesn't, in practice).
|
|
313
|
+
* closeTurnCard has its own release pass (with the slightly-early
|
|
314
|
+
* merged-batch trade-off documented there); this is the catastrophic-
|
|
315
|
+
* exit pass. Direct `deleteReaction` calls are fire-and-forget and
|
|
316
|
+
* swallow their own failures (see feishu.deleteReaction). */
|
|
317
|
+
private releaseAllReactions(): void {
|
|
318
|
+
for (const [msgId, rid] of [
|
|
319
|
+
...this.pendingReactionIds.entries(),
|
|
320
|
+
...this.currentBatchReactionIds.entries(),
|
|
321
|
+
]) {
|
|
322
|
+
if (rid) void feishu.deleteReaction(msgId, rid)
|
|
323
|
+
}
|
|
324
|
+
this.pendingReactionIds = new Map()
|
|
325
|
+
this.currentBatchReactionIds = new Map()
|
|
326
|
+
}
|
|
327
|
+
|
|
297
328
|
async stop(reason = '已终止'): Promise<void> {
|
|
298
329
|
if (!this.proc) {
|
|
299
330
|
this.status = 'stopped'
|
|
@@ -315,8 +346,7 @@ export class Session {
|
|
|
315
346
|
this.pendingUserMessageCount = 0
|
|
316
347
|
this.pendingMidTurnMsgs = []
|
|
317
348
|
this.lastUserOpenId = ''
|
|
318
|
-
this.
|
|
319
|
-
this.currentBatchReactionIds = new Map()
|
|
349
|
+
this.releaseAllReactions()
|
|
320
350
|
this.initCount = 0
|
|
321
351
|
this.openingTurn = false
|
|
322
352
|
this.pendingPermissions.clear()
|
|
@@ -336,8 +366,7 @@ export class Session {
|
|
|
336
366
|
this.pendingUserMessageCount = 0
|
|
337
367
|
this.pendingMidTurnMsgs = []
|
|
338
368
|
this.lastUserOpenId = ''
|
|
339
|
-
this.
|
|
340
|
-
this.currentBatchReactionIds = new Map()
|
|
369
|
+
this.releaseAllReactions()
|
|
341
370
|
this.initCount = 0
|
|
342
371
|
this.openingTurn = false
|
|
343
372
|
this.pendingPermissions.clear()
|
|
@@ -619,25 +648,37 @@ export class Session {
|
|
|
619
648
|
// - first message after spawn (init not yet fired)
|
|
620
649
|
// - bootstrap race (sibling msgs landing before init#1)
|
|
621
650
|
// - solo message after a prior turn has fully closed
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
651
|
+
// Eager-open path: open the card BEFORE feeding SDK, so a card-open
|
|
652
|
+
// failure doesn't strand the daemon with SDK processing a turn we
|
|
653
|
+
// have nowhere to render. `!openingTurn` means no sibling is mid-
|
|
654
|
+
// open; `initCount >= 1` means SDK boot init has fired (otherwise
|
|
655
|
+
// the init handler owns turn opening and we just feed the queue
|
|
656
|
+
// below). On failure openTurnCard surfaces a red banner via
|
|
657
|
+
// sendTextRaw; SDK was idle so no interrupt needed.
|
|
629
658
|
if (!this.openingTurn && this.initCount >= 1) {
|
|
630
|
-
// Eager open: SDK is healthy and idle, open card now. Any extra
|
|
631
|
-
// messages arriving during the open's Feishu API await pile up in
|
|
632
|
-
// the count and the init handler batches them.
|
|
633
659
|
this.openingTurn = true
|
|
634
|
-
this.pendingUserMessageCount--
|
|
635
660
|
try {
|
|
636
661
|
await this.openTurnCard(userOpenId, 'user_message')
|
|
662
|
+
if (!this.currentTurn) return
|
|
663
|
+
this.proc!.sendUserText(wireText, files)
|
|
664
|
+
this.pendingUserMessageCount++
|
|
637
665
|
this.status = 'working'
|
|
638
666
|
} finally {
|
|
639
667
|
this.openingTurn = false
|
|
640
668
|
}
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Non-eager path: either init hasn't fired yet (cold start) or a
|
|
673
|
+
// sibling onUserMessage is already opening. Feed SDK directly; the
|
|
674
|
+
// init handler / sibling card-opener will batch this message in.
|
|
675
|
+
this.proc!.sendUserText(wireText, files)
|
|
676
|
+
this.pendingUserMessageCount++
|
|
677
|
+
if (wasBusy && msgId) {
|
|
678
|
+
// Bootstrap race / sibling-opening race: until a card is open,
|
|
679
|
+
// the OneSecond ⏳ is the only ack the user gets. The init handler
|
|
680
|
+
// inherits these via currentBatchReactionIds when it opens.
|
|
681
|
+
trackReaction(msgId)
|
|
641
682
|
}
|
|
642
683
|
}
|
|
643
684
|
|
|
@@ -873,7 +914,19 @@ export class Session {
|
|
|
873
914
|
void (async () => {
|
|
874
915
|
try {
|
|
875
916
|
await this.openTurnCard(userOpenId, isUserBatch ? 'user_message' : 'scheduled')
|
|
876
|
-
this.
|
|
917
|
+
if (!this.currentTurn) {
|
|
918
|
+
// SDK already started this turn (its `init` is what got us
|
|
919
|
+
// here) but we have no card to render into. Interrupt so
|
|
920
|
+
// assistant/tool events aren't silently dropped while the
|
|
921
|
+
// model burns tokens. Release the reactions this batch
|
|
922
|
+
// inherited (init handler moved them above) — otherwise
|
|
923
|
+
// they stay ⏳ forever on the user's chat messages.
|
|
924
|
+
log(`session "${this.sessionName}": init-path openTurnCard failed — sendInterrupt + release reactions`)
|
|
925
|
+
this.proc?.sendInterrupt()
|
|
926
|
+
this.releaseAllReactions()
|
|
927
|
+
} else {
|
|
928
|
+
this.status = 'working'
|
|
929
|
+
}
|
|
877
930
|
} finally {
|
|
878
931
|
this.openingTurn = false
|
|
879
932
|
}
|
|
@@ -923,8 +976,7 @@ export class Session {
|
|
|
923
976
|
this.pendingUserMessageCount = 0
|
|
924
977
|
this.pendingMidTurnMsgs = []
|
|
925
978
|
this.lastUserOpenId = ''
|
|
926
|
-
this.
|
|
927
|
-
this.currentBatchReactionIds = new Map()
|
|
979
|
+
this.releaseAllReactions()
|
|
928
980
|
this.initCount = 0
|
|
929
981
|
this.openingTurn = false
|
|
930
982
|
this.status = 'stopped'
|
|
@@ -984,7 +1036,17 @@ export class Session {
|
|
|
984
1036
|
* calls wake the SDK polling loop (priority="now" semantics) and
|
|
985
1037
|
* comprise the input for the new turn. Opens the card here rather
|
|
986
1038
|
* than deferring to init because the init for this batch will arrive
|
|
987
|
-
* with `currentTurn` already set and bail.
|
|
1039
|
+
* with `currentTurn` already set and bail.
|
|
1040
|
+
*
|
|
1041
|
+
* Each sendUserText also bumps `pendingUserMessageCount`. The SDK
|
|
1042
|
+
* USUALLY collapses our N writes into one merged turn, but **not
|
|
1043
|
+
* always** — empirically observed 2026-05-17, test1 accumulator
|
|
1044
|
+
* session: when the first write lands in an idle SDK (turn just
|
|
1045
|
+
* ended), the SDK eagerly starts a turn for that msg alone, then
|
|
1046
|
+
* merges the rest into a second turn. Without the bump here, that
|
|
1047
|
+
* second turn fires an `init` with `pendingUserMessageCount === 0`
|
|
1048
|
+
* and the init handler misclassifies it as a scheduled wakeup,
|
|
1049
|
+
* painting the `⏰ 触发` banner on what is really a user batch. */
|
|
988
1050
|
private async drainMidTurnAndOpen(): Promise<void> {
|
|
989
1051
|
if (this.pendingMidTurnMsgs.length === 0) return
|
|
990
1052
|
const batch = this.pendingMidTurnMsgs
|
|
@@ -993,6 +1055,7 @@ export class Session {
|
|
|
993
1055
|
try {
|
|
994
1056
|
for (const msg of batch) {
|
|
995
1057
|
this.proc!.sendUserText(msg.wireText, msg.files)
|
|
1058
|
+
this.pendingUserMessageCount++
|
|
996
1059
|
if (msg.msgId) {
|
|
997
1060
|
const rid = this.pendingReactionIds.get(msg.msgId) ?? ''
|
|
998
1061
|
this.currentBatchReactionIds.set(msg.msgId, rid)
|
|
@@ -1031,11 +1094,10 @@ export class Session {
|
|
|
1031
1094
|
this.chatId,
|
|
1032
1095
|
'❌ 创建对话卡片失败 (Feishu SDK 重试 3 次后仍连不上)。你这条消息没能送到 Claude,请稍后重发。',
|
|
1033
1096
|
)
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
this.proc?.sendInterrupt()
|
|
1097
|
+
// currentTurn left null as the failure signal. Caller decides
|
|
1098
|
+
// whether to sendInterrupt: onUserMessage's eager-open path
|
|
1099
|
+
// hasn't fed SDK yet so doesn't need to; the init handler has
|
|
1100
|
+
// (SDK started the turn itself) and must.
|
|
1039
1101
|
return
|
|
1040
1102
|
}
|
|
1041
1103
|
let cardId: string
|
|
@@ -1066,32 +1128,42 @@ export class Session {
|
|
|
1066
1128
|
// and mutate `this.currentTurn` underfoot.
|
|
1067
1129
|
private appendAssistant(delta: string): void {
|
|
1068
1130
|
if (!this.currentTurn) return
|
|
1069
|
-
|
|
1131
|
+
const turn = this.currentTurn
|
|
1132
|
+
if (!turn.currentAssistantSegmentId) {
|
|
1070
1133
|
// New assistant segment opens a visual break — any prior Read run
|
|
1071
1134
|
// is now visually separated from future Reads, so close the batch
|
|
1072
1135
|
// window. Future Reads will start a fresh batch at a new i.
|
|
1073
|
-
|
|
1074
|
-
const i =
|
|
1136
|
+
turn.openReadBatchI = null
|
|
1137
|
+
const i = turn.assistantSegmentCount++
|
|
1075
1138
|
const segId = cards.ELEMENTS.assistant(i)
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
void cardkit.addElement(
|
|
1139
|
+
turn.currentAssistantSegmentId = segId
|
|
1140
|
+
turn.currentAssistantText = ''
|
|
1141
|
+
void cardkit.addElement(turn.cardId, cards.assistantSegmentElement(i), {
|
|
1079
1142
|
type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
|
|
1143
|
+
}, () => {
|
|
1144
|
+
// addElement永久失败:reset segmentId 让下次 delta 重新创建
|
|
1145
|
+
// segment,否则后续 streamText 全都 PUT 到不存在的 element,
|
|
1146
|
+
// 整段 assistant text 在用户那看不到。守 segId 不变以防 turn
|
|
1147
|
+
// rotation 或 addTool 已经清掉了它(每次 addElement 闭包带的
|
|
1148
|
+
// 是自己创建那次的 segId,只清自己的)。
|
|
1149
|
+
if (turn.currentAssistantSegmentId === segId) {
|
|
1150
|
+
log(`session "${this.sessionName}": assistant segment ${segId} addElement failed — will retry on next delta`)
|
|
1151
|
+
turn.currentAssistantSegmentId = null
|
|
1152
|
+
turn.currentAssistantText = ''
|
|
1153
|
+
turn.segmentTexts.delete(segId)
|
|
1154
|
+
}
|
|
1080
1155
|
})
|
|
1081
1156
|
}
|
|
1082
|
-
|
|
1083
|
-
const segId =
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
segId,
|
|
1088
|
-
this.currentTurn.currentAssistantText,
|
|
1089
|
-
)
|
|
1157
|
+
turn.currentAssistantText += delta
|
|
1158
|
+
const segId = turn.currentAssistantSegmentId
|
|
1159
|
+
if (!segId) return // addElement 已失败 reset,等下一次 delta 重建
|
|
1160
|
+
turn.segmentTexts.set(segId, turn.currentAssistantText)
|
|
1161
|
+
cardkit.streamTextThrottled(turn.cardId, segId, turn.currentAssistantText)
|
|
1090
1162
|
// Chat-list preview: tail of the latest assistant text. Feishu
|
|
1091
1163
|
// truncates anyway; ~60 chars is what shows on a typical phone
|
|
1092
1164
|
// preview line. patchSummaryThrottled is rate-limited on its own.
|
|
1093
|
-
const tail =
|
|
1094
|
-
cardkit.patchSummaryThrottled(
|
|
1165
|
+
const tail = turn.currentAssistantText.slice(-60)
|
|
1166
|
+
cardkit.patchSummaryThrottled(turn.cardId, tail)
|
|
1095
1167
|
}
|
|
1096
1168
|
|
|
1097
1169
|
private appendThinking(delta: string): void {
|