@leviyuan/lodestar 0.1.10 → 0.1.11
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 +1 -1
- package/package.json +1 -1
- package/src/cards.ts +10 -1
- package/src/feishu.ts +24 -4
- package/src/session.ts +351 -19
package/daemon.ts
CHANGED
|
@@ -175,7 +175,7 @@ async function handleMessage(data: any): Promise<void> {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
if (!text && !filePath) return
|
|
178
|
-
await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [], userOpenId)
|
|
178
|
+
await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [], userOpenId, msgId ?? '')
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
// ── Card action handler ────────────────────────────────────────────────
|
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -172,10 +172,18 @@ interface MainCardOpts {
|
|
|
172
172
|
model?: string
|
|
173
173
|
effort?: string
|
|
174
174
|
userText: string
|
|
175
|
+
/** What started this turn. `'scheduled'` adds a top-of-card banner so
|
|
176
|
+
* the user can tell a cron-fired wakeup apart from one of their own
|
|
177
|
+
* messages — the user's message bubble is otherwise the only visual
|
|
178
|
+
* cue, and scheduled turns have no preceding bubble in the chat. */
|
|
179
|
+
kind?: 'user_message' | 'scheduled'
|
|
175
180
|
}
|
|
176
181
|
|
|
177
182
|
/** Initial card sent at the start of each turn. Streaming on. */
|
|
178
|
-
export function mainConversationCard(
|
|
183
|
+
export function mainConversationCard(opts: MainCardOpts): object {
|
|
184
|
+
const banner = opts.kind === 'scheduled'
|
|
185
|
+
? [{ tag: 'markdown', content: '⏰ **定时任务触发** — Claude 在 idle 间隙被 CronCreate / ScheduleWakeup 唤醒' }]
|
|
186
|
+
: []
|
|
179
187
|
return {
|
|
180
188
|
schema: '2.0',
|
|
181
189
|
config: {
|
|
@@ -194,6 +202,7 @@ export function mainConversationCard(_opts: MainCardOpts): object {
|
|
|
194
202
|
// thinking element starts with a single space placeholder; the first
|
|
195
203
|
// real append overwrites it.
|
|
196
204
|
elements: [
|
|
205
|
+
...banner,
|
|
197
206
|
{ tag: 'markdown', element_id: ELEMENTS.thinking, content: ' ' },
|
|
198
207
|
{ tag: 'markdown', element_id: ELEMENTS.footer, content: '⏳ working…' },
|
|
199
208
|
],
|
package/src/feishu.ts
CHANGED
|
@@ -194,14 +194,34 @@ export async function sendCard(chatId: string, card: object): Promise<string | n
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
// ── Reactions ──────────────────────────────────────────────────────────
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
/** Add an emoji reaction. Returns the new reaction_id on success (needed
|
|
198
|
+
* to delete the reaction later via {@link deleteReaction}) or null on
|
|
199
|
+
* failure. Failures are logged and swallowed — reactions are non-load-
|
|
200
|
+
* bearing UX, not worth bubbling errors. */
|
|
201
|
+
export async function addReaction(messageId: string, emojiType: string): Promise<string | null> {
|
|
202
|
+
if (!messageId) return null
|
|
199
203
|
try {
|
|
200
|
-
await client.im.messageReaction.create({
|
|
204
|
+
const res: any = await client.im.messageReaction.create({
|
|
201
205
|
path: { message_id: messageId },
|
|
202
206
|
data: { reaction_type: { emoji_type: emojiType } },
|
|
203
207
|
})
|
|
204
|
-
|
|
208
|
+
return res?.data?.reaction_id ?? null
|
|
209
|
+
} catch (e) { log(`feishu: addReaction ${emojiType} on ${messageId} failed: ${e}`); return null }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Remove a previously-added reaction by its reaction_id (returned from
|
|
213
|
+
* {@link addReaction}). Used for the "queued → released" lifecycle: the
|
|
214
|
+
* OneSecond placed on arrival is *removed* when the daemon hands the
|
|
215
|
+
* message off to the SDK's batch / system-reminder pipeline, instead of
|
|
216
|
+
* stacking a second CheckMark on top — keeps the message's reaction row
|
|
217
|
+
* uncluttered. Quiet on failure. */
|
|
218
|
+
export async function deleteReaction(messageId: string, reactionId: string): Promise<void> {
|
|
219
|
+
if (!messageId || !reactionId) return
|
|
220
|
+
try {
|
|
221
|
+
await client.im.messageReaction.delete({
|
|
222
|
+
path: { message_id: messageId, reaction_id: reactionId },
|
|
223
|
+
})
|
|
224
|
+
} catch (e) { log(`feishu: deleteReaction ${reactionId} on ${messageId} failed: ${e}`) }
|
|
205
225
|
}
|
|
206
226
|
|
|
207
227
|
// ── Urgent push ───────────────────────────────────────────────────────
|
package/src/session.ts
CHANGED
|
@@ -28,6 +28,12 @@ interface TurnState {
|
|
|
28
28
|
* urgent_app push so only the initiator gets pinged (in case there
|
|
29
29
|
* are other members in the group). Empty string → skip the ping. */
|
|
30
30
|
userOpenId: string
|
|
31
|
+
/** What kicked off this turn. Only `'user_message'` turns fire the
|
|
32
|
+
* end-of-turn urgent_app push — scheduled / cron / loop wakeups
|
|
33
|
+
* finish on their own time and pinging the user would be noise,
|
|
34
|
+
* not signal. Ask / permission urgents inside the turn still fire
|
|
35
|
+
* regardless (those genuinely need attention even mid-schedule). */
|
|
36
|
+
trigger: 'user_message' | 'scheduled'
|
|
31
37
|
userText: string
|
|
32
38
|
thinkingText: string
|
|
33
39
|
toolCount: number
|
|
@@ -92,6 +98,79 @@ export class Session {
|
|
|
92
98
|
|
|
93
99
|
private proc: ClaudeProcess | null = null
|
|
94
100
|
private currentTurn: TurnState | null = null
|
|
101
|
+
/** Count of user messages we've written to Claude's stdin since the last
|
|
102
|
+
* turn opened on our side. NOT a FIFO of individual messages — the SDK
|
|
103
|
+
* batch-merges every mid-turn user message into a single combined turn
|
|
104
|
+
* once the in-flight turn finishes, so the daemon only ever observes
|
|
105
|
+
* **one** init event per batch (no matter how many Feishu messages went
|
|
106
|
+
* into the batch). Tracking a count + last-sender (rather than an
|
|
107
|
+
* Array<msg>) keeps the daemon's view in sync with the SDK's actual
|
|
108
|
+
* dequeue semantics. Empirically verified 2026-05-15 from the SDK's
|
|
109
|
+
* `queue-operation` transcript events: 4 enqueues during a long turn
|
|
110
|
+
* → single dequeue at turn end → one merged user message. Count is
|
|
111
|
+
* decremented to 0 wholesale at the `init` boundary because the SDK
|
|
112
|
+
* has already collapsed them into one turn. Distinguishes user-msg
|
|
113
|
+
* turns from cron-fired scheduled wakeups: count > 0 ⇒ user;
|
|
114
|
+
* count === 0 ⇒ scheduled (and `initCount > 1`). */
|
|
115
|
+
private pendingUserMessageCount = 0
|
|
116
|
+
/** Most recent userOpenId seen via `onUserMessage`. Used only when a
|
|
117
|
+
* merged batch fires its init event and the daemon needs *some* open_id
|
|
118
|
+
* to scope the eventual `urgent_app` push — there's no obviously right
|
|
119
|
+
* answer when N messages from possibly different users collapse into
|
|
120
|
+
* one turn, and "the most recent sender" is a defensible default for
|
|
121
|
+
* the single-user private-bot scenario this product targets. */
|
|
122
|
+
private lastUserOpenId = ''
|
|
123
|
+
/** Feishu message_ids of user messages that arrived while the daemon
|
|
124
|
+
* was busy (turn in flight or mid-open), mapped to the `reaction_id`
|
|
125
|
+
* of the `OneSecond` reaction placed at arrival. The reaction_id is
|
|
126
|
+
* what `deleteReaction` needs to *remove* the OneSecond once the
|
|
127
|
+
* message has been absorbed by the SDK (either system-reminder
|
|
128
|
+
* injection mid-turn or a merged-batch dequeue on next turn).
|
|
129
|
+
* User feedback (2026-05-15): replacing OneSecond with a second
|
|
130
|
+
* CheckMark stacked two emojis on the same row; cleaner UX is
|
|
131
|
+
* "queued → released" via removal, not "queued → done" via
|
|
132
|
+
* stacking. */
|
|
133
|
+
private pendingReactionIds = new Map<string, string>()
|
|
134
|
+
/** Snapshot of `pendingReactionIds` taken when the init handler
|
|
135
|
+
* claims a merged batch — these are the Feishu messages whose
|
|
136
|
+
* OneSecond reactions are the currently-open turn's responsibility
|
|
137
|
+
* to clear (via deleteReaction). Empty for eager-opened solo turns
|
|
138
|
+
* and for scheduled wakeups (no user messages went into those). */
|
|
139
|
+
private currentBatchReactionIds = new Map<string, string>()
|
|
140
|
+
/** Set the moment a mid-turn user message lands. Tells the next
|
|
141
|
+
* content-adding event (assistant text delta or fresh tool_use) to
|
|
142
|
+
* rotate the card before applying its update — closes the in-flight
|
|
143
|
+
* card with a `📨 转交新卡` footer and opens a fresh card, so the
|
|
144
|
+
* continuation has a visible boundary instead of piling up under
|
|
145
|
+
* one card. Reset to false after the rotation fires (or on
|
|
146
|
+
* stop/restart/exit). User feedback (2026-05-15): the prior
|
|
147
|
+
* everything-in-one-card behavior made the order feel jumbled. */
|
|
148
|
+
private wantsRotation = false
|
|
149
|
+
/** Holds assistant / thinking / tool_use events that arrive while a
|
|
150
|
+
* card rotation is mid-flight (close-old → open-new straddles a
|
|
151
|
+
* Feishu API await window during which `currentTurn` is transiently
|
|
152
|
+
* null). Replayed onto the new card the moment rotation completes
|
|
153
|
+
* so no streamed token is lost across the boundary. */
|
|
154
|
+
private rotationBuffer: Array<
|
|
155
|
+
| { kind: 'assistant'; delta: string }
|
|
156
|
+
| { kind: 'thinking'; delta: string }
|
|
157
|
+
| { kind: 'tool_use'; id: string; name: string; input: any }
|
|
158
|
+
> = []
|
|
159
|
+
/** Count of `system/init` events seen this subprocess. The first one is
|
|
160
|
+
* the boot init (claimed by whichever user message lands first); all
|
|
161
|
+
* subsequent ones mark the start of an SDK-initiated turn (queued
|
|
162
|
+
* user message draining or a CronCreate fire). Reset on stop/restart/exit
|
|
163
|
+
* since `init` re-fires after every spawn. */
|
|
164
|
+
private initCount = 0
|
|
165
|
+
/** Sync guard set before any `await` in the eager-open path of
|
|
166
|
+
* `onUserMessage`, cleared after `currentTurn` is set. Closes the race
|
|
167
|
+
* where an SDK-emitted `init` event lands during the eager open's
|
|
168
|
+
* Feishu API await — without this, the init handler would observe
|
|
169
|
+
* `currentTurn === null && queue empty` (we've already shifted) and
|
|
170
|
+
* incorrectly open a *second* scheduled card for the same user
|
|
171
|
+
* message. The flag tells the init handler "an eager open is already
|
|
172
|
+
* claiming the slot, stand down". */
|
|
173
|
+
private openingTurn = false
|
|
95
174
|
private pendingPermissions = new Map<string, { toolUseId: string }>()
|
|
96
175
|
/** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
|
|
97
176
|
* routes AskUserQuestion through the can_use_tool flow even under
|
|
@@ -228,6 +307,14 @@ export class Session {
|
|
|
228
307
|
this.lastSessionId = proc.sessionId ?? this.lastSessionId
|
|
229
308
|
this.proc = null
|
|
230
309
|
this.currentTurn = null
|
|
310
|
+
this.pendingUserMessageCount = 0
|
|
311
|
+
this.lastUserOpenId = ''
|
|
312
|
+
this.pendingReactionIds = new Map()
|
|
313
|
+
this.currentBatchReactionIds = new Map()
|
|
314
|
+
this.wantsRotation = false
|
|
315
|
+
this.rotationBuffer = []
|
|
316
|
+
this.initCount = 0
|
|
317
|
+
this.openingTurn = false
|
|
231
318
|
this.pendingPermissions.clear()
|
|
232
319
|
this.status = 'stopped'
|
|
233
320
|
await proc.kill()
|
|
@@ -242,6 +329,14 @@ export class Session {
|
|
|
242
329
|
this.proc = null
|
|
243
330
|
}
|
|
244
331
|
this.currentTurn = null
|
|
332
|
+
this.pendingUserMessageCount = 0
|
|
333
|
+
this.lastUserOpenId = ''
|
|
334
|
+
this.pendingReactionIds = new Map()
|
|
335
|
+
this.currentBatchReactionIds = new Map()
|
|
336
|
+
this.wantsRotation = false
|
|
337
|
+
this.rotationBuffer = []
|
|
338
|
+
this.initCount = 0
|
|
339
|
+
this.openingTurn = false
|
|
245
340
|
this.pendingPermissions.clear()
|
|
246
341
|
if (resume && prevSessionId) {
|
|
247
342
|
this.proc = new ClaudeProcess({
|
|
@@ -271,15 +366,18 @@ export class Session {
|
|
|
271
366
|
}
|
|
272
367
|
}
|
|
273
368
|
|
|
274
|
-
/** Run a bare-text control command (`hi`, `kill`, `restart`, `clear`).
|
|
369
|
+
/** Run a bare-text control command (`hi`, `stop`, `kill`, `restart`, `clear`).
|
|
275
370
|
* Returns true if the command was consumed (don't forward to Claude).
|
|
276
371
|
* Exact match, case-insensitive, ignores trailing whitespace.
|
|
277
372
|
*
|
|
278
|
-
* Trade-off (user-confirmed 2026-05-15):
|
|
373
|
+
* Trade-off (user-confirmed 2026-05-15): these words are reserved
|
|
279
374
|
* globally — typing "hi" as a literal greeting will show the console
|
|
280
375
|
* card instead of reaching Claude. The ergonomic win (no slash, no
|
|
281
376
|
* shift key, one-handed phone use) outweighs the collision in this
|
|
282
|
-
* product's private-bot use case.
|
|
377
|
+
* product's private-bot use case. `stop` was added 2026-05-15 once
|
|
378
|
+
* auto-interrupt on mid-turn user messages was removed (matching
|
|
379
|
+
* claude-code's native type-ahead behavior) — explicit barge-out
|
|
380
|
+
* needed a knob and `kill` (full subprocess teardown) is too heavy. */
|
|
283
381
|
async runCommand(raw: string): Promise<boolean> {
|
|
284
382
|
switch (raw.trim().toLowerCase()) {
|
|
285
383
|
case 'hi':
|
|
@@ -289,6 +387,38 @@ export class Session {
|
|
|
289
387
|
}
|
|
290
388
|
await this.showConsole()
|
|
291
389
|
return true
|
|
390
|
+
case 'stop':
|
|
391
|
+
// Soft barge-out: interrupt the current turn (if any) AND drop
|
|
392
|
+
// the pending-message count so a stack of type-ahead doesn't
|
|
393
|
+
// refire after the interrupt. Subprocess stays alive. Note: the
|
|
394
|
+
// SDK keeps its OWN internal queue of the user-text frames we
|
|
395
|
+
// already sendText'd — interrupt should also flush that side,
|
|
396
|
+
// but the daemon can't reach into it directly; in practice the
|
|
397
|
+
// sendInterrupt() control_request causes the SDK to discard
|
|
398
|
+
// queued input alongside the in-flight call.
|
|
399
|
+
if (!this.currentTurn && this.pendingUserMessageCount === 0) {
|
|
400
|
+
await feishu.sendText(this.chatId, '⚪ 当前没有正在执行的 turn')
|
|
401
|
+
return true
|
|
402
|
+
}
|
|
403
|
+
log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount}`)
|
|
404
|
+
// Cancelled queued msgs: remove the OneSecond (no longer waiting)
|
|
405
|
+
// and stamp a CrossMark (explicit cancelled state, distinct from
|
|
406
|
+
// a natural release where reactions just disappear). Cancelled
|
|
407
|
+
// mid-batch msgs get the same treatment.
|
|
408
|
+
for (const [msgId, rid] of [
|
|
409
|
+
...this.pendingReactionIds.entries(),
|
|
410
|
+
...this.currentBatchReactionIds.entries(),
|
|
411
|
+
]) {
|
|
412
|
+
if (rid) void feishu.deleteReaction(msgId, rid)
|
|
413
|
+
void feishu.addReaction(msgId, 'CrossMark')
|
|
414
|
+
}
|
|
415
|
+
this.pendingUserMessageCount = 0
|
|
416
|
+
this.lastUserOpenId = ''
|
|
417
|
+
this.pendingReactionIds = new Map()
|
|
418
|
+
this.currentBatchReactionIds = new Map()
|
|
419
|
+
this.wantsRotation = false
|
|
420
|
+
this.interrupt()
|
|
421
|
+
return true
|
|
292
422
|
case 'kill':
|
|
293
423
|
await this.stop()
|
|
294
424
|
return true
|
|
@@ -359,19 +489,62 @@ export class Session {
|
|
|
359
489
|
}
|
|
360
490
|
|
|
361
491
|
// ── Inbound from Feishu ────────────────────────────────────────────
|
|
362
|
-
|
|
492
|
+
/** Inbound user message. Always writes to Claude's stdin immediately —
|
|
493
|
+
* the SDK queues internally if a turn is in flight (FIFO, exactly the
|
|
494
|
+
* type-ahead semantics of the native claude-code REPL). Card opening:
|
|
495
|
+
* - First msg of session OR no turn in flight → open card eagerly here
|
|
496
|
+
* - Mid-flight msg → defer; the `init`
|
|
497
|
+
* handler opens its card when the SDK actually starts the turn
|
|
498
|
+
* This is what lets a single subprocess host both user-typed turns and
|
|
499
|
+
* cron-fired wakeups without the daemon ever calling `sendInterrupt` —
|
|
500
|
+
* `kill`/`stop` are the only paths that interrupt now. */
|
|
501
|
+
async onUserMessage(text: string, files: string[] = [], userOpenId = '', msgId = ''): Promise<void> {
|
|
363
502
|
if (!this.isRunning()) {
|
|
364
503
|
const ok = await this.start()
|
|
365
504
|
if (!ok) return
|
|
366
505
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
506
|
+
// Capture busy-state SYNC, before any state mutation — this decides
|
|
507
|
+
// whether the message will visibly queue (gets the OneSecond → later
|
|
508
|
+
// CheckMark lifecycle reactions on its Feishu chat message) or
|
|
509
|
+
// eager-open its own card (no reaction needed; the card itself is
|
|
510
|
+
// the acknowledgement).
|
|
511
|
+
const wasBusy = this.currentTurn !== null || this.openingTurn
|
|
512
|
+
this.pendingUserMessageCount++
|
|
513
|
+
this.lastUserOpenId = userOpenId
|
|
373
514
|
this.proc!.sendUserText(text, files)
|
|
374
|
-
|
|
515
|
+
if (wasBusy && msgId) {
|
|
516
|
+
// Hold the slot in the map even if the API call hasn't returned
|
|
517
|
+
// yet — empty string is a sentinel meaning "we tried to react;
|
|
518
|
+
// reaction_id pending". When deleteReaction time comes, an empty
|
|
519
|
+
// string is a no-op (deleteReaction guards against it), which is
|
|
520
|
+
// the right behavior if the add failed.
|
|
521
|
+
this.pendingReactionIds.set(msgId, '')
|
|
522
|
+
void (async () => {
|
|
523
|
+
const rid = await feishu.addReaction(msgId, 'OneSecond')
|
|
524
|
+
if (rid && this.pendingReactionIds.has(msgId)) {
|
|
525
|
+
this.pendingReactionIds.set(msgId, rid)
|
|
526
|
+
}
|
|
527
|
+
})()
|
|
528
|
+
// Rotation hint: a mid-turn user msg means the next assistant /
|
|
529
|
+
// tool event should split the visual into a new card.
|
|
530
|
+
this.wantsRotation = true
|
|
531
|
+
}
|
|
532
|
+
if (!this.currentTurn && !this.openingTurn && this.initCount >= 1) {
|
|
533
|
+
// Eager open: this message is going to be processed solo (no current
|
|
534
|
+
// turn to merge with on the SDK side, so SDK runs it as its own turn).
|
|
535
|
+
// Claim one count and open the card with this message's own text +
|
|
536
|
+
// sender; any *additional* messages arriving during the open's
|
|
537
|
+
// Feishu API await will pile up in the count and get batched by the
|
|
538
|
+
// SDK into the NEXT turn (handled by the init handler).
|
|
539
|
+
this.openingTurn = true
|
|
540
|
+
this.pendingUserMessageCount--
|
|
541
|
+
try {
|
|
542
|
+
await this.openTurnCard(text, userOpenId, 'user_message')
|
|
543
|
+
this.status = 'working'
|
|
544
|
+
} finally {
|
|
545
|
+
this.openingTurn = false
|
|
546
|
+
}
|
|
547
|
+
}
|
|
375
548
|
}
|
|
376
549
|
|
|
377
550
|
async onPermissionDecision(
|
|
@@ -420,6 +593,15 @@ export class Session {
|
|
|
420
593
|
return this.pendingAsks.size > 0
|
|
421
594
|
}
|
|
422
595
|
|
|
596
|
+
/** True iff a turn is currently running (or a queued user message is
|
|
597
|
+
* waiting for its turn to start). daemon uses this to drop a hourglass
|
|
598
|
+
* reaction on inbound messages — without it the user sees no visible
|
|
599
|
+
* acknowledgement that their type-ahead message landed (the card
|
|
600
|
+
* doesn't open until the current turn finishes). */
|
|
601
|
+
isBusy(): boolean {
|
|
602
|
+
return this.currentTurn !== null || this.pendingUserMessageCount > 0
|
|
603
|
+
}
|
|
604
|
+
|
|
423
605
|
/** Funnel an arbitrary chat message into the *current* question
|
|
424
606
|
* of the oldest pending ask as a `customText` answer. Multi-
|
|
425
607
|
* question semantics: from the user's perspective, the chat
|
|
@@ -575,6 +757,58 @@ export class Session {
|
|
|
575
757
|
this.lastSessionId = p.sessionId
|
|
576
758
|
feishu.bindSessionResume(this.sessionName, p.sessionId)
|
|
577
759
|
}
|
|
760
|
+
this.initCount++
|
|
761
|
+
|
|
762
|
+
// The boot init (initCount === 1) only happens once per spawn and
|
|
763
|
+
// is claimed by whichever user message gets processed first — that
|
|
764
|
+
// message's card is opened eagerly in `onUserMessage`, so the boot
|
|
765
|
+
// init itself opens nothing. EXCEPTION: if a user message landed
|
|
766
|
+
// before the boot init (rare race during start()), the queue has
|
|
767
|
+
// an entry — drain it here.
|
|
768
|
+
//
|
|
769
|
+
// Subsequent inits (initCount >= 2) mark the start of an SDK-
|
|
770
|
+
// initiated turn — either the SDK draining its internal type-ahead
|
|
771
|
+
// queue (we'll have an entry in `pendingUserMessages` mirroring
|
|
772
|
+
// it) or a CronCreate / ScheduleWakeup fire (queue empty). The
|
|
773
|
+
// `currentTurn` / `openingTurn` checks guard the race where
|
|
774
|
+
// `onUserMessage` already eager-opened (or is mid-open) for the
|
|
775
|
+
// same user message and the SDK emitted an init#≥2 we don't need
|
|
776
|
+
// to act on. The init handler ALSO claims `openingTurn` for its
|
|
777
|
+
// own async open so a user message landing during the open
|
|
778
|
+
// doesn't spawn a duplicate card.
|
|
779
|
+
if (this.currentTurn || this.openingTurn) return
|
|
780
|
+
// `pendingUserMessageCount > 0` ⇒ SDK is about to fire an init for a
|
|
781
|
+
// merged batch of one-or-more user messages we already sendText'd
|
|
782
|
+
// (the eager-open path didn't claim them because a turn was still
|
|
783
|
+
// running at the time). Claim the ENTIRE count here — the SDK
|
|
784
|
+
// collapses them into ONE turn, so only one card opens; any further
|
|
785
|
+
// messages that arrive after this point will start a fresh count
|
|
786
|
+
// and a fresh batch.
|
|
787
|
+
const isUserBatch = this.pendingUserMessageCount > 0
|
|
788
|
+
const isScheduledFire = !isUserBatch && this.initCount > 1
|
|
789
|
+
if (!isUserBatch && !isScheduledFire) return
|
|
790
|
+
const userOpenId = isUserBatch ? this.lastUserOpenId : ''
|
|
791
|
+
if (isUserBatch) {
|
|
792
|
+
this.pendingUserMessageCount = 0
|
|
793
|
+
// Inherit the queued reaction_ids — this turn is collectively
|
|
794
|
+
// responsible for releasing their OneSecond reactions when it
|
|
795
|
+
// closes (via deleteReaction in closeTurnCard).
|
|
796
|
+
this.currentBatchReactionIds = this.pendingReactionIds
|
|
797
|
+
this.pendingReactionIds = new Map()
|
|
798
|
+
}
|
|
799
|
+
this.openingTurn = true
|
|
800
|
+
void (async () => {
|
|
801
|
+
try {
|
|
802
|
+
await this.openTurnCard(
|
|
803
|
+
isUserBatch ? '' : '⏰ 定时唤醒',
|
|
804
|
+
userOpenId,
|
|
805
|
+
isUserBatch ? 'user_message' : 'scheduled',
|
|
806
|
+
)
|
|
807
|
+
this.status = 'working'
|
|
808
|
+
} finally {
|
|
809
|
+
this.openingTurn = false
|
|
810
|
+
}
|
|
811
|
+
})()
|
|
578
812
|
})
|
|
579
813
|
p.on('assistant_text', ({ text }: { text: string }) => {
|
|
580
814
|
this.appendAssistant(text)
|
|
@@ -604,6 +838,13 @@ export class Session {
|
|
|
604
838
|
log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
|
|
605
839
|
this.proc = null
|
|
606
840
|
this.currentTurn = null
|
|
841
|
+
this.pendingUserMessageCount = 0
|
|
842
|
+
this.lastUserOpenId = ''
|
|
843
|
+
this.pendingReactionIds = new Map()
|
|
844
|
+
this.currentBatchReactionIds = new Map()
|
|
845
|
+
this.wantsRotation = false
|
|
846
|
+
this.initCount = 0
|
|
847
|
+
this.openingTurn = false
|
|
607
848
|
this.status = 'stopped'
|
|
608
849
|
if (!expected && code !== 0 && signal !== 'SIGTERM') {
|
|
609
850
|
void feishu.sendText(this.chatId, `⚠️ Claude 异常退出 (code=${code}, signal=${signal})。回复任意消息将重新启动。`)
|
|
@@ -642,13 +883,14 @@ export class Session {
|
|
|
642
883
|
return this.lastTurnDelta?.inputTokens ?? 0
|
|
643
884
|
}
|
|
644
885
|
|
|
645
|
-
private async openTurnCard(userText: string, userOpenId: string): Promise<void> {
|
|
886
|
+
private async openTurnCard(userText: string, userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
|
|
646
887
|
const turn = ++this.turnCounter
|
|
647
888
|
const card = cards.mainConversationCard({
|
|
648
889
|
sessionName: this.sessionName,
|
|
649
890
|
turn,
|
|
650
891
|
effort: 'max',
|
|
651
892
|
userText,
|
|
893
|
+
kind: trigger,
|
|
652
894
|
})
|
|
653
895
|
const messageId = await feishu.sendCard(this.chatId, card)
|
|
654
896
|
if (!messageId) { log(`session "${this.sessionName}": openTurnCard sendCard failed`); return }
|
|
@@ -659,6 +901,7 @@ export class Session {
|
|
|
659
901
|
cardId,
|
|
660
902
|
messageId,
|
|
661
903
|
userOpenId,
|
|
904
|
+
trigger,
|
|
662
905
|
userText,
|
|
663
906
|
thinkingText: '',
|
|
664
907
|
toolCount: 0,
|
|
@@ -676,8 +919,43 @@ export class Session {
|
|
|
676
919
|
// forget here and rely on enqueue source order — that way no `await`
|
|
677
920
|
// can yield mid-handler and let `closeTurnCard` (or another event) race
|
|
678
921
|
// and mutate `this.currentTurn` underfoot.
|
|
922
|
+
/** Rotate to a fresh card mid-turn: close the in-flight card with a
|
|
923
|
+
* `📨 转交新卡` footer (distinct from `✅ done` and `🛑 打断`) and
|
|
924
|
+
* open a new card so the post-user-message continuation has a
|
|
925
|
+
* visible boundary. Streams that land during the rotation's await
|
|
926
|
+
* windows are buffered in `rotationBuffer` and replayed onto the
|
|
927
|
+
* new card the moment it's ready, so no tokens are lost across the
|
|
928
|
+
* cut. Caller guarantees `wantsRotation` was true sync-immediately
|
|
929
|
+
* before. */
|
|
930
|
+
private async rotateCard(): Promise<void> {
|
|
931
|
+
this.openingTurn = true
|
|
932
|
+
try {
|
|
933
|
+
await this.closeTurnCard('📨 转交新卡')
|
|
934
|
+
await this.openTurnCard('', this.lastUserOpenId, 'user_message')
|
|
935
|
+
} finally {
|
|
936
|
+
this.openingTurn = false
|
|
937
|
+
}
|
|
938
|
+
if (this.rotationBuffer.length === 0) return
|
|
939
|
+
const buf = this.rotationBuffer
|
|
940
|
+
this.rotationBuffer = []
|
|
941
|
+
for (const e of buf) {
|
|
942
|
+
if (e.kind === 'assistant') this.appendAssistant(e.delta)
|
|
943
|
+
else if (e.kind === 'thinking') this.appendThinking(e.delta)
|
|
944
|
+
else if (e.kind === 'tool_use') this.addTool(e.id, e.name, e.input)
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
679
948
|
private appendAssistant(delta: string): void {
|
|
680
|
-
if (!this.currentTurn)
|
|
949
|
+
if (!this.currentTurn) {
|
|
950
|
+
if (this.openingTurn) this.rotationBuffer.push({ kind: 'assistant', delta })
|
|
951
|
+
return
|
|
952
|
+
}
|
|
953
|
+
if (this.wantsRotation) {
|
|
954
|
+
this.wantsRotation = false
|
|
955
|
+
this.rotationBuffer.push({ kind: 'assistant', delta })
|
|
956
|
+
void this.rotateCard()
|
|
957
|
+
return
|
|
958
|
+
}
|
|
681
959
|
if (!this.currentTurn.currentAssistantSegmentId) {
|
|
682
960
|
const i = this.currentTurn.assistantSegmentCount++
|
|
683
961
|
const segId = cards.ELEMENTS.assistant(i)
|
|
@@ -703,7 +981,16 @@ export class Session {
|
|
|
703
981
|
}
|
|
704
982
|
|
|
705
983
|
private appendThinking(delta: string): void {
|
|
706
|
-
if (!this.currentTurn)
|
|
984
|
+
if (!this.currentTurn) {
|
|
985
|
+
if (this.openingTurn) this.rotationBuffer.push({ kind: 'thinking', delta })
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
if (this.wantsRotation) {
|
|
989
|
+
this.wantsRotation = false
|
|
990
|
+
this.rotationBuffer.push({ kind: 'thinking', delta })
|
|
991
|
+
void this.rotateCard()
|
|
992
|
+
return
|
|
993
|
+
}
|
|
707
994
|
this.currentTurn.thinkingText += delta
|
|
708
995
|
cardkit.streamTextThrottled(
|
|
709
996
|
this.currentTurn.cardId,
|
|
@@ -721,7 +1008,16 @@ export class Session {
|
|
|
721
1008
|
}
|
|
722
1009
|
|
|
723
1010
|
private addTool(toolUseId: string, name: string, input: any): void {
|
|
724
|
-
if (!this.currentTurn)
|
|
1011
|
+
if (!this.currentTurn) {
|
|
1012
|
+
if (this.openingTurn) this.rotationBuffer.push({ kind: 'tool_use', id: toolUseId, name, input })
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
if (this.wantsRotation) {
|
|
1016
|
+
this.wantsRotation = false
|
|
1017
|
+
this.rotationBuffer.push({ kind: 'tool_use', id: toolUseId, name, input })
|
|
1018
|
+
void this.rotateCard()
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
725
1021
|
// Close current assistant segment (if any) so the tool panel renders
|
|
726
1022
|
// AFTER it in card body order. Flush queues the segment's last
|
|
727
1023
|
// buffered delta before the tool element is inserted.
|
|
@@ -1007,7 +1303,11 @@ export class Session {
|
|
|
1007
1303
|
await cardkit.replaceElement(cardId, cards.ELEMENTS.thinking, cards.thinkingCollapsedPanel(thinkingText))
|
|
1008
1304
|
}
|
|
1009
1305
|
const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
|
|
1010
|
-
|
|
1306
|
+
// Suffix REPLACES the trailing `✅ done` — it represents a terminal
|
|
1307
|
+
// state distinct from natural completion (e.g. `📨 转交新卡` for a
|
|
1308
|
+
// mid-turn rotation). When absent, the turn ended cleanly.
|
|
1309
|
+
const stateMark = suffix ? ` · ${suffix}` : ' · ✅ done'
|
|
1310
|
+
const footer = `⏱ ${elapsed}s${sendNote}${stateMark}`
|
|
1011
1311
|
await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
|
|
1012
1312
|
// Final chat-list preview: clean finish shows "⏱ Xs · NK tokens";
|
|
1013
1313
|
// interrupted shows the suffix instead (no usage event landed).
|
|
@@ -1023,12 +1323,44 @@ export class Session {
|
|
|
1023
1323
|
|
|
1024
1324
|
// Phone push on clean turn close so the user knows Claude is done
|
|
1025
1325
|
// even with the chat backgrounded. Skip on interrupts (no real
|
|
1026
|
-
// completion)
|
|
1027
|
-
//
|
|
1028
|
-
|
|
1326
|
+
// completion), when we don't know who to ping, and when the turn
|
|
1327
|
+
// wasn't kicked off by the user typing a message — scheduled /
|
|
1328
|
+
// cron / loop wakeups finish on their own and shouldn't ping the
|
|
1329
|
+
// phone. Fire-and-forget; urgent_app failures are non-fatal and
|
|
1330
|
+
// already logged in feishu.ts.
|
|
1331
|
+
if (!suffix && turn.trigger === 'user_message' && turn.userOpenId && turn.messageId) {
|
|
1029
1332
|
void feishu.urgentApp(turn.messageId, [turn.userOpenId])
|
|
1030
1333
|
}
|
|
1031
1334
|
|
|
1335
|
+
// Release the OneSecond reactions on every queued Feishu message
|
|
1336
|
+
// this turn was responsible for. Two buckets:
|
|
1337
|
+
// 1. `currentBatchReactionIds` — msgs the init handler explicitly
|
|
1338
|
+
// claimed (SDK dequeued them as a merged next-turn batch).
|
|
1339
|
+
// 2. `pendingReactionIds` — msgs whose fate is invisible to the
|
|
1340
|
+
// daemon: the SDK either dequeued them as part of the
|
|
1341
|
+
// JUST-CLOSED turn OR injected them mid-turn as
|
|
1342
|
+
// `<system-reminder>` and silently removed them from the
|
|
1343
|
+
// queue (common when the current turn had tool calls).
|
|
1344
|
+
// Without visibility into queue-operation events the daemon
|
|
1345
|
+
// can't tell which; the safe default is "the prior turn just
|
|
1346
|
+
// ended, so the msg is at least *acknowledged* now —
|
|
1347
|
+
// release the OneSecond and let it stop saying 'queued',
|
|
1348
|
+
// instead of leaving it stuck permanently."
|
|
1349
|
+
// For merged-batch follow-ups, this releases slightly early
|
|
1350
|
+
// (before the merged turn actually runs), which is an
|
|
1351
|
+
// acceptable trade vs. msgs stuck under OneSecond forever.
|
|
1352
|
+
const releaseEntries = [
|
|
1353
|
+
...this.currentBatchReactionIds.entries(),
|
|
1354
|
+
...this.pendingReactionIds.entries(),
|
|
1355
|
+
]
|
|
1356
|
+
if (releaseEntries.length > 0) {
|
|
1357
|
+
for (const [msgId, rid] of releaseEntries) {
|
|
1358
|
+
if (rid) void feishu.deleteReaction(msgId, rid)
|
|
1359
|
+
}
|
|
1360
|
+
this.currentBatchReactionIds = new Map()
|
|
1361
|
+
this.pendingReactionIds = new Map()
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1032
1364
|
// Fire uploads sequentially AFTER the card is sealed so each file
|
|
1033
1365
|
// posts as its own Feishu message below the conversation card.
|
|
1034
1366
|
// Path gate: workDir (Claude's project sandbox), the inbox where
|