@leviyuan/lodestar 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/feishu.ts CHANGED
@@ -8,11 +8,11 @@
8
8
 
9
9
  import * as lark from '@larksuiteoapi/node-sdk'
10
10
  import { execSync } from 'node:child_process'
11
- import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
11
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
12
12
  import { homedir } from 'node:os'
13
13
  import { basename, extname, join } from 'node:path'
14
14
  import { config } from './config'
15
- import { DATA_DIR, INBOX_DIR, SESSION_CHAT_MAP_FILE, SESSION_RESUME_MAP_FILE } from './paths'
15
+ import { ALIVE_MARKER_FILE, DATA_DIR, INBOX_DIR, SESSION_CHAT_MAP_FILE, SESSION_RESUME_MAP_FILE } from './paths'
16
16
  import { log } from './log'
17
17
 
18
18
  const APP_ID = config.feishu.app_id
@@ -106,6 +106,36 @@ export function getSessionResume(sessionName: string): string | null {
106
106
  return lastSessionIdByName.get(sessionName) ?? null
107
107
  }
108
108
 
109
+ // ── Alive-on-shutdown marker ──────────────────────────────────────────
110
+ // Persists the list of session names that were still running when the
111
+ // daemon went down. Next boot reads + unlinks the file and auto-spawns
112
+ // (via session.restart(true)) only those — sessions that were already
113
+ // `stop`ped before shutdown are deliberately NOT in this list, so they
114
+ // stay stopped after restart.
115
+
116
+ export function writeAliveMarker(sessionNames: string[]): void {
117
+ try {
118
+ writeFileSync(ALIVE_MARKER_FILE, JSON.stringify(sessionNames, null, 2))
119
+ } catch (e) { log(`feishu: write alive marker failed: ${e}`) }
120
+ }
121
+
122
+ /** Read + unlink in one shot — marker is single-use: revival should
123
+ * happen exactly once per boot, not re-run on every subsequent crash
124
+ * loop where systemd might keep re-launching us. */
125
+ export function readAndConsumeAliveMarker(): string[] {
126
+ if (!existsSync(ALIVE_MARKER_FILE)) return []
127
+ try {
128
+ const raw = readFileSync(ALIVE_MARKER_FILE, 'utf8')
129
+ try { unlinkSync(ALIVE_MARKER_FILE) } catch {}
130
+ const data = JSON.parse(raw)
131
+ return Array.isArray(data) ? data.filter((x: unknown): x is string => typeof x === 'string') : []
132
+ } catch (e) {
133
+ log(`feishu: read alive marker failed: ${e}`)
134
+ try { unlinkSync(ALIVE_MARKER_FILE) } catch {}
135
+ return []
136
+ }
137
+ }
138
+
109
139
  export function chatIdForSession(sessionName: string): string | null {
110
140
  const preferred = preferredChatForSession.get(sessionName)
111
141
  if (preferred && chatNameCache.get(preferred) === sessionName) return preferred
@@ -174,6 +204,50 @@ export async function addReaction(messageId: string, emojiType: string): Promise
174
204
  } catch (e) { log(`feishu: addReaction ${emojiType} on ${messageId} failed: ${e}`) }
175
205
  }
176
206
 
207
+ // ── Urgent push ───────────────────────────────────────────────────────
208
+ /** Fire Feishu's "加急 — 应用内" push for an already-sent message.
209
+ * Bypasses chat-level mute and pops a full-screen prompt on the
210
+ * recipient's phone. Bot must be the original sender of the message
211
+ * AND must still be a member of the chat.
212
+ *
213
+ * Endpoint:
214
+ * PATCH /open-apis/im/v1/messages/{message_id}/urgent_app
215
+ * ?user_id_type=open_id
216
+ * body: { user_id_list: ["ou_..."] }
217
+ *
218
+ * Required app scope (either one):
219
+ * - `im:message.urgent` (「发送应用内加急消息」)
220
+ * - `im:message.urgent:app_send` (「…(历史版本)」)
221
+ *
222
+ * Limits: 50 QPS app-wide; per-recipient cap is 200 unread urgent
223
+ * messages (230023). No daily quota.
224
+ *
225
+ * Common error codes:
226
+ * 230012 — message not sent by this bot
227
+ * 230023 — recipient has 200 unread urgent already
228
+ * 230052 — missing scope / chat restricts urgent */
229
+ export async function urgentApp(messageId: string, openIds: string[]): Promise<void> {
230
+ if (!messageId) { log(`feishu: urgentApp skip — missing messageId`); return }
231
+ if (openIds.length === 0) { log(`feishu: urgentApp skip — empty openIds (msg=${messageId})`); return }
232
+ const token = await getTenantToken()
233
+ const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/urgent_app?user_id_type=open_id`
234
+ try {
235
+ const res = await fetch(url, {
236
+ method: 'PATCH',
237
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
238
+ body: JSON.stringify({ user_id_list: openIds }),
239
+ })
240
+ const json = await res.json() as any
241
+ if (json?.code !== 0) {
242
+ log(`feishu: urgentApp ${messageId} code=${json?.code} msg=${json?.msg}`)
243
+ return
244
+ }
245
+ const invalid = json.data?.invalid_user_id_list ?? []
246
+ const delivered = openIds.length - invalid.length
247
+ log(`feishu: urgentApp ${messageId} ok — delivered=${delivered}${invalid.length ? ` invalid=${invalid.length}` : ''}`)
248
+ } catch (e) { log(`feishu: urgentApp ${messageId} failed: ${e}`) }
249
+ }
250
+
177
251
  // ── Attachment download (image/file) ───────────────────────────────────
178
252
  export async function downloadAttachment(
179
253
  messageId: string, key: string, type: 'image' | 'file', name?: string,
package/src/paths.ts CHANGED
@@ -40,4 +40,10 @@ export const PID_FILE = join(DATA_DIR, 'daemon.pid')
40
40
  export const LOG_FILE = join(DATA_DIR, 'daemon.log')
41
41
  export const SESSION_CHAT_MAP_FILE = join(DATA_DIR, 'session-chat-map.json')
42
42
  export const SESSION_RESUME_MAP_FILE = join(DATA_DIR, 'session-resume-map.json')
43
+ /** Marker file written at shutdown listing the session names that
44
+ * were still alive. The next daemon boot reads it (and unlinks it)
45
+ * to auto-revive those sessions via `restart(true)` — bridges the
46
+ * gap between systemctl-restart killing every child Claude and
47
+ * Lodestar's "you have to send a message to re-spawn it" default. */
48
+ export const ALIVE_MARKER_FILE = join(DATA_DIR, 'alive-on-shutdown.json')
43
49
  export const INBOX_DIR = join(DATA_DIR, 'inbox')
package/src/session.ts CHANGED
@@ -16,9 +16,18 @@ import * as cards from './cards'
16
16
  import * as feishu from './feishu'
17
17
  import { log } from './log'
18
18
  import { INBOX_DIR } from './paths'
19
+ import { readUsage } from './usage'
19
20
 
20
21
  interface TurnState {
21
22
  cardId: string
23
+ /** Feishu message_id of the card — needed for urgent_app push on clean
24
+ * turn close. Kept separate from cardId because cardkit's stream APIs
25
+ * operate on card_id but the urgent_app endpoint takes message_id. */
26
+ messageId: string
27
+ /** open_id of the user who started this turn. Used to scope the
28
+ * urgent_app push so only the initiator gets pinged (in case there
29
+ * are other members in the group). Empty string → skip the ping. */
30
+ userOpenId: string
22
31
  userText: string
23
32
  thinkingText: string
24
33
  toolCount: number
@@ -73,6 +82,14 @@ interface CumStats {
73
82
  }
74
83
 
75
84
  export class Session {
85
+ /** Process-wide registry of every Session ever constructed in this daemon.
86
+ * Used by the `hi` console panel to enumerate sibling sessions across
87
+ * Feishu groups. Sessions are never removed (matches the daemon's
88
+ * `sessions` map lifecycle — one Session per chat for the daemon's
89
+ * lifetime). Callers should filter on `isRunning()` when they only
90
+ * want currently-alive Claude processes. */
91
+ static readonly all: Set<Session> = new Set()
92
+
76
93
  private proc: ClaudeProcess | null = null
77
94
  private currentTurn: TurnState | null = null
78
95
  private pendingPermissions = new Map<string, { toolUseId: string }>()
@@ -89,12 +106,19 @@ export class Session {
89
106
  questions: cards.AskQuestion[]
90
107
  i: number
91
108
  requestId?: string
92
- deferredAnswer?: {
93
- questionIdx: number
94
- optionIdx?: number
95
- customText?: string
96
- user: string
97
- }
109
+ /** 累计答案 — key 是 question 原文 (SDK 把这条 record 格式
110
+ * 化进 tool_result), value 是用户选的 option label 或自定
111
+ * 义文字。全部 question 都答完时一并塞进 updatedInput.answers
112
+ * 发回 SDK。 */
113
+ answers: Record<string, string>
114
+ /** 已答详情 (按 question idx 索引),用来给历史面板和 terminal
115
+ * 状态画选中态。answers 同步累计,但这里多保留 customText /
116
+ * optionIdx 字段以便 UI 区分两种回答路径。 */
117
+ answered: Map<number, cards.AskAnswered>
118
+ /** 当前展示的 question idx。undefined 表示全部答完 (terminal)
119
+ * —— 这时若 requestId 已就位则 finalize;否则等 renderPermission
120
+ * 一来立即 finalize。 */
121
+ currentIdx?: number
98
122
  }>()
99
123
  private turnCounter = 0
100
124
  // Last seen sessionId — preserved across `kill`/`stop` so a later
@@ -118,6 +142,7 @@ export class Session {
118
142
  public readonly chatId: string,
119
143
  private opts: SessionOpts = {},
120
144
  ) {
145
+ Session.all.add(this)
121
146
  // Restore last-known claude session_id from disk so a daemon restart
122
147
  // (systemctl, crash, watchdog) doesn't strand the user with a fresh
123
148
  // conversation when they next type `restart`.
@@ -127,6 +152,16 @@ export class Session {
127
152
  }
128
153
  }
129
154
 
155
+ /** Minimal cross-chat snapshot for the `hi` peer-list section.
156
+ * `startedAt` stays private so this is the documented read path. */
157
+ peerSnapshot(): { name: string; status: Status; uptimeMs?: number } {
158
+ return {
159
+ name: this.sessionName,
160
+ status: this.status,
161
+ uptimeMs: this.startedAt ? (Date.now() - this.startedAt) : undefined,
162
+ }
163
+ }
164
+
130
165
  get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
131
166
  isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
132
167
 
@@ -168,12 +203,21 @@ export class Session {
168
203
  await feishu.sendText(this.chatId, `⚪ session "${this.sessionName}" 当前未运行`)
169
204
  return
170
205
  }
171
- this.lastSessionId = this.proc.sessionId ?? this.lastSessionId
172
- await this.proc.kill()
206
+ // Flip lifecycle state SYNCHRONOUSLY before awaiting kill — daemon's
207
+ // SIGTERM cleanup snapshots `isRunning()` and if we're still mid-
208
+ // `proc.kill()` await it'll see proc!=null and write us into the
209
+ // alive marker, which makes the next boot auto-revive a session
210
+ // the user explicitly killed. Reordering the null-out fixes that
211
+ // race (bug observed 2026-05-15: `kill` immediately followed by
212
+ // `systemctl restart` revived the killed session on boot).
213
+ log(`session "${this.sessionName}": stop (${reason})`)
214
+ const proc = this.proc
215
+ this.lastSessionId = proc.sessionId ?? this.lastSessionId
173
216
  this.proc = null
174
217
  this.currentTurn = null
175
218
  this.pendingPermissions.clear()
176
219
  this.status = 'stopped'
220
+ await proc.kill()
177
221
  await feishu.sendText(this.chatId, `🔴 ${reason} (session: ${this.sessionName})`)
178
222
  }
179
223
 
@@ -257,6 +301,14 @@ export class Session {
257
301
  model,
258
302
  effort: 'max',
259
303
  uptimeMs,
304
+ peers: [...Session.all]
305
+ .filter(s => s.isRunning())
306
+ .map(s => ({ ...s.peerSnapshot(), isCurrent: s === this })),
307
+ // Initial paint without usage → cards.ts renders the
308
+ // `_加载中…_` placeholder in the consoleUsage element. We patch
309
+ // it in below once readUsage() resolves (ccusage cold-call is
310
+ // ~5s; not worth blocking the panel on it).
311
+ usage: undefined,
260
312
  contextTokens: this.currentContextTokens(),
261
313
  cumStats: this.cumStats,
262
314
  lastTurn: this.lastTurnDelta
@@ -269,7 +321,22 @@ export class Session {
269
321
  sessionId: this.proc?.sessionId ?? this.lastSessionId,
270
322
  hasSession: this.isRunning(),
271
323
  })
272
- await feishu.sendCard(this.chatId, card)
324
+ const messageId = await feishu.sendCard(this.chatId, card)
325
+ if (!messageId) return
326
+ // Patch the usage element asynchronously so the rest of the panel
327
+ // stays responsive. We don't await; failures are logged and the
328
+ // placeholder stays visible (no fallback fabrication).
329
+ void (async () => {
330
+ try {
331
+ const cardId = await cardkit.convertMessageToCard(messageId)
332
+ const usage = await readUsage()
333
+ await cardkit.replaceElement(cardId, cards.ELEMENTS.consoleUsage, {
334
+ tag: 'markdown',
335
+ element_id: cards.ELEMENTS.consoleUsage,
336
+ content: cards.consoleUsageContent(usage),
337
+ })
338
+ } catch (e) { log(`session "${this.sessionName}": consoleUsage patch failed: ${e}`) }
339
+ })()
273
340
  }
274
341
 
275
342
  interrupt(): void {
@@ -279,7 +346,7 @@ export class Session {
279
346
  }
280
347
 
281
348
  // ── Inbound from Feishu ────────────────────────────────────────────
282
- async onUserMessage(text: string, files: string[] = []): Promise<void> {
349
+ async onUserMessage(text: string, files: string[] = [], userOpenId = ''): Promise<void> {
283
350
  if (!this.isRunning()) {
284
351
  const ok = await this.start()
285
352
  if (!ok) return
@@ -289,7 +356,7 @@ export class Session {
289
356
  this.proc!.sendInterrupt()
290
357
  await this.closeTurnCard('🛑 用户打断')
291
358
  }
292
- await this.openTurnCard(text)
359
+ await this.openTurnCard(text, userOpenId)
293
360
  this.proc!.sendUserText(text, files)
294
361
  this.status = 'working'
295
362
  }
@@ -340,11 +407,11 @@ export class Session {
340
407
  return this.pendingAsks.size > 0
341
408
  }
342
409
 
343
- /** Funnel an arbitrary chat message into the oldest pending ask as
344
- * a `customText` answer. Falls back to a normal turn if for some
345
- * reason there's nothing pending (defensive — handleMessage's gate
346
- * should prevent that). Picks the first entry by Map insertion
347
- * order, which is the earliest unanswered ask. */
410
+ /** Funnel an arbitrary chat message into the *current* question
411
+ * of the oldest pending ask as a `customText` answer. Multi-
412
+ * question semantics: from the user's perspective, the chat
413
+ * input always answers whatever question is on screen right now
414
+ * (`pending.currentIdx`), and a new question slides in after. */
348
415
  async onAskMessageAnswer(text: string, user: string): Promise<void> {
349
416
  const firstEntry = this.pendingAsks.entries().next()
350
417
  if (firstEntry.done) {
@@ -352,102 +419,121 @@ export class Session {
352
419
  await this.onUserMessage(text)
353
420
  return
354
421
  }
355
- const [toolUseId, _pending] = firstEntry.value
356
- await this.onAskCustomAnswer(toolUseId, 0, text, user)
422
+ const [toolUseId, pending] = firstEntry.value
423
+ if (pending.currentIdx === undefined) {
424
+ log(`session "${this.sessionName}": pending ask ${toolUseId} already terminal — ignoring message`)
425
+ return
426
+ }
427
+ await this.onAskCustomAnswer(toolUseId, pending.currentIdx, text, user)
357
428
  }
358
429
 
359
- /** Click handler for an AskUserQuestion option button. Dispatches
360
- * to `resolveAsk` if can_use_tool has already arrived, otherwise
361
- * parks the click on the pendingAsk record for renderPermission
362
- * to drain. */
430
+ /** Click handler for an option button. The click must target the
431
+ * question currently on screen (`pending.currentIdx`); a stale
432
+ * click (e.g. user clicked an older render before it swapped in
433
+ * the next question) is logged and dropped — better than double-
434
+ * answering. */
363
435
  async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
364
436
  const pending = this.pendingAsks.get(toolUseId)
365
437
  if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
366
- if (pending.requestId) {
367
- this.resolveAsk(toolUseId, pending.requestId, { questionIdx, optionIdx, user })
368
- } else {
369
- pending.deferredAnswer = { questionIdx, optionIdx, user }
370
- log(`session "${this.sessionName}": ask answer deferred for ${toolUseId} (no requestId yet)`)
438
+ if (questionIdx !== pending.currentIdx) {
439
+ log(`session "${this.sessionName}": stale ask click q=${questionIdx} current=${pending.currentIdx}`)
440
+ return
371
441
  }
442
+ this.advanceAsk(toolUseId, { optionIdx, user })
372
443
  }
373
444
 
374
- /** Form-submit handler for the custom-answer input on an
375
- * AskUserQuestion panel. Same dispatch pattern as `onAskAnswer`,
376
- * just routes a free-form string into `resolveAsk` instead of an
377
- * option index. Empty/whitespace input is ignored (no answer
378
- * sent, panel stays pending). */
445
+ /** Custom-text branch. Same staleness rule as onAskAnswer; empty
446
+ * input is silently ignored (panel stays pending). */
379
447
  async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
380
448
  const pending = this.pendingAsks.get(toolUseId)
381
449
  if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
382
450
  const trimmed = (customText ?? '').trim()
383
451
  if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
384
- if (pending.requestId) {
385
- this.resolveAsk(toolUseId, pending.requestId, { questionIdx, customText: trimmed, user })
386
- } else {
387
- pending.deferredAnswer = { questionIdx, customText: trimmed, user }
388
- log(`session "${this.sessionName}": ask custom deferred for ${toolUseId} (no requestId yet)`)
452
+ if (questionIdx !== pending.currentIdx) {
453
+ log(`session "${this.sessionName}": stale ask custom q=${questionIdx} current=${pending.currentIdx}`)
454
+ return
389
455
  }
456
+ this.advanceAsk(toolUseId, { customText: trimmed, user })
390
457
  }
391
458
 
392
- /** Settle an AskUserQuestion: emit the permission allow with the
393
- * picked option OR custom text folded into `updatedInput.answers`
394
- * (this is the shape the SDK reads to synthesise the tool_result
395
- * string), repaint the panel ✅, drop bookkeeping. Single source
396
- * of truth — option-click, custom-submit, and the deferred drain
397
- * all go through here. */
398
- private resolveAsk(
459
+ /** Record an answer for the current question, advance the state
460
+ * machine, repaint. If every question is now answered, finalize
461
+ * (or defer the finalize until can_use_tool lands the race is
462
+ * handled by renderPermission). */
463
+ private advanceAsk(
399
464
  toolUseId: string,
400
- requestId: string,
401
- answer: { questionIdx: number; optionIdx?: number; customText?: string; user: string },
465
+ answer: { optionIdx?: number; customText?: string; user: string },
402
466
  ): void {
403
467
  const pending = this.pendingAsks.get(toolUseId)
404
- if (!pending) return
405
- const q = pending.questions[answer.questionIdx]
406
- if (!q) {
407
- log(`session "${this.sessionName}": ask answer out of range q=${answer.questionIdx}`)
408
- return
409
- }
410
- // Determine the literal string that will become the SDK's
411
- // `answers` value for this question — custom wins if both are
412
- // somehow set (shouldn't happen, but defensive).
413
- let answerValue: string
414
- if (answer.customText) {
415
- answerValue = answer.customText
468
+ if (!pending || pending.currentIdx === undefined) return
469
+ const cur = pending.currentIdx
470
+ const q = pending.questions[cur]
471
+ if (!q) { log(`session "${this.sessionName}": advanceAsk currentIdx=${cur} out of range`); return }
472
+ // Resolve the literal answer value — custom text wins if both set.
473
+ let value: string
474
+ if (answer.customText !== undefined) {
475
+ value = answer.customText
416
476
  } else if (answer.optionIdx !== undefined) {
417
477
  const opt = q.options?.[answer.optionIdx]
418
- if (!opt) {
419
- log(`session "${this.sessionName}": ask option out of range o=${answer.optionIdx}`)
420
- return
421
- }
422
- answerValue = opt.label
478
+ if (!opt) { log(`session "${this.sessionName}": advanceAsk option ${answer.optionIdx} out of range`); return }
479
+ value = opt.label
423
480
  } else {
424
- log(`session "${this.sessionName}": resolveAsk called with neither optionIdx nor customText`)
481
+ log(`session "${this.sessionName}": advanceAsk with neither customText nor optionIdx`)
425
482
  return
426
483
  }
427
- const turn = this.currentTurn
428
- const meta = turn?.toolByUseId.get(toolUseId)
429
- const originalInput = meta?.input ?? {}
430
- // SDK keys the answer record by the question's text — confirmed
431
- // by the v0.1.2 jsonl trace (empty record formatted to "User has
432
- // answered your questions: .").
433
- const answers: Record<string, string> = { [q.question]: answerValue }
434
- this.proc?.sendPermissionResponse(requestId, 'allow', {
435
- updatedInput: { ...originalInput, answers },
484
+ pending.answers[q.question] = value
485
+ pending.answered.set(cur, {
486
+ optionIdx: answer.optionIdx,
487
+ customText: answer.customText,
488
+ user: answer.user,
436
489
  })
437
- this.pendingPermissions.delete(requestId)
438
- this.pendingAsks.delete(toolUseId)
490
+ // Next unanswered idx — linear from cur+1. Implementation
491
+ // always moves forward; we don't currently let users revisit a
492
+ // previous question (would need richer UI affordance for that).
493
+ const total = pending.questions.length
494
+ let nextIdx: number | undefined = undefined
495
+ for (let i = cur + 1; i < total; i++) {
496
+ if (!pending.answered.has(i)) { nextIdx = i; break }
497
+ }
498
+ pending.currentIdx = nextIdx
439
499
 
500
+ const turn = this.currentTurn
501
+ const meta = turn?.toolByUseId.get(toolUseId)
440
502
  if (turn && meta) {
441
- meta.output = JSON.stringify({ answers })
442
- meta.isError = false
443
- const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '', {
444
- optionIdx: answer.optionIdx,
445
- customText: answer.customText,
446
- user: answer.user || '匿名',
447
- })
503
+ const el = cards.askUserQuestionElement(
504
+ meta.i, toolUseId, pending.questions,
505
+ nextIdx === undefined ? '✅' : '🤔',
506
+ { currentIdx: nextIdx, answered: pending.answered },
507
+ )
448
508
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
449
509
  }
450
510
 
511
+ if (nextIdx === undefined) {
512
+ // All done. Finalize iff we have the permission request id;
513
+ // otherwise renderPermission will pick it up when it arrives.
514
+ if (pending.requestId) this.finalizeAsk(toolUseId)
515
+ else log(`session "${this.sessionName}": ask ${toolUseId} all answered, waiting for can_use_tool`)
516
+ }
517
+ }
518
+
519
+ /** Settle a fully-answered AskUserQuestion: emit the SDK allow
520
+ * with the full `answers` record folded into `updatedInput`,
521
+ * drop bookkeeping, restore status. The terminal panel paint was
522
+ * already done by the final advanceAsk; this is just protocol. */
523
+ private finalizeAsk(toolUseId: string): void {
524
+ const pending = this.pendingAsks.get(toolUseId)
525
+ if (!pending || !pending.requestId) return
526
+ const meta = this.currentTurn?.toolByUseId.get(toolUseId)
527
+ const originalInput = meta?.input ?? {}
528
+ this.proc?.sendPermissionResponse(pending.requestId, 'allow', {
529
+ updatedInput: { ...originalInput, answers: pending.answers },
530
+ })
531
+ this.pendingPermissions.delete(pending.requestId)
532
+ if (meta) {
533
+ meta.output = JSON.stringify({ answers: pending.answers })
534
+ meta.isError = false
535
+ }
536
+ this.pendingAsks.delete(toolUseId)
451
537
  if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
452
538
  this.status = 'working'
453
539
  }
@@ -543,7 +629,7 @@ export class Session {
543
629
  return this.lastTurnDelta?.inputTokens ?? 0
544
630
  }
545
631
 
546
- private async openTurnCard(userText: string): Promise<void> {
632
+ private async openTurnCard(userText: string, userOpenId: string): Promise<void> {
547
633
  const turn = ++this.turnCounter
548
634
  const card = cards.mainConversationCard({
549
635
  sessionName: this.sessionName,
@@ -558,6 +644,8 @@ export class Session {
558
644
  catch (e) { log(`session "${this.sessionName}": id_convert failed: ${e}`); return }
559
645
  this.currentTurn = {
560
646
  cardId,
647
+ messageId,
648
+ userOpenId,
561
649
  userText,
562
650
  thinkingText: '',
563
651
  toolCount: 0,
@@ -594,6 +682,11 @@ export class Session {
594
682
  segId,
595
683
  this.currentTurn.currentAssistantText,
596
684
  )
685
+ // Chat-list preview: tail of the latest assistant text. Feishu
686
+ // truncates anyway; ~60 chars is what shows on a typical phone
687
+ // preview line. patchSummaryThrottled is rate-limited on its own.
688
+ const tail = this.currentTurn.currentAssistantText.slice(-60)
689
+ cardkit.patchSummaryThrottled(this.currentTurn.cardId, tail)
597
690
  }
598
691
 
599
692
  private appendThinking(delta: string): void {
@@ -634,12 +727,28 @@ export class Session {
634
727
  // don't match the actual N options).
635
728
  if (name === 'AskUserQuestion') {
636
729
  const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
637
- this.pendingAsks.set(toolUseId, { questions, i })
638
- const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔')
730
+ const startIdx = questions.length > 0 ? 0 : undefined
731
+ const answered = new Map<number, cards.AskAnswered>()
732
+ this.pendingAsks.set(toolUseId, {
733
+ questions,
734
+ i,
735
+ answers: {},
736
+ answered,
737
+ currentIdx: startIdx,
738
+ })
739
+ const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔', {
740
+ currentIdx: startIdx,
741
+ answered,
742
+ })
639
743
  void cardkit.addElement(this.currentTurn.cardId, el, {
640
744
  type: 'insert_before',
641
745
  targetElementId: cards.ELEMENTS.footer,
642
746
  })
747
+ // Phone push — user has to come back and answer before Claude can
748
+ // continue. urgentApp no-ops when userOpenId is empty.
749
+ if (this.currentTurn.userOpenId && this.currentTurn.messageId) {
750
+ void feishu.urgentApp(this.currentTurn.messageId, [this.currentTurn.userOpenId])
751
+ }
643
752
  return
644
753
  }
645
754
  // Pending Task* panels still show the *pre-op* todo mirror so users
@@ -799,30 +908,26 @@ export class Session {
799
908
  if (meta.name === 'AskUserQuestion') {
800
909
  const ask = this.pendingAsks.get(toolUseId)
801
910
  if (!ask) {
802
- // Defensive: addTool should have populated pendingAsks. If it
803
- // didn't, fall back to a denial so Claude doesn't hang.
804
911
  log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
805
912
  this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
806
913
  return
807
914
  }
808
915
  ask.requestId = req.request_id
809
916
  this.pendingPermissions.set(req.request_id, { toolUseId })
810
- if (ask.deferredAnswer) {
811
- const d = ask.deferredAnswer
812
- ask.deferredAnswer = undefined
813
- this.resolveAsk(toolUseId, req.request_id, {
814
- questionIdx: d.questionIdx,
815
- optionIdx: d.optionIdx,
816
- customText: d.customText,
817
- user: d.user,
818
- })
819
- }
917
+ // Fast-clicker race: the user may have answered every question
918
+ // while we were still waiting for can_use_tool to arrive. If so,
919
+ // advanceAsk parked the all-done state and we drain it now.
920
+ if (ask.currentIdx === undefined) this.finalizeAsk(toolUseId)
820
921
  return
821
922
  }
822
923
  this.status = 'awaiting_permission'
823
924
  this.pendingPermissions.set(req.request_id, { toolUseId })
824
925
  const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
825
926
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
927
+ // Phone push — Claude is blocked until the user approves/denies.
928
+ if (turn.userOpenId && turn.messageId) {
929
+ void feishu.urgentApp(turn.messageId, [turn.userOpenId])
930
+ }
826
931
  }
827
932
 
828
933
  private async closeTurnCard(suffix?: string): Promise<void> {
@@ -867,9 +972,26 @@ export class Session {
867
972
  const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
868
973
  const footer = `⏱ ${elapsed}s${suffix ? ' · ' + suffix : ''}${sendNote} · ✅ done`
869
974
  await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
870
- await cardkit.patchSettings(cardId, cards.STREAMING_OFF_SETTINGS)
975
+ // Final chat-list preview: clean finish shows "⏱ Xs · NK tokens";
976
+ // interrupted shows the suffix instead (no usage event landed).
977
+ // cancelSummary kills any in-flight throttled write so a stale
978
+ // mid-stream tail can't clobber this terminal summary.
979
+ cardkit.cancelSummary(cardId)
980
+ await cardkit.patchSettings(cardId, cards.streamingOffSettings({
981
+ durationSec: elapsed,
982
+ tokens: suffix ? undefined : this.lastTurnDelta?.tokens,
983
+ suffix,
984
+ }))
871
985
  await cardkit.dispose(cardId)
872
986
 
987
+ // Phone push on clean turn close so the user knows Claude is done
988
+ // even with the chat backgrounded. Skip on interrupts (no real
989
+ // completion) and when we don't know who to ping. Fire-and-forget;
990
+ // urgent_app failures are non-fatal and already logged in feishu.ts.
991
+ if (!suffix && turn.userOpenId && turn.messageId) {
992
+ void feishu.urgentApp(turn.messageId, [turn.userOpenId])
993
+ }
994
+
873
995
  // Fire uploads sequentially AFTER the card is sealed so each file
874
996
  // posts as its own Feishu message below the conversation card.
875
997
  // Path gate: workDir (Claude's project sandbox), the inbox where