@leviyuan/lodestar 0.2.5 → 0.2.7

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.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cardkit.ts CHANGED
@@ -73,11 +73,68 @@ 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
+ onFailure?: () => void,
111
+ ): Promise<void> {
112
+ try {
113
+ await op()
114
+ return
115
+ } catch (e) {
116
+ if (!isStreamingClosed(e)) {
117
+ log(`cardkit ${label} ${cardId}: ${e}`)
118
+ if (onFailure) onFailure()
119
+ return
120
+ }
121
+ log(`cardkit ${label} ${cardId}: streaming closed (300309) — reopening`)
122
+ }
123
+ try {
124
+ await reopenStreaming(cardId)
125
+ } catch (re) {
126
+ log(`cardkit STREAMING_REOPEN_FAILED ${cardId}: ${re}`)
127
+ if (onFailure) onFailure()
128
+ return
129
+ }
130
+ try {
131
+ await op()
132
+ } catch (e2) {
133
+ log(`cardkit ${label} ${cardId} retry-after-reopen: ${e2}`)
134
+ if (onFailure) onFailure()
135
+ }
136
+ }
137
+
81
138
  /** Convert a sent interactive message into a card entity. */
82
139
  export async function convertMessageToCard(messageId: string): Promise<string> {
83
140
  const data = await call('POST', '/cards/id_convert', { message_id: messageId })
@@ -101,17 +158,17 @@ export async function createCardEntity(card: object): Promise<string> {
101
158
  export function streamText(cardId: string, elementId: string, content: string): Promise<void> {
102
159
  if (!content || !content.trim()) return Promise.resolve()
103
160
  const s = state(cardId)
104
- const seq = nextSeq(cardId)
105
- s.queue = s.queue.then(async () => {
106
- try {
161
+ s.queue = s.queue.then(() => withReopenOnStreamingClosed(
162
+ cardId,
163
+ `streamText ${elementId}`,
164
+ async () => {
165
+ const seq = nextSeq(cardId)
107
166
  await call('PUT', `/cards/${cardId}/elements/${elementId}/content`, {
108
167
  content, sequence: seq,
109
168
  })
110
169
  s.lastSent.set(elementId, content)
111
- } catch (e) {
112
- log(`cardkit streamText ${cardId}/${elementId}: ${e}`)
113
- }
114
- })
170
+ },
171
+ ))
115
172
  return s.queue
116
173
  }
117
174
 
@@ -148,53 +205,69 @@ export async function flush(cardId: string): Promise<void> {
148
205
  }
149
206
  }
150
207
 
151
- /** Add a new element to the card body or relative to a sibling. */
208
+ /** Add a new element to the card body or relative to a sibling.
209
+ *
210
+ * `onFailure` fires asynchronously (after promise queue settles) if the
211
+ * element was NOT created — either the first attempt failed with a non-
212
+ * 300309 error, or the retry-after-reopen also failed. Use it to invalidate
213
+ * any daemon-side reference to the element you tried to add (e.g. a segment
214
+ * id), so subsequent writes don't keep PUTting content to a phantom element
215
+ * that Feishu will silently reject. Default (no callback) preserves the
216
+ * legacy fire-and-forget swallow behavior. */
152
217
  export function addElement(
153
218
  cardId: string,
154
219
  element: object,
155
220
  opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
221
+ onFailure?: () => void,
156
222
  ): Promise<void> {
157
223
  const s = state(cardId)
158
- const seq = nextSeq(cardId)
159
- s.queue = s.queue.then(async () => {
160
- try {
224
+ s.queue = s.queue.then(() => withReopenOnStreamingClosed(
225
+ cardId,
226
+ `addElement`,
227
+ async () => {
228
+ const seq = nextSeq(cardId)
161
229
  await call('POST', `/cards/${cardId}/elements`, {
162
230
  type: opts.type ?? 'append',
163
231
  ...(opts.targetElementId ? { target_element_id: opts.targetElementId } : {}),
164
232
  elements: JSON.stringify([element]),
165
233
  sequence: seq,
166
234
  })
167
- } catch (e) { log(`cardkit addElement ${cardId}: ${e}`) }
168
- })
235
+ },
236
+ onFailure,
237
+ ))
169
238
  return s.queue
170
239
  }
171
240
 
172
241
  /** Replace an entire element (used to swap a tool placeholder with its result). */
173
242
  export function replaceElement(cardId: string, elementId: string, element: object): Promise<void> {
174
243
  const s = state(cardId)
175
- const seq = nextSeq(cardId)
176
- s.queue = s.queue.then(async () => {
177
- try {
244
+ s.queue = s.queue.then(() => withReopenOnStreamingClosed(
245
+ cardId,
246
+ `replaceElement ${elementId}`,
247
+ async () => {
248
+ const seq = nextSeq(cardId)
178
249
  await call('PUT', `/cards/${cardId}/elements/${elementId}`, {
179
250
  element: JSON.stringify(element),
180
251
  sequence: seq,
181
252
  })
182
- } catch (e) { log(`cardkit replaceElement ${cardId}/${elementId}: ${e}`) }
183
- })
253
+ },
254
+ ))
184
255
  return s.queue
185
256
  }
186
257
 
187
258
  /** Delete an element by id. */
188
259
  export function deleteElement(cardId: string, elementId: string): Promise<void> {
189
260
  const s = state(cardId)
190
- const seq = nextSeq(cardId)
191
- s.queue = s.queue.then(async () => {
192
- try {
261
+ s.queue = s.queue.then(() => withReopenOnStreamingClosed(
262
+ cardId,
263
+ `deleteElement ${elementId}`,
264
+ async () => {
265
+ const seq = nextSeq(cardId)
193
266
  await call('DELETE', `/cards/${cardId}/elements/${elementId}`, {
194
267
  sequence: seq,
195
268
  })
196
- } catch (e) { log(`cardkit deleteElement ${cardId}/${elementId}: ${e}`) }
197
- })
269
+ },
270
+ ))
198
271
  return s.queue
199
272
  }
200
273
 
@@ -235,12 +308,22 @@ export function cancelSummary(cardId: string): void {
235
308
  summaryStates.delete(cardId)
236
309
  }
237
310
 
238
- /** Patch settings — used to flip streaming_mode off when a turn finishes. */
311
+ /** Patch settings — used to flip streaming_mode off when a turn finishes.
312
+ *
313
+ * `nextSeq` is called inside the queued task (not at enqueue time) to
314
+ * match streamText/addElement/replaceElement/deleteElement above. Mixing
315
+ * call-time and execution-time seq allocation interleaves badly: a
316
+ * patchSettings enqueued right after a replaceElement would grab the
317
+ * smaller seq number, but the replaceElement's then-block would grab
318
+ * the larger one when it ran first, so the patchSettings PATCH lands
319
+ * with a stale seq and Feishu rejects 300317 "sequence number compare
320
+ * failed". Keeping all writes on execution-time allocation makes the
321
+ * seq order match the queue order. */
239
322
  export function patchSettings(cardId: string, settings: object): Promise<void> {
240
323
  const s = state(cardId)
241
- const seq = nextSeq(cardId)
242
324
  s.queue = s.queue.then(async () => {
243
325
  try {
326
+ const seq = nextSeq(cardId)
244
327
  await call('PATCH', `/cards/${cardId}/settings`, {
245
328
  settings: JSON.stringify(settings),
246
329
  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
@@ -75,11 +91,14 @@ interface LastTurnDelta {
75
91
  inputTokens: number // input + cache_* (excludes output) — context-window estimate
76
92
  }
77
93
 
78
- /** Cumulative session counters. Reset on full restart (`clear`), preserved
79
- * across resume but resumed conversations start counting from the
80
- * resume point, not the original turn 0; the SDK doesn't replay historical
81
- * usage. The session_id continuity is preserved separately by the resume
82
- * map; cumStats represents "since the current ClaudeProcess was spawned". */
94
+ /** Cumulative session counters. Reset on full restart (`clear`),
95
+ * preserved across `restart`/resume and daemon-restart so the `hi`
96
+ * panel reflects the user's total spend in this conversation
97
+ * regardless of how many times the underlying ClaudeProcess has been
98
+ * respawned. Resumed conversations start counting from the resume
99
+ * point onward — the SDK doesn't replay historical usage on resume,
100
+ * so a long pre-resume conversation shows up as zero here until the
101
+ * first new turn lands. */
83
102
  interface CumStats {
84
103
  tokens: number
85
104
  costUsd: number
@@ -112,6 +131,15 @@ export class Session {
112
131
  * turns from cron-fired scheduled wakeups: count > 0 ⇒ user;
113
132
  * count === 0 ⇒ scheduled (and `initCount > 1`). */
114
133
  private pendingUserMessageCount = 0
134
+ /** Mid-turn user messages buffered DAEMON-SIDE (not yet sendUserText'd
135
+ * to the SDK). Drained in the `result` handler by writing each to SDK
136
+ * stdin, which doubles as the `priority="now"` wake signal the SDK
137
+ * polling loop needs to start the next batch turn (the SDK won't
138
+ * auto-dequeue queued type-ahead msgs after `result` — confirmed via
139
+ * claude-code issue #39632). Buffering also keeps mid-turn msgs out
140
+ * of any AskUserQuestion `QUEUE remove` storm, since they were never
141
+ * in the SDK queue to begin with. */
142
+ private pendingMidTurnMsgs: Array<{ wireText: string; files: string[]; userOpenId: string; msgId: string }> = []
115
143
  /** Most recent userOpenId seen via `onUserMessage`. Used only when a
116
144
  * merged batch fires its init event and the daemon needs *some* open_id
117
145
  * to scope the eventual `urgent_app` push — there's no obviously right
@@ -269,6 +297,27 @@ export class Session {
269
297
  return true
270
298
  }
271
299
 
300
+ /** Drop every ⏳ OneSecond reaction this session is currently holding
301
+ * on user chat messages, then empty the two tracking maps. Used by
302
+ * every tear-down path (proc exit, kill, restart) so reactions don't
303
+ * outlive the conversation that placed them — without this, a Claude
304
+ * crash / daemon SIGTERM leaves orphan ⏳ stuck on user messages until
305
+ * Feishu's UI eventually GCs them (which it doesn't, in practice).
306
+ * closeTurnCard has its own release pass (with the slightly-early
307
+ * merged-batch trade-off documented there); this is the catastrophic-
308
+ * exit pass. Direct `deleteReaction` calls are fire-and-forget and
309
+ * swallow their own failures (see feishu.deleteReaction). */
310
+ private releaseAllReactions(): void {
311
+ for (const [msgId, rid] of [
312
+ ...this.pendingReactionIds.entries(),
313
+ ...this.currentBatchReactionIds.entries(),
314
+ ]) {
315
+ if (rid) void feishu.deleteReaction(msgId, rid)
316
+ }
317
+ this.pendingReactionIds = new Map()
318
+ this.currentBatchReactionIds = new Map()
319
+ }
320
+
272
321
  async stop(reason = '已终止'): Promise<void> {
273
322
  if (!this.proc) {
274
323
  this.status = 'stopped'
@@ -288,9 +337,9 @@ export class Session {
288
337
  this.proc = null
289
338
  this.currentTurn = null
290
339
  this.pendingUserMessageCount = 0
340
+ this.pendingMidTurnMsgs = []
291
341
  this.lastUserOpenId = ''
292
- this.pendingReactionIds = new Map()
293
- this.currentBatchReactionIds = new Map()
342
+ this.releaseAllReactions()
294
343
  this.initCount = 0
295
344
  this.openingTurn = false
296
345
  this.pendingPermissions.clear()
@@ -308,9 +357,9 @@ export class Session {
308
357
  }
309
358
  this.currentTurn = null
310
359
  this.pendingUserMessageCount = 0
360
+ this.pendingMidTurnMsgs = []
311
361
  this.lastUserOpenId = ''
312
- this.pendingReactionIds = new Map()
313
- this.currentBatchReactionIds = new Map()
362
+ this.releaseAllReactions()
314
363
  this.initCount = 0
315
364
  this.openingTurn = false
316
365
  this.pendingPermissions.clear()
@@ -372,11 +421,11 @@ export class Session {
372
421
  // but the daemon can't reach into it directly; in practice the
373
422
  // sendInterrupt() control_request causes the SDK to discard
374
423
  // queued input alongside the in-flight call.
375
- if (!this.currentTurn && this.pendingUserMessageCount === 0) {
424
+ if (!this.currentTurn && this.pendingUserMessageCount === 0 && this.pendingMidTurnMsgs.length === 0) {
376
425
  await feishu.sendText(this.chatId, '⚪ 当前没有正在执行的 turn')
377
426
  return true
378
427
  }
379
- log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount}`)
428
+ log(`session "${this.sessionName}": stop command — interrupt + drop count=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length}`)
380
429
  // Cancelled queued msgs: remove the OneSecond (no longer waiting)
381
430
  // and stamp a CrossMark (explicit cancelled state, distinct from
382
431
  // a natural release where reactions just disappear). Cancelled
@@ -388,11 +437,20 @@ export class Session {
388
437
  if (rid) void feishu.deleteReaction(msgId, rid)
389
438
  void feishu.addReaction(msgId, 'CrossMark')
390
439
  }
440
+ // Mid-turn buffer never reached SDK — cancel those too.
441
+ for (const msg of this.pendingMidTurnMsgs) {
442
+ if (msg.msgId) void feishu.addReaction(msg.msgId, 'CrossMark')
443
+ }
391
444
  this.pendingUserMessageCount = 0
445
+ this.pendingMidTurnMsgs = []
392
446
  this.lastUserOpenId = ''
393
447
  this.pendingReactionIds = new Map()
394
448
  this.currentBatchReactionIds = new Map()
395
449
  this.interrupt()
450
+ // SDK 收到 interrupt 后不发 `result`,没人会触发 closeTurnCard。
451
+ // 这里主动封口,把 footer 改成 🛑 打断、折叠 thinking、把
452
+ // streaming_mode 翻回 false,否则卡片会僵在 `⏳ working…`。
453
+ await this.closeTurnCard('🛑 打断')
396
454
  return true
397
455
  case 'kill':
398
456
  await this.stop()
@@ -532,8 +590,8 @@ export class Session {
532
590
  // reaction) and the model would see e.g. "123" + "321" + "1"
533
591
  // glued into a single string "1233211" (2026-05-16 accumulator
534
592
  // bug).
535
- const wasBusy = this.currentTurn !== null || this.openingTurn || this.pendingUserMessageCount > 0
536
- this.pendingUserMessageCount++
593
+ const wasBusy = this.currentTurn !== null || this.openingTurn
594
+ || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
537
595
  this.lastUserOpenId = userOpenId
538
596
  // When the SDK will merge this msg with siblings into a multi-
539
597
  // content user turn, wrap it in `<u>...</u>` so the model sees a
@@ -547,59 +605,73 @@ export class Session {
547
605
  // skips the wrap — no sibling, no merge, no need. Contract
548
606
  // declared in CHANNEL_INSTRUCTIONS.
549
607
  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, '')
608
+
609
+ // Reaction helper: track the OneSecond reaction so deleteReaction can
610
+ // clear it later. Use empty-string sentinel until addReaction returns.
611
+ const trackReaction = (id: string) => {
612
+ this.pendingReactionIds.set(id, '')
558
613
  void (async () => {
559
- const rid = await feishu.addReaction(msgId, 'OneSecond')
614
+ const rid = await feishu.addReaction(id, 'OneSecond')
560
615
  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)
616
+ if (this.pendingReactionIds.has(id)) {
617
+ this.pendingReactionIds.set(id, rid)
618
+ } else if (this.currentBatchReactionIds.has(id)) {
619
+ this.currentBatchReactionIds.set(id, rid)
568
620
  } 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
621
+ // Orphan: both maps cleared before our add returned. Delete
572
622
  // 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)
623
+ void feishu.deleteReaction(id, rid)
578
624
  }
579
625
  })()
580
626
  }
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).
627
+
628
+ if (this.currentTurn !== null) {
629
+ // Mid-turn BUFFER instead of immediate sendUserText. The SDK polling
630
+ // loop will not auto-dequeue queued type-ahead msgs after `result`
631
+ // (only `priority="now"` writes wake it claude-code issue #39632),
632
+ // so writing here would leave the msg stuck until the next user msg
633
+ // arrives. Drain happens in the `result` handler, which both wakes
634
+ // the SDK and opens a fresh card for the new batch turn.
635
+ this.pendingMidTurnMsgs.push({ wireText, files, userOpenId, msgId })
636
+ if (msgId) trackReaction(msgId)
637
+ return
638
+ }
639
+
640
+ // No in-flight turn: send straight to SDK. This path handles
641
+ // - first message after spawn (init not yet fired)
642
+ // - bootstrap race (sibling msgs landing before init#1)
643
+ // - solo message after a prior turn has fully closed
644
+ // Eager-open path: open the card BEFORE feeding SDK, so a card-open
645
+ // failure doesn't strand the daemon with SDK processing a turn we
646
+ // have nowhere to render. `!openingTurn` means no sibling is mid-
647
+ // open; `initCount >= 1` means SDK boot init has fired (otherwise
648
+ // the init handler owns turn opening and we just feed the queue
649
+ // below). On failure openTurnCard surfaces a red banner via
650
+ // sendTextRaw; SDK was idle so no interrupt needed.
651
+ if (!this.openingTurn && this.initCount >= 1) {
595
652
  this.openingTurn = true
596
- this.pendingUserMessageCount--
597
653
  try {
598
654
  await this.openTurnCard(userOpenId, 'user_message')
655
+ if (!this.currentTurn) return
656
+ this.proc!.sendUserText(wireText, files)
657
+ this.pendingUserMessageCount++
599
658
  this.status = 'working'
600
659
  } finally {
601
660
  this.openingTurn = false
602
661
  }
662
+ return
663
+ }
664
+
665
+ // Non-eager path: either init hasn't fired yet (cold start) or a
666
+ // sibling onUserMessage is already opening. Feed SDK directly; the
667
+ // init handler / sibling card-opener will batch this message in.
668
+ this.proc!.sendUserText(wireText, files)
669
+ this.pendingUserMessageCount++
670
+ if (wasBusy && msgId) {
671
+ // Bootstrap race / sibling-opening race: until a card is open,
672
+ // the OneSecond ⏳ is the only ack the user gets. The init handler
673
+ // inherits these via currentBatchReactionIds when it opens.
674
+ trackReaction(msgId)
603
675
  }
604
676
  }
605
677
 
@@ -655,7 +727,7 @@ export class Session {
655
727
  * acknowledgement that their type-ahead message landed (the card
656
728
  * doesn't open until the current turn finishes). */
657
729
  isBusy(): boolean {
658
- return this.currentTurn !== null || this.pendingUserMessageCount > 0
730
+ return this.currentTurn !== null || this.pendingUserMessageCount > 0 || this.pendingMidTurnMsgs.length > 0
659
731
  }
660
732
 
661
733
  /** Funnel an arbitrary chat message into the *current* question
@@ -801,6 +873,7 @@ export class Session {
801
873
  feishu.bindSessionResume(this.sessionName, p.sessionId)
802
874
  }
803
875
  this.initCount++
876
+ log(`session "${this.sessionName}": SDK init#${this.initCount} pendingCount=${this.pendingUserMessageCount} midBuffer=${this.pendingMidTurnMsgs.length} currentTurn=${this.currentTurn ? 'yes' : 'no'} openingTurn=${this.openingTurn}`)
804
877
 
805
878
  // Boot init (initCount === 1) is claimed by `onUserMessage`'s
806
879
  // eager-open path — if a user message landed before the init
@@ -834,7 +907,19 @@ export class Session {
834
907
  void (async () => {
835
908
  try {
836
909
  await this.openTurnCard(userOpenId, isUserBatch ? 'user_message' : 'scheduled')
837
- this.status = 'working'
910
+ if (!this.currentTurn) {
911
+ // SDK already started this turn (its `init` is what got us
912
+ // here) but we have no card to render into. Interrupt so
913
+ // assistant/tool events aren't silently dropped while the
914
+ // model burns tokens. Release the reactions this batch
915
+ // inherited (init handler moved them above) — otherwise
916
+ // they stay ⏳ forever on the user's chat messages.
917
+ log(`session "${this.sessionName}": init-path openTurnCard failed — sendInterrupt + release reactions`)
918
+ this.proc?.sendInterrupt()
919
+ this.releaseAllReactions()
920
+ } else {
921
+ this.status = 'working'
922
+ }
838
923
  } finally {
839
924
  this.openingTurn = false
840
925
  }
@@ -861,24 +946,30 @@ export class Session {
861
946
  })
862
947
  p.on('result', () => {
863
948
  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
949
+ // Daemon-driven rotation: mid-turn msgs were buffered (not yet
950
+ // sent to SDK) close the in-flight card with `📨 转交新卡` and
951
+ // drain the buffer in one shot. The drain writes each buffered
952
+ // msg to SDK stdin, which is the `priority="now"` wake the SDK
953
+ // polling loop needs (claude-code issue #39632) AND constitutes
954
+ // the input for the new batch turn. We open the new card here
955
+ // ourselves rather than waiting on init the SDK init for this
956
+ // batch will fire shortly but `currentTurn` will already be set,
957
+ // so the init handler will return without double-opening.
958
+ const hasMidTurn = this.pendingMidTurnMsgs.length > 0
959
+ const suffix = hasMidTurn ? '📨 转交新卡' : undefined
960
+ log(`session "${this.sessionName}": SDK result midBuffer=${this.pendingMidTurnMsgs.length} suffix=${suffix ?? '<✅>'}`)
871
961
  void this.closeTurnCard(suffix)
872
962
  this.status = 'idle'
963
+ if (hasMidTurn) void this.drainMidTurnAndOpen()
873
964
  })
874
965
  p.on('exit', ({ code, signal, expected }: any) => {
875
966
  log(`session "${this.sessionName}": claude exited code=${code} signal=${signal} expected=${expected}`)
876
967
  this.proc = null
877
968
  this.currentTurn = null
878
969
  this.pendingUserMessageCount = 0
970
+ this.pendingMidTurnMsgs = []
879
971
  this.lastUserOpenId = ''
880
- this.pendingReactionIds = new Map()
881
- this.currentBatchReactionIds = new Map()
972
+ this.releaseAllReactions()
882
973
  this.initCount = 0
883
974
  this.openingTurn = false
884
975
  this.status = 'stopped'
@@ -932,8 +1023,39 @@ export class Session {
932
1023
  return this.proc?.lastContextWindow ?? 200_000
933
1024
  }
934
1025
 
1026
+ /** Drain `pendingMidTurnMsgs` to the SDK and open a fresh card for the
1027
+ * resulting batch turn. Called from the `result` handler when buffered
1028
+ * mid-turn messages need to start their own turn. The `sendUserText`
1029
+ * calls wake the SDK polling loop (priority="now" semantics) and
1030
+ * comprise the input for the new turn. Opens the card here rather
1031
+ * than deferring to init because the init for this batch will arrive
1032
+ * with `currentTurn` already set and bail. */
1033
+ private async drainMidTurnAndOpen(): Promise<void> {
1034
+ if (this.pendingMidTurnMsgs.length === 0) return
1035
+ const batch = this.pendingMidTurnMsgs
1036
+ this.pendingMidTurnMsgs = []
1037
+ this.openingTurn = true
1038
+ try {
1039
+ for (const msg of batch) {
1040
+ this.proc!.sendUserText(msg.wireText, msg.files)
1041
+ if (msg.msgId) {
1042
+ const rid = this.pendingReactionIds.get(msg.msgId) ?? ''
1043
+ this.currentBatchReactionIds.set(msg.msgId, rid)
1044
+ this.pendingReactionIds.delete(msg.msgId)
1045
+ }
1046
+ }
1047
+ const last = batch[batch.length - 1]
1048
+ const userOpenId = last?.userOpenId ?? this.lastUserOpenId
1049
+ await this.openTurnCard(userOpenId, 'user_message')
1050
+ this.status = 'working'
1051
+ } finally {
1052
+ this.openingTurn = false
1053
+ }
1054
+ }
1055
+
935
1056
  private async openTurnCard(userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
936
1057
  const turn = ++this.turnCounter
1058
+ log(`session "${this.sessionName}": openTurnCard turn=${turn} trigger=${trigger}`)
937
1059
  const card = cards.mainConversationCard({
938
1060
  sessionName: this.sessionName,
939
1061
  turn,
@@ -954,11 +1076,10 @@ export class Session {
954
1076
  this.chatId,
955
1077
  '❌ 创建对话卡片失败 (Feishu SDK 重试 3 次后仍连不上)。你这条消息没能送到 Claude,请稍后重发。',
956
1078
  )
957
- // Halt Claude we already wrote the user text to its stdin in
958
- // onUserMessage, but with no card to stream into the response would
959
- // be lost. Interrupt now so the model doesn't burn tokens producing
960
- // an answer that has nowhere to land.
961
- this.proc?.sendInterrupt()
1079
+ // currentTurn left null as the failure signal. Caller decides
1080
+ // whether to sendInterrupt: onUserMessage's eager-open path
1081
+ // hasn't fed SDK yet so doesn't need to; the init handler has
1082
+ // (SDK started the turn itself) and must.
962
1083
  return
963
1084
  }
964
1085
  let cardId: string
@@ -972,6 +1093,8 @@ export class Session {
972
1093
  thinkingText: '',
973
1094
  toolCount: 0,
974
1095
  toolByUseId: new Map(),
1096
+ readBatches: new Map(),
1097
+ openReadBatchI: null,
975
1098
  assistantSegmentCount: 0,
976
1099
  currentAssistantSegmentId: null,
977
1100
  currentAssistantText: '',
@@ -987,28 +1110,42 @@ export class Session {
987
1110
  // and mutate `this.currentTurn` underfoot.
988
1111
  private appendAssistant(delta: string): void {
989
1112
  if (!this.currentTurn) return
990
- if (!this.currentTurn.currentAssistantSegmentId) {
991
- const i = this.currentTurn.assistantSegmentCount++
1113
+ const turn = this.currentTurn
1114
+ if (!turn.currentAssistantSegmentId) {
1115
+ // New assistant segment opens a visual break — any prior Read run
1116
+ // is now visually separated from future Reads, so close the batch
1117
+ // window. Future Reads will start a fresh batch at a new i.
1118
+ turn.openReadBatchI = null
1119
+ const i = turn.assistantSegmentCount++
992
1120
  const segId = cards.ELEMENTS.assistant(i)
993
- this.currentTurn.currentAssistantSegmentId = segId
994
- this.currentTurn.currentAssistantText = ''
995
- void cardkit.addElement(this.currentTurn.cardId, cards.assistantSegmentElement(i), {
1121
+ turn.currentAssistantSegmentId = segId
1122
+ turn.currentAssistantText = ''
1123
+ void cardkit.addElement(turn.cardId, cards.assistantSegmentElement(i), {
996
1124
  type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
1125
+ }, () => {
1126
+ // addElement永久失败:reset segmentId 让下次 delta 重新创建
1127
+ // segment,否则后续 streamText 全都 PUT 到不存在的 element,
1128
+ // 整段 assistant text 在用户那看不到。守 segId 不变以防 turn
1129
+ // rotation 或 addTool 已经清掉了它(每次 addElement 闭包带的
1130
+ // 是自己创建那次的 segId,只清自己的)。
1131
+ if (turn.currentAssistantSegmentId === segId) {
1132
+ log(`session "${this.sessionName}": assistant segment ${segId} addElement failed — will retry on next delta`)
1133
+ turn.currentAssistantSegmentId = null
1134
+ turn.currentAssistantText = ''
1135
+ turn.segmentTexts.delete(segId)
1136
+ }
997
1137
  })
998
1138
  }
999
- this.currentTurn.currentAssistantText += delta
1000
- const segId = this.currentTurn.currentAssistantSegmentId
1001
- this.currentTurn.segmentTexts.set(segId, this.currentTurn.currentAssistantText)
1002
- cardkit.streamTextThrottled(
1003
- this.currentTurn.cardId,
1004
- segId,
1005
- this.currentTurn.currentAssistantText,
1006
- )
1139
+ turn.currentAssistantText += delta
1140
+ const segId = turn.currentAssistantSegmentId
1141
+ if (!segId) return // addElement 已失败 reset,等下一次 delta 重建
1142
+ turn.segmentTexts.set(segId, turn.currentAssistantText)
1143
+ cardkit.streamTextThrottled(turn.cardId, segId, turn.currentAssistantText)
1007
1144
  // Chat-list preview: tail of the latest assistant text. Feishu
1008
1145
  // truncates anyway; ~60 chars is what shows on a typical phone
1009
1146
  // preview line. patchSummaryThrottled is rate-limited on its own.
1010
- const tail = this.currentTurn.currentAssistantText.slice(-60)
1011
- cardkit.patchSummaryThrottled(this.currentTurn.cardId, tail)
1147
+ const tail = turn.currentAssistantText.slice(-60)
1148
+ cardkit.patchSummaryThrottled(turn.cardId, tail)
1012
1149
  }
1013
1150
 
1014
1151
  private appendThinking(delta: string): void {
@@ -1039,7 +1176,37 @@ export class Session {
1039
1176
  this.currentTurn.currentAssistantSegmentId = null
1040
1177
  this.currentTurn.currentAssistantText = ''
1041
1178
  }
1179
+ // Consecutive Read merger: if a Read run is already open, append to
1180
+ // its batch and re-render the panel instead of inserting a new one.
1181
+ // Any other tool name closes the run (handled below).
1182
+ if (name === 'Read' && this.currentTurn.openReadBatchI !== null) {
1183
+ const batchI = this.currentTurn.openReadBatchI
1184
+ const batch = this.currentTurn.readBatches.get(batchI)!
1185
+ const slot = batch.items.length
1186
+ batch.items.push({ toolUseId, input, output: null, isError: false })
1187
+ this.currentTurn.toolByUseId.set(toolUseId, { i: batchI, name, input, readBatchSlot: slot })
1188
+ const el = cards.readBatchElement(batchI, batch.items)
1189
+ void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(batchI), el)
1190
+ return
1191
+ }
1192
+ if (name !== 'Read') this.currentTurn.openReadBatchI = null
1042
1193
  const i = this.currentTurn.toolCount++
1194
+ if (name === 'Read') {
1195
+ // First Read of a potential run — render the existing single-tool
1196
+ // panel (which keeps the full file-contents dump on completion). If
1197
+ // a second Read arrives, completeTool/addTool will switch it to
1198
+ // `readBatchElement`.
1199
+ this.currentTurn.openReadBatchI = i
1200
+ this.currentTurn.readBatches.set(i, {
1201
+ items: [{ toolUseId, input, output: null, isError: false }],
1202
+ })
1203
+ this.currentTurn.toolByUseId.set(toolUseId, { i, name, input, readBatchSlot: 0 })
1204
+ const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, undefined)
1205
+ void cardkit.addElement(this.currentTurn.cardId, el, {
1206
+ type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
1207
+ })
1208
+ return
1209
+ }
1043
1210
  this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
1044
1211
  // AskUserQuestion is a client-side tool — daemon renders the choice
1045
1212
  // UI in-line and supplies the tool_result itself once the user
@@ -1116,6 +1283,22 @@ export class Session {
1116
1283
  // via toolCallElement would clobber the nice option-row layout
1117
1284
  // with a generic JSON dump. Bail out; the panel is done.
1118
1285
  if (meta.name === 'AskUserQuestion') return
1286
+ // Read batch path: update this row's status in the shared batch then
1287
+ // re-render. Single-item batches keep the original full-output panel
1288
+ // (file-contents dump); 2+ items switch to the compact `Read · N 次`
1289
+ // listing, which overwrites whatever was last drawn at this i.
1290
+ if (meta.name === 'Read' && meta.readBatchSlot != null) {
1291
+ const batch = this.currentTurn.readBatches.get(meta.i)
1292
+ if (batch) {
1293
+ const row = batch.items[meta.readBatchSlot]
1294
+ if (row) { row.output = output; row.isError = isError }
1295
+ const el = batch.items.length >= 2
1296
+ ? cards.readBatchElement(meta.i, batch.items)
1297
+ : cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, undefined)
1298
+ void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
1299
+ }
1300
+ return
1301
+ }
1119
1302
  // Update the local todo mirror BEFORE rendering so the just-
1120
1303
  // completed panel shows the new state too (e.g. a TaskCreate panel
1121
1304
  // already lists the task it just created).