@leviyuan/lodestar 0.2.5 → 0.2.6

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 CHANGED
@@ -11,13 +11,13 @@
11
11
  */
12
12
 
13
13
  import * as lark from '@larksuiteoapi/node-sdk'
14
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
15
15
  import { dirname } from 'node:path'
16
16
  import { Session } from './src/session'
17
17
  import * as feishu from './src/feishu'
18
18
  import { config } from './src/config'
19
19
  import { log } from './src/log'
20
- import { PID_FILE } from './src/paths'
20
+ import { DEBUG_CTX_FILE, DEBUG_SOCK_FILE, PID_FILE } from './src/paths'
21
21
 
22
22
  // ── PID guard ───────────────────────────────────────────────────────────
23
23
  try {
@@ -44,6 +44,7 @@ const cleanup = () => {
44
44
  if (alive.length > 0) log(`alive marker: [${alive.join(', ')}]`)
45
45
  } catch (e) { log(`alive marker write failed: ${e}`) }
46
46
  try { unlinkSync(PID_FILE) } catch {}
47
+ try { unlinkSync(DEBUG_SOCK_FILE) } catch {}
47
48
  }
48
49
  process.on('exit', cleanup)
49
50
  process.on('SIGTERM', () => { log('SIGTERM'); cleanup(); process.exit(0) })
@@ -126,6 +127,29 @@ async function handleMessage(data: any): Promise<void> {
126
127
  }
127
128
 
128
129
  const chatId = message.chat_id as string
130
+
131
+ // `[DEBUG]` prefix — seed the inject context with the real chat/sender
132
+ // captured from a live WS event, then strip the prefix and continue as
133
+ // normal. The injector script (scripts/test-inject.ts) reads this
134
+ // context to replay arbitrary messages without the user touching Feishu.
135
+ let contentObjForDebug: any = {}
136
+ try { contentObjForDebug = JSON.parse(message.content ?? '{}') } catch {}
137
+ const debugTextRaw = (message.message_type === 'text' ? contentObjForDebug.text ?? '' : '')
138
+ if (typeof debugTextRaw === 'string' && debugTextRaw.startsWith('[DEBUG]')) {
139
+ try {
140
+ writeFileSync(DEBUG_CTX_FILE, JSON.stringify({
141
+ chat_id: chatId,
142
+ sender_open_id: userOpenId,
143
+ seeded_at: new Date().toISOString(),
144
+ seeded_msg_id: msgId ?? '',
145
+ }, null, 2))
146
+ log(`debug: seeded inject context chat=${chatId.slice(0, 8)}… sender=${userOpenId.slice(0, 8)}…`)
147
+ } catch (e) { log(`debug: seed context failed: ${e}`) }
148
+ const stripped = debugTextRaw.slice('[DEBUG]'.length)
149
+ contentObjForDebug.text = stripped
150
+ message.content = JSON.stringify(contentObjForDebug)
151
+ }
152
+
129
153
  let groupName = feishu.chatNameCache.get(chatId)
130
154
  if (!groupName) {
131
155
  await feishu.refreshChatList()
@@ -218,6 +242,55 @@ function fmt(m: any[]): string {
218
242
  return m.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')
219
243
  }
220
244
 
245
+ // ── Debug message injection ─────────────────────────────────────────────
246
+ // Listens on a unix socket so scripts/test-inject.ts can replay messages
247
+ // through the same `handleMessage` path that real WS events take. Seeded
248
+ // by a one-time `[DEBUG]<anything>` from the real Feishu user; from then
249
+ // on the injector reuses that chat_id + sender_open_id.
250
+ function startDebugSocket(): void {
251
+ try { if (existsSync(DEBUG_SOCK_FILE)) unlinkSync(DEBUG_SOCK_FILE) } catch {}
252
+ try {
253
+ Bun.serve({
254
+ unix: DEBUG_SOCK_FILE,
255
+ fetch: async (req: Request) => {
256
+ if (req.method !== 'POST') return new Response('use POST', { status: 405 })
257
+ let body: any = {}
258
+ try { body = await req.json() } catch { return new Response('bad json', { status: 400 }) }
259
+ if (!existsSync(DEBUG_CTX_FILE)) {
260
+ return new Response('no debug context yet — send `[DEBUG]hi` from Feishu first', { status: 412 })
261
+ }
262
+ let ctx: any = {}
263
+ try { ctx = JSON.parse(readFileSync(DEBUG_CTX_FILE, 'utf8')) } catch (e) {
264
+ return new Response(`ctx read failed: ${e}`, { status: 500 })
265
+ }
266
+ const text: string = String(body.text ?? '')
267
+ if (!text) return new Response('text required', { status: 400 })
268
+ const fakeMsgId = `om_DEBUG_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
269
+ const payload = {
270
+ sender: { sender_id: { open_id: ctx.sender_open_id } },
271
+ message: {
272
+ message_id: fakeMsgId,
273
+ chat_id: ctx.chat_id,
274
+ message_type: 'text',
275
+ content: JSON.stringify({ text }),
276
+ create_time: String(Date.now()),
277
+ },
278
+ }
279
+ log(`debug: inject text=${JSON.stringify(text).slice(0, 80)} fake_id=${fakeMsgId}`)
280
+ // Don't await — match real WS dispatcher behavior (fire-and-forget per event).
281
+ handleMessage(payload).catch(e => log(`debug: handleMessage rejected: ${e}`))
282
+ return new Response(JSON.stringify({ ok: true, fake_msg_id: fakeMsgId }), {
283
+ headers: { 'content-type': 'application/json' },
284
+ })
285
+ },
286
+ })
287
+ try { chmodSync(DEBUG_SOCK_FILE, 0o600) } catch {}
288
+ log(`debug: inject socket listening at ${DEBUG_SOCK_FILE}`)
289
+ } catch (e) {
290
+ log(`debug: socket bind failed: ${e}`)
291
+ }
292
+ }
293
+
221
294
  async function boot(): Promise<void> {
222
295
  log(`lodestar-daemon: pid ${process.pid} starting`)
223
296
  feishu.loadSessionChatMap()
@@ -269,6 +342,8 @@ async function boot(): Promise<void> {
269
342
  ws.start({ eventDispatcher: dispatcher })
270
343
  log(`lodestar-daemon: WS started, watching ${feishu.chatNameCache.size} groups`)
271
344
 
345
+ startDebugSocket()
346
+
272
347
  // Auto-revive sessions that were running when we last went down.
273
348
  // Runs AFTER the WS is up so any 🔁 revive message lands in the
274
349
  // right chat instead of disappearing into the void.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cardkit.ts CHANGED
@@ -73,11 +73,64 @@ async function call(method: string, path: string, body?: object): Promise<any> {
73
73
  })
74
74
  const json = await res.json() as any
75
75
  if (json?.code && json.code !== 0) {
76
- throw new Error(`cardkit ${method} ${path}: code=${json.code} msg=${json.msg}`)
76
+ const e = new Error(`cardkit ${method} ${path}: code=${json.code} msg=${json.msg}`) as Error & { code: number }
77
+ e.code = json.code
78
+ throw e
77
79
  }
78
80
  return json?.data
79
81
  }
80
82
 
83
+ function isStreamingClosed(e: unknown): boolean {
84
+ return typeof e === 'object' && e !== null && (e as any).code === 300309
85
+ }
86
+
87
+ /** Reopen streaming_mode on a card that Feishu auto-closed after its
88
+ * 10-minute streaming TTL (no keepalive, no idle reset — the timer
89
+ * starts when streaming is opened and fires regardless of activity).
90
+ * Called from inside the per-card queue's catch path, so it allocates
91
+ * its own sequence and runs inline without re-enqueueing. */
92
+ async function reopenStreaming(cardId: string): Promise<void> {
93
+ const seq = nextSeq(cardId)
94
+ await call('PATCH', `/cards/${cardId}/settings`, {
95
+ settings: JSON.stringify({ config: { streaming_mode: true } }),
96
+ sequence: seq,
97
+ })
98
+ }
99
+
100
+ /** Run `op` inside the per-card queue. If it fails with code=300309
101
+ * (Feishu auto-closed streaming after the 10-minute TTL), reopen
102
+ * streaming inline and retry `op` exactly once. Anything else — non-
103
+ * 300309 failure, reopen failure, retry failure — is logged and
104
+ * swallowed, matching the fire-and-forget contract every cardkit op
105
+ * already has at the call sites. */
106
+ async function withReopenOnStreamingClosed(
107
+ cardId: string,
108
+ label: string,
109
+ op: () => Promise<void>,
110
+ ): Promise<void> {
111
+ try {
112
+ await op()
113
+ return
114
+ } catch (e) {
115
+ if (!isStreamingClosed(e)) {
116
+ log(`cardkit ${label} ${cardId}: ${e}`)
117
+ return
118
+ }
119
+ log(`cardkit ${label} ${cardId}: streaming closed (300309) — reopening`)
120
+ }
121
+ try {
122
+ await reopenStreaming(cardId)
123
+ } catch (re) {
124
+ log(`cardkit STREAMING_REOPEN_FAILED ${cardId}: ${re}`)
125
+ return
126
+ }
127
+ try {
128
+ await op()
129
+ } catch (e2) {
130
+ log(`cardkit ${label} ${cardId} retry-after-reopen: ${e2}`)
131
+ }
132
+ }
133
+
81
134
  /** Convert a sent interactive message into a card entity. */
82
135
  export async function convertMessageToCard(messageId: string): Promise<string> {
83
136
  const data = await call('POST', '/cards/id_convert', { message_id: messageId })
@@ -101,17 +154,17 @@ export async function createCardEntity(card: object): Promise<string> {
101
154
  export function streamText(cardId: string, elementId: string, content: string): Promise<void> {
102
155
  if (!content || !content.trim()) return Promise.resolve()
103
156
  const s = state(cardId)
104
- const seq = nextSeq(cardId)
105
- s.queue = s.queue.then(async () => {
106
- try {
157
+ s.queue = s.queue.then(() => withReopenOnStreamingClosed(
158
+ cardId,
159
+ `streamText ${elementId}`,
160
+ async () => {
161
+ const seq = nextSeq(cardId)
107
162
  await call('PUT', `/cards/${cardId}/elements/${elementId}/content`, {
108
163
  content, sequence: seq,
109
164
  })
110
165
  s.lastSent.set(elementId, content)
111
- } catch (e) {
112
- log(`cardkit streamText ${cardId}/${elementId}: ${e}`)
113
- }
114
- })
166
+ },
167
+ ))
115
168
  return s.queue
116
169
  }
117
170
 
@@ -155,46 +208,52 @@ export function addElement(
155
208
  opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
156
209
  ): Promise<void> {
157
210
  const s = state(cardId)
158
- const seq = nextSeq(cardId)
159
- s.queue = s.queue.then(async () => {
160
- try {
211
+ s.queue = s.queue.then(() => withReopenOnStreamingClosed(
212
+ cardId,
213
+ `addElement`,
214
+ async () => {
215
+ const seq = nextSeq(cardId)
161
216
  await call('POST', `/cards/${cardId}/elements`, {
162
217
  type: opts.type ?? 'append',
163
218
  ...(opts.targetElementId ? { target_element_id: opts.targetElementId } : {}),
164
219
  elements: JSON.stringify([element]),
165
220
  sequence: seq,
166
221
  })
167
- } catch (e) { log(`cardkit addElement ${cardId}: ${e}`) }
168
- })
222
+ },
223
+ ))
169
224
  return s.queue
170
225
  }
171
226
 
172
227
  /** Replace an entire element (used to swap a tool placeholder with its result). */
173
228
  export function replaceElement(cardId: string, elementId: string, element: object): Promise<void> {
174
229
  const s = state(cardId)
175
- const seq = nextSeq(cardId)
176
- s.queue = s.queue.then(async () => {
177
- try {
230
+ s.queue = s.queue.then(() => withReopenOnStreamingClosed(
231
+ cardId,
232
+ `replaceElement ${elementId}`,
233
+ async () => {
234
+ const seq = nextSeq(cardId)
178
235
  await call('PUT', `/cards/${cardId}/elements/${elementId}`, {
179
236
  element: JSON.stringify(element),
180
237
  sequence: seq,
181
238
  })
182
- } catch (e) { log(`cardkit replaceElement ${cardId}/${elementId}: ${e}`) }
183
- })
239
+ },
240
+ ))
184
241
  return s.queue
185
242
  }
186
243
 
187
244
  /** Delete an element by id. */
188
245
  export function deleteElement(cardId: string, elementId: string): Promise<void> {
189
246
  const s = state(cardId)
190
- const seq = nextSeq(cardId)
191
- s.queue = s.queue.then(async () => {
192
- try {
247
+ s.queue = s.queue.then(() => withReopenOnStreamingClosed(
248
+ cardId,
249
+ `deleteElement ${elementId}`,
250
+ async () => {
251
+ const seq = nextSeq(cardId)
193
252
  await call('DELETE', `/cards/${cardId}/elements/${elementId}`, {
194
253
  sequence: seq,
195
254
  })
196
- } catch (e) { log(`cardkit deleteElement ${cardId}/${elementId}: ${e}`) }
197
- })
255
+ },
256
+ ))
198
257
  return s.queue
199
258
  }
200
259
 
@@ -235,12 +294,22 @@ export function cancelSummary(cardId: string): void {
235
294
  summaryStates.delete(cardId)
236
295
  }
237
296
 
238
- /** Patch settings — used to flip streaming_mode off when a turn finishes. */
297
+ /** Patch settings — used to flip streaming_mode off when a turn finishes.
298
+ *
299
+ * `nextSeq` is called inside the queued task (not at enqueue time) to
300
+ * match streamText/addElement/replaceElement/deleteElement above. Mixing
301
+ * call-time and execution-time seq allocation interleaves badly: a
302
+ * patchSettings enqueued right after a replaceElement would grab the
303
+ * smaller seq number, but the replaceElement's then-block would grab
304
+ * the larger one when it ran first, so the patchSettings PATCH lands
305
+ * with a stale seq and Feishu rejects 300317 "sequence number compare
306
+ * failed". Keeping all writes on execution-time allocation makes the
307
+ * seq order match the queue order. */
239
308
  export function patchSettings(cardId: string, settings: object): Promise<void> {
240
309
  const s = state(cardId)
241
- const seq = nextSeq(cardId)
242
310
  s.queue = s.queue.then(async () => {
243
311
  try {
312
+ const seq = nextSeq(cardId)
244
313
  await call('PATCH', `/cards/${cardId}/settings`, {
245
314
  settings: JSON.stringify(settings),
246
315
  sequence: seq,
package/src/cards.ts CHANGED
@@ -74,7 +74,7 @@ export function summarizeToolInput(name: string, input: any): string {
74
74
  return truncate(summarizeTaskWorkflow(name, input), 80)
75
75
  }
76
76
  switch (name) {
77
- case 'Bash': return truncate(String(input.command ?? ''), 80)
77
+ case 'Bash': return truncate(String(input.description ?? input.command ?? ''), 80)
78
78
  case 'Read':
79
79
  case 'Write':
80
80
  case 'Edit':
@@ -181,7 +181,7 @@ interface MainCardOpts {
181
181
  /** Initial card sent at the start of each turn. Streaming on. */
182
182
  export function mainConversationCard(opts: MainCardOpts): object {
183
183
  const banner = opts.kind === 'scheduled'
184
- ? [{ tag: 'markdown', content: '⏰ **定时任务触发** — Claude 在 idle 间隙被 CronCreate / ScheduleWakeup 唤醒' }]
184
+ ? [{ tag: 'markdown', content: '⏰ 触发' }]
185
185
  : []
186
186
  return {
187
187
  schema: '2.0',
@@ -276,6 +276,30 @@ export function toolCallElement(
276
276
  }
277
277
  }
278
278
 
279
+ /** Merged panel for a run of consecutive `Read` tool calls in one turn.
280
+ * Header shows the dynamic count (`Read · 3 次`), body lists one row per
281
+ * Read with its own status + file path. Replaces the individual panels
282
+ * once a second Read joins the batch — single Reads still render as the
283
+ * full `toolCallElement` (with file-contents dump on completion). */
284
+ export function readBatchElement(
285
+ i: number,
286
+ items: Array<{ input: any; output: string | null; isError: boolean }>,
287
+ ): object {
288
+ const n = items.length
289
+ const anyError = items.some(it => it.isError)
290
+ const allDone = items.every(it => it.output !== null)
291
+ const status = anyError ? '❌' : allDone ? '✅' : '⏳'
292
+ const headerText = `${status} 🔧 Read · ${n} 次`
293
+ const lines = items.map(it => `\`${String(it.input.file_path ?? '(无 path)')}\``)
294
+ return {
295
+ tag: 'collapsible_panel',
296
+ element_id: ELEMENTS.tool(i),
297
+ header: { title: { tag: 'plain_text', content: headerText } },
298
+ expanded: false,
299
+ elements: [{ tag: 'markdown', content: lines.join('\n') }],
300
+ }
301
+ }
302
+
279
303
  /** Same tool panel as `toolCallElement`, but with the 🔐 status and
280
304
  * three inline action buttons (allow / allow_always / deny). Expanded
281
305
  * by default so the user can read the request without clicking through.
package/src/paths.ts CHANGED
@@ -47,3 +47,11 @@ export const SESSION_RESUME_MAP_FILE = join(DATA_DIR, 'session-resume-map.json')
47
47
  * Lodestar's "you have to send a message to re-spawn it" default. */
48
48
  export const ALIVE_MARKER_FILE = join(DATA_DIR, 'alive-on-shutdown.json')
49
49
  export const INBOX_DIR = join(DATA_DIR, 'inbox')
50
+ /** Unix-socket endpoint the daemon listens on for debug message
51
+ * injection (see scripts/test-inject.ts). A real Feishu user sends
52
+ * a `[DEBUG]…` text once to seed `DEBUG_CTX_FILE` with chat_id +
53
+ * sender_open_id; the injector then POSTs `{text}` here and the
54
+ * daemon replays it through `handleMessage` as if WS had delivered
55
+ * it. File mode 0600 — daemon-private, never network-exposed. */
56
+ export const DEBUG_SOCK_FILE = join(DATA_DIR, 'debug.sock')
57
+ export const DEBUG_CTX_FILE = join(DATA_DIR, 'debug-context.json')
package/src/session.ts CHANGED
@@ -47,7 +47,23 @@ interface TurnState {
47
47
  resolvedNote?: string
48
48
  output?: string
49
49
  isError?: boolean
50
+ /** Set when this tool is part of a merged Read batch — points to the
51
+ * batch's slot in `readBatches[i].items`. completeTool uses it to
52
+ * update the right row instead of rendering a standalone panel. */
53
+ readBatchSlot?: number
50
54
  }>
55
+ /** Consecutive `Read` calls collapse into a single panel rendered by
56
+ * `cards.readBatchElement`. Keyed by element index `i` so completeTool
57
+ * can find the batch after its open-window closed (a non-Read tool or
58
+ * new assistant segment has since arrived).
59
+ *
60
+ * `openReadBatchI` is the i of the batch currently accepting new Reads;
61
+ * null once the run ends. Subsequent Read calls open a fresh batch at a
62
+ * new i. */
63
+ readBatches: Map<number, {
64
+ items: Array<{ toolUseId: string; input: any; output: string | null; isError: boolean }>
65
+ }>
66
+ openReadBatchI: number | null
51
67
  assistantSegmentCount: number
52
68
  currentAssistantSegmentId: string | null
53
69
  currentAssistantText: string
@@ -112,6 +128,15 @@ export class Session {
112
128
  * turns from cron-fired scheduled wakeups: count > 0 ⇒ user;
113
129
  * count === 0 ⇒ scheduled (and `initCount > 1`). */
114
130
  private pendingUserMessageCount = 0
131
+ /** Mid-turn user messages buffered DAEMON-SIDE (not yet sendUserText'd
132
+ * to the SDK). Drained in the `result` handler by writing each to SDK
133
+ * stdin, which doubles as the `priority="now"` wake signal the SDK
134
+ * polling loop needs to start the next batch turn (the SDK won't
135
+ * auto-dequeue queued type-ahead msgs after `result` — confirmed via
136
+ * claude-code issue #39632). Buffering also keeps mid-turn msgs out
137
+ * of any AskUserQuestion `QUEUE remove` storm, since they were never
138
+ * in the SDK queue to begin with. */
139
+ private pendingMidTurnMsgs: Array<{ wireText: string; files: string[]; userOpenId: string; msgId: string }> = []
115
140
  /** Most recent userOpenId seen via `onUserMessage`. Used only when a
116
141
  * merged batch fires its init event and the daemon needs *some* open_id
117
142
  * to scope the eventual `urgent_app` push — there's no obviously right
@@ -288,6 +313,7 @@ export class Session {
288
313
  this.proc = null
289
314
  this.currentTurn = null
290
315
  this.pendingUserMessageCount = 0
316
+ this.pendingMidTurnMsgs = []
291
317
  this.lastUserOpenId = ''
292
318
  this.pendingReactionIds = new Map()
293
319
  this.currentBatchReactionIds = new Map()
@@ -308,6 +334,7 @@ export class Session {
308
334
  }
309
335
  this.currentTurn = null
310
336
  this.pendingUserMessageCount = 0
337
+ this.pendingMidTurnMsgs = []
311
338
  this.lastUserOpenId = ''
312
339
  this.pendingReactionIds = new Map()
313
340
  this.currentBatchReactionIds = new Map()
@@ -372,11 +399,11 @@ export class Session {
372
399
  // but the daemon can't reach into it directly; in practice the
373
400
  // sendInterrupt() control_request causes the SDK to discard
374
401
  // queued input alongside the in-flight call.
375
- if (!this.currentTurn && this.pendingUserMessageCount === 0) {
402
+ if (!this.currentTurn && this.pendingUserMessageCount === 0 && this.pendingMidTurnMsgs.length === 0) {
376
403
  await feishu.sendText(this.chatId, '⚪ 当前没有正在执行的 turn')
377
404
  return true
378
405
  }
379
- log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount}`)
406
+ log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length}`)
380
407
  // Cancelled queued msgs: remove the OneSecond (no longer waiting)
381
408
  // and stamp a CrossMark (explicit cancelled state, distinct from
382
409
  // a natural release where reactions just disappear). Cancelled
@@ -388,11 +415,20 @@ export class Session {
388
415
  if (rid) void feishu.deleteReaction(msgId, rid)
389
416
  void feishu.addReaction(msgId, 'CrossMark')
390
417
  }
418
+ // Mid-turn buffer never reached SDK — cancel those too.
419
+ for (const msg of this.pendingMidTurnMsgs) {
420
+ if (msg.msgId) void feishu.addReaction(msg.msgId, 'CrossMark')
421
+ }
391
422
  this.pendingUserMessageCount = 0
423
+ this.pendingMidTurnMsgs = []
392
424
  this.lastUserOpenId = ''
393
425
  this.pendingReactionIds = new Map()
394
426
  this.currentBatchReactionIds = new Map()
395
427
  this.interrupt()
428
+ // SDK 收到 interrupt 后不发 `result`,没人会触发 closeTurnCard。
429
+ // 这里主动封口,把 footer 改成 🛑 打断、折叠 thinking、把
430
+ // streaming_mode 翻回 false,否则卡片会僵在 `⏳ working…`。
431
+ await this.closeTurnCard('🛑 打断')
396
432
  return true
397
433
  case 'kill':
398
434
  await this.stop()
@@ -532,8 +568,8 @@ export class Session {
532
568
  // reaction) and the model would see e.g. "123" + "321" + "1"
533
569
  // glued into a single string "1233211" (2026-05-16 accumulator
534
570
  // bug).
535
- const wasBusy = this.currentTurn !== null || this.openingTurn || this.pendingUserMessageCount > 0
536
- this.pendingUserMessageCount++
571
+ const wasBusy = this.currentTurn !== null || this.openingTurn
572
+ || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
537
573
  this.lastUserOpenId = userOpenId
538
574
  // When the SDK will merge this msg with siblings into a multi-
539
575
  // content user turn, wrap it in `<u>...</u>` so the model sees a
@@ -547,51 +583,53 @@ export class Session {
547
583
  // skips the wrap — no sibling, no merge, no need. Contract
548
584
  // declared in CHANNEL_INSTRUCTIONS.
549
585
  const wireText = wasBusy ? `<u>${text}</u>` : text
550
- this.proc!.sendUserText(wireText, files)
551
- if (wasBusy && msgId) {
552
- // Hold the slot in the map even if the API call hasn't returned
553
- // yet empty string is a sentinel meaning "we tried to react;
554
- // reaction_id pending". When deleteReaction time comes, an empty
555
- // string is a no-op (deleteReaction guards against it), which is
556
- // the right behavior if the add failed.
557
- this.pendingReactionIds.set(msgId, '')
586
+
587
+ // Reaction helper: track the OneSecond reaction so deleteReaction can
588
+ // clear it later. Use empty-string sentinel until addReaction returns.
589
+ const trackReaction = (id: string) => {
590
+ this.pendingReactionIds.set(id, '')
558
591
  void (async () => {
559
- const rid = await feishu.addReaction(msgId, 'OneSecond')
592
+ const rid = await feishu.addReaction(id, 'OneSecond')
560
593
  if (!rid) return
561
- if (this.pendingReactionIds.has(msgId)) {
562
- this.pendingReactionIds.set(msgId, rid)
563
- } else if (this.currentBatchReactionIds.has(msgId)) {
564
- // Init handler renamed the sentinel into the batch map while
565
- // addReaction was in flight — record the rid there so the
566
- // batch's close-time deleteReaction sees it.
567
- this.currentBatchReactionIds.set(msgId, rid)
594
+ if (this.pendingReactionIds.has(id)) {
595
+ this.pendingReactionIds.set(id, rid)
596
+ } else if (this.currentBatchReactionIds.has(id)) {
597
+ this.currentBatchReactionIds.set(id, rid)
568
598
  } else {
569
- // Orphan: both maps cleared (closeTurnCard already released
570
- // them) before our add returned. The reaction is now stuck
571
- // on the Feishu message with no one tracking it — delete
599
+ // Orphan: both maps cleared before our add returned. Delete
572
600
  // directly so the user doesn't see a stale ⏳ forever.
573
- // (Observed bug 2026-05-16: 8 OneSeconds added during a M0
574
- // turn, 2 addReaction callbacks landed after close fired the
575
- // release loop, those rids never made it back into either
576
- // map → 2 stuck ⏳ in chat.)
577
- void feishu.deleteReaction(msgId, rid)
601
+ void feishu.deleteReaction(id, rid)
578
602
  }
579
603
  })()
580
604
  }
581
- // Mid-turn user messages don't touch the in-flight card — the SDK
582
- // queues them and dequeues them on its next turn boundary, at
583
- // which point `result` closes the current card with `📨 转交新卡`
584
- // and `init` opens a fresh card for the merged batch turn. The
585
- // user's own message bubble in the chat (plus the OneSecond ⏳
586
- // reaction added above) is the only mid-flight feedback they get;
587
- // no card edit, no echo inside the card.
588
- if (!this.currentTurn && !this.openingTurn && this.initCount >= 1) {
589
- // Eager open: this message is going to be processed solo (no current
590
- // turn to merge with on the SDK side, so SDK runs it as its own turn).
591
- // Claim one count and open the card with this message's own text +
592
- // sender; any *additional* messages arriving during the open's
593
- // Feishu API await will pile up in the count and get batched by the
594
- // SDK into the NEXT turn (handled by the init handler).
605
+
606
+ if (this.currentTurn !== null) {
607
+ // Mid-turn BUFFER instead of immediate sendUserText. The SDK polling
608
+ // loop will not auto-dequeue queued type-ahead msgs after `result`
609
+ // (only `priority="now"` writes wake it claude-code issue #39632),
610
+ // so writing here would leave the msg stuck until the next user msg
611
+ // arrives. Drain happens in the `result` handler, which both wakes
612
+ // the SDK and opens a fresh card for the new batch turn.
613
+ this.pendingMidTurnMsgs.push({ wireText, files, userOpenId, msgId })
614
+ if (msgId) trackReaction(msgId)
615
+ return
616
+ }
617
+
618
+ // No in-flight turn: send straight to SDK. This path handles
619
+ // - first message after spawn (init not yet fired)
620
+ // - bootstrap race (sibling msgs landing before init#1)
621
+ // - solo message after a prior turn has fully closed
622
+ this.proc!.sendUserText(wireText, files)
623
+ this.pendingUserMessageCount++
624
+ if (wasBusy && msgId) {
625
+ // Bootstrap race: the init handler will open the card for us; until
626
+ // then the OneSecond ⏳ is the only ack the user gets.
627
+ trackReaction(msgId)
628
+ }
629
+ 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.
595
633
  this.openingTurn = true
596
634
  this.pendingUserMessageCount--
597
635
  try {
@@ -655,7 +693,7 @@ export class Session {
655
693
  * acknowledgement that their type-ahead message landed (the card
656
694
  * doesn't open until the current turn finishes). */
657
695
  isBusy(): boolean {
658
- return this.currentTurn !== null || this.pendingUserMessageCount > 0
696
+ return this.currentTurn !== null || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
659
697
  }
660
698
 
661
699
  /** Funnel an arbitrary chat message into the *current* question
@@ -801,6 +839,7 @@ export class Session {
801
839
  feishu.bindSessionResume(this.sessionName, p.sessionId)
802
840
  }
803
841
  this.initCount++
842
+ log(`session "${this.sessionName}": SDK init#${this.initCount} pendingCount=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length} currentTurn=${this.currentTurn ? 'yes' : 'no'} openingTurn=${this.openingTurn}`)
804
843
 
805
844
  // Boot init (initCount === 1) is claimed by `onUserMessage`'s
806
845
  // eager-open path — if a user message landed before the init
@@ -861,21 +900,28 @@ export class Session {
861
900
  })
862
901
  p.on('result', () => {
863
902
  this.accumulateResultStats()
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
903
+ // Daemon-driven rotation: mid-turn msgs were buffered (not yet
904
+ // sent to SDK) close the in-flight card with `📨 转交新卡` and
905
+ // drain the buffer in one shot. The drain writes each buffered
906
+ // msg to SDK stdin, which is the `priority="now"` wake the SDK
907
+ // polling loop needs (claude-code issue #39632) AND constitutes
908
+ // the input for the new batch turn. We open the new card here
909
+ // ourselves rather than waiting on init the SDK init for this
910
+ // batch will fire shortly but `currentTurn` will already be set,
911
+ // so the init handler will return without double-opening.
912
+ const hasMidTurn = this.pendingMidTurnMsgs.length > 0
913
+ const suffix = hasMidTurn ? '📨 转交新卡' : undefined
914
+ log(`session "${this.sessionName}": SDK result midBuffer=${this.pendingMidTurnMsgs.length} suffix=${suffix ?? '<✅>'}`)
871
915
  void this.closeTurnCard(suffix)
872
916
  this.status = 'idle'
917
+ if (hasMidTurn) void this.drainMidTurnAndOpen()
873
918
  })
874
919
  p.on('exit', ({ code, signal, expected }: any) => {
875
920
  log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
876
921
  this.proc = null
877
922
  this.currentTurn = null
878
923
  this.pendingUserMessageCount = 0
924
+ this.pendingMidTurnMsgs = []
879
925
  this.lastUserOpenId = ''
880
926
  this.pendingReactionIds = new Map()
881
927
  this.currentBatchReactionIds = new Map()
@@ -932,8 +978,39 @@ export class Session {
932
978
  return this.proc?.lastContextWindow ?? 200_000
933
979
  }
934
980
 
981
+ /** Drain `pendingMidTurnMsgs` to the SDK and open a fresh card for the
982
+ * resulting batch turn. Called from the `result` handler when buffered
983
+ * mid-turn messages need to start their own turn. The `sendUserText`
984
+ * calls wake the SDK polling loop (priority="now" semantics) and
985
+ * comprise the input for the new turn. Opens the card here rather
986
+ * than deferring to init because the init for this batch will arrive
987
+ * with `currentTurn` already set and bail. */
988
+ private async drainMidTurnAndOpen(): Promise<void> {
989
+ if (this.pendingMidTurnMsgs.length === 0) return
990
+ const batch = this.pendingMidTurnMsgs
991
+ this.pendingMidTurnMsgs = []
992
+ this.openingTurn = true
993
+ try {
994
+ for (const msg of batch) {
995
+ this.proc!.sendUserText(msg.wireText, msg.files)
996
+ if (msg.msgId) {
997
+ const rid = this.pendingReactionIds.get(msg.msgId) ?? ''
998
+ this.currentBatchReactionIds.set(msg.msgId, rid)
999
+ this.pendingReactionIds.delete(msg.msgId)
1000
+ }
1001
+ }
1002
+ const last = batch[batch.length - 1]
1003
+ const userOpenId = last?.userOpenId ?? this.lastUserOpenId
1004
+ await this.openTurnCard(userOpenId, 'user_message')
1005
+ this.status = 'working'
1006
+ } finally {
1007
+ this.openingTurn = false
1008
+ }
1009
+ }
1010
+
935
1011
  private async openTurnCard(userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
936
1012
  const turn = ++this.turnCounter
1013
+ log(`session "${this.sessionName}": openTurnCard turn=${turn} trigger=${trigger}`)
937
1014
  const card = cards.mainConversationCard({
938
1015
  sessionName: this.sessionName,
939
1016
  turn,
@@ -972,6 +1049,8 @@ export class Session {
972
1049
  thinkingText: '',
973
1050
  toolCount: 0,
974
1051
  toolByUseId: new Map(),
1052
+ readBatches: new Map(),
1053
+ openReadBatchI: null,
975
1054
  assistantSegmentCount: 0,
976
1055
  currentAssistantSegmentId: null,
977
1056
  currentAssistantText: '',
@@ -988,6 +1067,10 @@ export class Session {
988
1067
  private appendAssistant(delta: string): void {
989
1068
  if (!this.currentTurn) return
990
1069
  if (!this.currentTurn.currentAssistantSegmentId) {
1070
+ // New assistant segment opens a visual break — any prior Read run
1071
+ // is now visually separated from future Reads, so close the batch
1072
+ // window. Future Reads will start a fresh batch at a new i.
1073
+ this.currentTurn.openReadBatchI = null
991
1074
  const i = this.currentTurn.assistantSegmentCount++
992
1075
  const segId = cards.ELEMENTS.assistant(i)
993
1076
  this.currentTurn.currentAssistantSegmentId = segId
@@ -1039,7 +1122,37 @@ export class Session {
1039
1122
  this.currentTurn.currentAssistantSegmentId = null
1040
1123
  this.currentTurn.currentAssistantText = ''
1041
1124
  }
1125
+ // Consecutive Read merger: if a Read run is already open, append to
1126
+ // its batch and re-render the panel instead of inserting a new one.
1127
+ // Any other tool name closes the run (handled below).
1128
+ if (name === 'Read' && this.currentTurn.openReadBatchI !== null) {
1129
+ const batchI = this.currentTurn.openReadBatchI
1130
+ const batch = this.currentTurn.readBatches.get(batchI)!
1131
+ const slot = batch.items.length
1132
+ batch.items.push({ toolUseId, input, output: null, isError: false })
1133
+ this.currentTurn.toolByUseId.set(toolUseId, { i: batchI, name, input, readBatchSlot: slot })
1134
+ const el = cards.readBatchElement(batchI, batch.items)
1135
+ void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(batchI), el)
1136
+ return
1137
+ }
1138
+ if (name !== 'Read') this.currentTurn.openReadBatchI = null
1042
1139
  const i = this.currentTurn.toolCount++
1140
+ if (name === 'Read') {
1141
+ // First Read of a potential run — render the existing single-tool
1142
+ // panel (which keeps the full file-contents dump on completion). If
1143
+ // a second Read arrives, completeTool/addTool will switch it to
1144
+ // `readBatchElement`.
1145
+ this.currentTurn.openReadBatchI = i
1146
+ this.currentTurn.readBatches.set(i, {
1147
+ items: [{ toolUseId, input, output: null, isError: false }],
1148
+ })
1149
+ this.currentTurn.toolByUseId.set(toolUseId, { i, name, input, readBatchSlot: 0 })
1150
+ const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, undefined)
1151
+ void cardkit.addElement(this.currentTurn.cardId, el, {
1152
+ type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
1153
+ })
1154
+ return
1155
+ }
1043
1156
  this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
1044
1157
  // AskUserQuestion is a client-side tool — daemon renders the choice
1045
1158
  // UI in-line and supplies the tool_result itself once the user
@@ -1116,6 +1229,22 @@ export class Session {
1116
1229
  // via toolCallElement would clobber the nice option-row layout
1117
1230
  // with a generic JSON dump. Bail out; the panel is done.
1118
1231
  if (meta.name === 'AskUserQuestion') return
1232
+ // Read batch path: update this row's status in the shared batch then
1233
+ // re-render. Single-item batches keep the original full-output panel
1234
+ // (file-contents dump); 2+ items switch to the compact `Read · N 次`
1235
+ // listing, which overwrites whatever was last drawn at this i.
1236
+ if (meta.name === 'Read' && meta.readBatchSlot != null) {
1237
+ const batch = this.currentTurn.readBatches.get(meta.i)
1238
+ if (batch) {
1239
+ const row = batch.items[meta.readBatchSlot]
1240
+ if (row) { row.output = output; row.isError = isError }
1241
+ const el = batch.items.length >= 2
1242
+ ? cards.readBatchElement(meta.i, batch.items)
1243
+ : cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, undefined)
1244
+ void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
1245
+ }
1246
+ return
1247
+ }
1119
1248
  // Update the local todo mirror BEFORE rendering so the just-
1120
1249
  // completed panel shows the new state too (e.g. a TaskCreate panel
1121
1250
  // already lists the task it just created).