@leviyuan/lodestar 0.2.4 → 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/README.md CHANGED
@@ -50,9 +50,9 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
50
50
  | --- | --- |
51
51
  | `hi` | 未运行时启动;运行中弹一张**状态卡片** |
52
52
  | `stop` | 软打断当前 turn + 清空 type-ahead 排队;子进程保活,刚排队中的消息会被打 `CrossMark` 反应表示取消 |
53
- | `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
54
- | `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
55
- | `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
53
+ | `kill` | 优雅关闭 Claude 进程;`sessionId` 仍记在磁盘,下次 `restart` 还能 resume |
54
+ | `restart` | 用上一次的 `sessionId` 重启会话(保留上下文);无进程时也能用,等于"恢复上一会话" |
55
+ | `clear` | 杀掉当前进程并启动一个全新 session(等价于 Claude Code 的 `/clear`);**无进程时无效** |
56
56
 
57
57
  > 这五个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
58
58
 
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()
@@ -192,9 +216,6 @@ async function handleCardAction(data: any): Promise<any> {
192
216
  case 'permission':
193
217
  await session.onPermissionDecision(value.request_id, value.decision, userId)
194
218
  return { toast: { type: value.decision === 'deny' ? 'error' : 'success', content: '已处理' } }
195
- case 'console':
196
- await session.onConsoleAction(value.action)
197
- return { toast: { type: 'info', content: value.action } }
198
219
  case 'menu':
199
220
  await session.onUserMessage(`(menu choice ${value.choice + 1})`)
200
221
  return { toast: { type: 'success', content: 'OK' } }
@@ -221,6 +242,55 @@ function fmt(m: any[]): string {
221
242
  return m.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')
222
243
  }
223
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
+
224
294
  async function boot(): Promise<void> {
225
295
  log(`lodestar-daemon: pid ${process.pid} starting`)
226
296
  feishu.loadSessionChatMap()
@@ -272,6 +342,8 @@ async function boot(): Promise<void> {
272
342
  ws.start({ eventDispatcher: dispatcher })
273
343
  log(`lodestar-daemon: WS started, watching ${feishu.chatNameCache.size} groups`)
274
344
 
345
+ startDebugSocket()
346
+
275
347
  // Auto-revive sessions that were running when we last went down.
276
348
  // Runs AFTER the WS is up so any 🔁 revive message lands in the
277
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.4",
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.
@@ -550,7 +574,6 @@ interface ConsoleOpts {
550
574
  cumStats?: { tokens: number; costUsd: number; turns: number }
551
575
  lastTurn?: { tokens: number; costUsd: number; durationMs: number }
552
576
  sessionId?: string | null
553
- hasSession: boolean
554
577
  }
555
578
 
556
579
  /** Format token counts as a compact human-readable string: 1,234 → 1.2K. */
@@ -656,7 +679,7 @@ export function consoleUsageContent(
656
679
  export function consoleCard(opts: ConsoleOpts): object {
657
680
  const {
658
681
  sessionName, status, model, effort, uptimeMs, peers, usage,
659
- contextTokens, contextLimit, cumStats, lastTurn, sessionId, hasSession,
682
+ contextTokens, contextLimit, cumStats, lastTurn, sessionId,
660
683
  } = opts
661
684
  const statusEmoji = {
662
685
  idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
@@ -698,12 +721,6 @@ export function consoleCard(opts: ConsoleOpts): object {
698
721
  lines.push(`**🆔 session** \`${sessionId.slice(0, 8)}…\``)
699
722
  }
700
723
 
701
- void hasSession // accept the field for caller compat; lifecycle is now
702
- // driven by bare-word commands (`hi` / `kill` / `restart` / `clear`),
703
- // not buttons — keeps the panel pure-readout and one-handed mobile-
704
- // friendly. The 'refresh' / 'ls' actions stay in onConsoleAction for
705
- // backward compat with any still-floating older cards in chat history.
706
-
707
724
  const template = status === 'working' ? 'blue'
708
725
  : status === 'awaiting_permission' ? 'orange'
709
726
  : status === 'stopped' ? 'grey'
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,19 +415,42 @@ 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()
399
435
  return true
400
436
  case 'restart':
437
+ // resume the prior conversation — kills the current proc (if
438
+ // any) and spawns a new one with `--resume <lastSessionId>`.
439
+ // If no process is running, this is how the user gets back the
440
+ // previous conversation after a `kill` or a daemon crash.
401
441
  await this.restart(true)
402
442
  return true
403
443
  case 'clear':
444
+ // "throw away current conversation, start a new one". By design
445
+ // this only makes sense when there IS a current conversation:
446
+ // calling clear from stopped state is a no-op (user-confirmed
447
+ // 2026-05-16) — we don't want a stray `clear` to silently spawn
448
+ // a fresh session the user didn't ask for. To start from cold,
449
+ // use `hi`.
450
+ if (!this.isRunning()) {
451
+ await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行,clear 无效;用 \`hi\` 启动或 \`restart\` 恢复上一会话`)
452
+ return true
453
+ }
404
454
  await this.restart(false)
405
455
  return true
406
456
  }
@@ -438,7 +488,6 @@ export class Session {
438
488
  }
439
489
  : undefined,
440
490
  sessionId: this.proc?.sessionId ?? this.lastSessionId,
441
- hasSession: this.isRunning(),
442
491
  })
443
492
  const messageId = await feishu.sendCard(this.chatId, card)
444
493
  if (!messageId) return
@@ -519,8 +568,8 @@ export class Session {
519
568
  // reaction) and the model would see e.g. "123" + "321" + "1"
520
569
  // glued into a single string "1233211" (2026-05-16 accumulator
521
570
  // bug).
522
- const wasBusy = this.currentTurn !== null || this.openingTurn || this.pendingUserMessageCount > 0
523
- this.pendingUserMessageCount++
571
+ const wasBusy = this.currentTurn !== null || this.openingTurn
572
+ || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
524
573
  this.lastUserOpenId = userOpenId
525
574
  // When the SDK will merge this msg with siblings into a multi-
526
575
  // content user turn, wrap it in `<u>...</u>` so the model sees a
@@ -534,51 +583,53 @@ export class Session {
534
583
  // skips the wrap — no sibling, no merge, no need. Contract
535
584
  // declared in CHANNEL_INSTRUCTIONS.
536
585
  const wireText = wasBusy ? `<u>${text}</u>` : text
537
- this.proc!.sendUserText(wireText, files)
538
- if (wasBusy && msgId) {
539
- // Hold the slot in the map even if the API call hasn't returned
540
- // yet empty string is a sentinel meaning "we tried to react;
541
- // reaction_id pending". When deleteReaction time comes, an empty
542
- // string is a no-op (deleteReaction guards against it), which is
543
- // the right behavior if the add failed.
544
- 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, '')
545
591
  void (async () => {
546
- const rid = await feishu.addReaction(msgId, 'OneSecond')
592
+ const rid = await feishu.addReaction(id, 'OneSecond')
547
593
  if (!rid) return
548
- if (this.pendingReactionIds.has(msgId)) {
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)
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)
555
598
  } 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
599
+ // Orphan: both maps cleared before our add returned. Delete
559
600
  // 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)
601
+ void feishu.deleteReaction(id, rid)
565
602
  }
566
603
  })()
567
604
  }
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.
575
- if (!this.currentTurn && !this.openingTurn && this.initCount >= 1) {
576
- // Eager open: this message is going to be processed solo (no current
577
- // turn to merge with on the SDK side, so SDK runs it as its own turn).
578
- // Claim one count and open the card with this message's own text +
579
- // sender; any *additional* messages arriving during the open's
580
- // Feishu API await will pile up in the count and get batched by the
581
- // 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.
582
633
  this.openingTurn = true
583
634
  this.pendingUserMessageCount--
584
635
  try {
@@ -642,7 +693,7 @@ export class Session {
642
693
  * acknowledgement that their type-ahead message landed (the card
643
694
  * doesn't open until the current turn finishes). */
644
695
  isBusy(): boolean {
645
- return this.currentTurn !== null || this.pendingUserMessageCount > 0
696
+ return this.currentTurn !== null || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
646
697
  }
647
698
 
648
699
  /** Funnel an arbitrary chat message into the *current* question
@@ -777,19 +828,6 @@ export class Session {
777
828
  }
778
829
  }
779
830
 
780
- async onConsoleAction(action: string): Promise<void> {
781
- log(`session "${this.sessionName}": console action=${action}`)
782
- switch (action) {
783
- case 'interrupt': this.interrupt(); break
784
- case 'clear': await this.restart(false); break
785
- case 'stop': await this.stop(); break
786
- case 'start': await this.start(); break
787
- case 'resume': await this.restart(true); break
788
- case 'refresh': await this.showConsole(); break
789
- case 'ls': await feishu.sendText(this.chatId, `📁 ${this.workDir}`); break
790
- }
791
- }
792
-
793
831
  // ── Wiring Claude → Feishu ─────────────────────────────────────────
794
832
  private wireProc(p: ClaudeProcess): void {
795
833
  p.on('init', () => {
@@ -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).