@leviyuan/lodestar 0.1.1 → 0.1.2

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
@@ -140,6 +140,9 @@ async function handleCardAction(data: any): Promise<any> {
140
140
  case 'menu':
141
141
  await session.onUserMessage(`(menu choice ${value.choice + 1})`)
142
142
  return { toast: { type: 'success', content: 'OK' } }
143
+ case 'ask':
144
+ await session.onAskAnswer(value.tool_use_id, value.question_idx ?? 0, value.option_idx, userId)
145
+ return { toast: { type: 'success', content: '已回答' } }
143
146
  }
144
147
  return { toast: { type: 'info', content: 'unknown action' } }
145
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -19,6 +19,44 @@ export const ELEMENTS = {
19
19
  assistant: (i: number) => `assistant_${i}`,
20
20
  } as const
21
21
 
22
+ /** Minimal projection of an SDK task — used by Session's local mirror,
23
+ * built incrementally from observed TaskCreate / TaskUpdate input+output
24
+ * pairs. Not authoritative (the SDK is the source of truth), but enough
25
+ * to render the "全部任务清单" footer on every Task* panel. */
26
+ export interface Todo {
27
+ id: number
28
+ subject?: string
29
+ description?: string
30
+ status: 'pending' | 'in_progress' | 'completed' | string
31
+ owner?: string
32
+ activeForm?: string
33
+ }
34
+
35
+ function todoStatusIcon(s: string): string {
36
+ switch (s) {
37
+ case 'pending': return '☐'
38
+ case 'in_progress': return '🔄'
39
+ case 'completed': return '✅'
40
+ default: return '·'
41
+ }
42
+ }
43
+
44
+ /** Render the session's full todo mirror as a markdown list. Empty list
45
+ * yields '' so callers can unconditionally concat. Sorted by numeric id
46
+ * so the order matches creation order regardless of Map iteration. */
47
+ function renderTodoList(todos: Todo[]): string {
48
+ if (!todos || todos.length === 0) return ''
49
+ const sorted = [...todos].sort((a, b) => a.id - b.id)
50
+ const lines = ['', '---', `**📋 当前任务清单(${sorted.length} 项)**`, '']
51
+ for (const t of sorted) {
52
+ const icon = todoStatusIcon(t.status)
53
+ const subject = t.subject ?? '(无 subject)'
54
+ const ownerTag = t.owner ? ` · ${t.owner}` : ''
55
+ lines.push(`- ${icon} **#${t.id}** ${subject}${ownerTag}`)
56
+ }
57
+ return lines.join('\n')
58
+ }
59
+
22
60
  /** Single-line summary used as a collapsible-panel header for a tool call. */
23
61
  export function summarizeToolInput(name: string, input: any): string {
24
62
  if (!input || typeof input !== 'object') return ''
@@ -81,8 +119,10 @@ function summarizeTaskWorkflow(name: string, input: any): string {
81
119
  /** Markdown body for Task* workflow tools — replaces the generic JSON
82
120
  * dump with a human-readable description of the operation plus, once the
83
121
  * tool result is in, the SDK's text reply (which already contains "Task
84
- * #N created" / "Updated task #X" / a rendered list for TaskList). */
85
- function renderTaskWorkflowBody(name: string, input: any, output: string | null): string {
122
+ * #N created" / "Updated task #X" / a rendered list for TaskList). When
123
+ * `todos` is non-empty, the full mirror is appended as a "📋 当前任务
124
+ * 清单" footer so every Task* panel doubles as a current-state readout. */
125
+ function renderTaskWorkflowBody(name: string, input: any, output: string | null, todos?: Todo[]): string {
86
126
  const lines: string[] = []
87
127
  switch (name) {
88
128
  case 'TaskCreate':
@@ -118,7 +158,7 @@ function renderTaskWorkflowBody(name: string, input: any, output: string | null)
118
158
  lines.push('**结果**')
119
159
  lines.push(output.slice(0, 3000))
120
160
  }
121
- return lines.join('\n')
161
+ return lines.join('\n') + renderTodoList(todos ?? [])
122
162
  }
123
163
 
124
164
  interface MainCardOpts {
@@ -191,6 +231,10 @@ export function toolCallElement(
191
231
  output: string | null,
192
232
  status: '⏳' | '✅' | '❌' = '⏳',
193
233
  resolvedNote?: string,
234
+ /** Session's full todo mirror — only rendered when the tool is a Task*
235
+ * workflow op. Other tools ignore it. Passed in by Session so every
236
+ * Task* panel shows the *current* state, not just this op's diff. */
237
+ todos?: Todo[],
194
238
  ): object {
195
239
  const summary = summarizeToolInput(name, input)
196
240
  const headerText = summary
@@ -198,11 +242,12 @@ export function toolCallElement(
198
242
  : `${status} 🔧 ${name}`
199
243
  const isTaskWorkflow = name.startsWith('Task') && name !== 'Task'
200
244
  const noteBlock = resolvedNote ? `\n\n${resolvedNote}` : ''
201
- // Task* gets a narrative body (operation + result), the rest keeps the
202
- // JSON-input + raw-output split — generic dump is better for unfamiliar
203
- // tools where users can't predict what fields matter.
245
+ // Task* gets a narrative body (operation + result + current todo list),
246
+ // the rest keeps the JSON-input + raw-output split — generic dump is
247
+ // better for unfamiliar tools where users can't predict what fields
248
+ // matter.
204
249
  const body = isTaskWorkflow
205
- ? renderTaskWorkflowBody(name, input, output) + noteBlock
250
+ ? renderTaskWorkflowBody(name, input, output, todos) + noteBlock
206
251
  : '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
207
252
  + noteBlock
208
253
  + (output != null ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```' : '')
@@ -265,6 +310,92 @@ function permissionButtonColumn(label: string, type: string, requestId: string,
265
310
  }
266
311
  }
267
312
 
313
+ /** Schema of an AskUserQuestion question, projected to just the fields
314
+ * the panel needs. Mirrors the SDK tool's input — kept loose since the
315
+ * runtime guarantees it matches. */
316
+ export interface AskQuestion {
317
+ question: string
318
+ header?: string
319
+ multiSelect?: boolean
320
+ options: { label: string; description?: string }[]
321
+ }
322
+
323
+ /** Tool-panel renderer for `AskUserQuestion` — the SDK's structured
324
+ * multiple-choice question. Daemon takes over the client-side role:
325
+ * instead of letting the request fall through to the generic JSON
326
+ * dump (or worse, the permission flow that misappropriates it), we
327
+ * render each question with one button per option, callbacks tagged
328
+ * `kind:'ask'` so the Lark handler can route the answer back as a
329
+ * `tool_result`.
330
+ *
331
+ * Single-question is the common case; multi-question gets buttons on
332
+ * the first question only and a text-only listing for the rest (an
333
+ * acceptable limitation — these are rare in practice and we can lift
334
+ * it once the UX is validated). */
335
+ export function askUserQuestionElement(
336
+ i: number,
337
+ toolUseId: string,
338
+ questions: AskQuestion[],
339
+ status: '🤔' | '✅' | '❌' = '🤔',
340
+ resolvedNote?: string,
341
+ ): object {
342
+ const primary = questions[0]
343
+ const headerTag = primary?.header ? ` · ${primary.header}` : ''
344
+ const headerText = `${status} 🤔 AskUserQuestion${headerTag}`
345
+ const bodyElements: any[] = []
346
+ if (primary) {
347
+ bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
348
+ // Stack option buttons in a column_set — one button per option.
349
+ // Each carries `kind:'ask'` + the toolUseId + question/option idx
350
+ // so the daemon's card action handler can map a click back to
351
+ // exactly one (question, choice) pair.
352
+ bodyElements.push({
353
+ tag: 'column_set',
354
+ columns: primary.options.map((opt, optIdx) => ({
355
+ tag: 'column', width: 'weighted', weight: 1,
356
+ elements: [{
357
+ tag: 'button',
358
+ text: { tag: 'plain_text', content: opt.label },
359
+ type: 'default',
360
+ behaviors: [{
361
+ type: 'callback',
362
+ value: {
363
+ kind: 'ask',
364
+ tool_use_id: toolUseId,
365
+ question_idx: 0,
366
+ option_idx: optIdx,
367
+ },
368
+ }],
369
+ }],
370
+ })),
371
+ })
372
+ // Inline option descriptions below the buttons so the user can
373
+ // read context without hovering.
374
+ const descLines = primary.options
375
+ .map((o, idx) => o.description ? `- **${o.label}** — ${o.description}` : `- **${o.label}**`)
376
+ .join('\n')
377
+ if (descLines) bodyElements.push({ tag: 'markdown', content: descLines })
378
+ }
379
+ // Secondary questions get text-only treatment (TODO: multi-question
380
+ // panels when actually requested by a real prompt).
381
+ for (let qi = 1; qi < questions.length; qi++) {
382
+ const q = questions[qi]
383
+ const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
384
+ bodyElements.push({
385
+ tag: 'markdown',
386
+ content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
387
+ })
388
+ }
389
+ if (resolvedNote) bodyElements.push({ tag: 'markdown', content: resolvedNote })
390
+ return {
391
+ tag: 'collapsible_panel',
392
+ element_id: ELEMENTS.tool(i),
393
+ header: { title: { tag: 'plain_text', content: headerText } },
394
+ expanded: true,
395
+ elements: bodyElements,
396
+ }
397
+ }
398
+
268
399
  interface ConsoleOpts {
269
400
  sessionName: string
270
401
  status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
@@ -312,6 +312,31 @@ export class ClaudeProcess extends EventEmitter {
312
312
  })
313
313
  }
314
314
 
315
+ /** Feed a `tool_result` back to Claude as if a tool finished — used
316
+ * by the daemon when WE (the client) own the tool's execution side,
317
+ * e.g. AskUserQuestion: the SDK emits `tool_use` and waits for the
318
+ * client to render UI, collect a choice, and supply the result here.
319
+ * `content` is whatever the tool's contract says — for
320
+ * AskUserQuestion this is a JSON string `{"answers": {...}}`.
321
+ *
322
+ * NOTE: this is the same `{type:'user'}` envelope as `sendUserText`,
323
+ * just with a `tool_result` content block instead of plain text. The
324
+ * SDK demultiplexes by `tool_use_id`. */
325
+ sendToolResult(toolUseId: string, content: string, isError = false): void {
326
+ this.write({
327
+ type: 'user',
328
+ message: {
329
+ role: 'user',
330
+ content: [{
331
+ type: 'tool_result',
332
+ tool_use_id: toolUseId,
333
+ content,
334
+ ...(isError ? { is_error: true } : {}),
335
+ }],
336
+ },
337
+ })
338
+ }
339
+
315
340
  sendHookResponse(requestId: string, output: object = {}): void {
316
341
  this.write({
317
342
  type: 'control_response',
package/src/session.ts CHANGED
@@ -22,7 +22,18 @@ interface TurnState {
22
22
  userText: string
23
23
  thinkingText: string
24
24
  toolCount: number
25
- toolByUseId: Map<string, { i: number; name: string; input: any; resolvedNote?: string }>
25
+ /** `output` / `isError` are filled in by completeTool kept on the
26
+ * meta (instead of being thrown away after the first render) so a
27
+ * later Task* op can re-render every prior Task* panel with the
28
+ * latest todo mirror appended. */
29
+ toolByUseId: Map<string, {
30
+ i: number
31
+ name: string
32
+ input: any
33
+ resolvedNote?: string
34
+ output?: string
35
+ isError?: boolean
36
+ }>
26
37
  assistantSegmentCount: number
27
38
  currentAssistantSegmentId: string | null
28
39
  currentAssistantText: string
@@ -65,6 +76,11 @@ export class Session {
65
76
  private proc: ClaudeProcess | null = null
66
77
  private currentTurn: TurnState | null = null
67
78
  private pendingPermissions = new Map<string, { toolUseId: string }>()
79
+ /** Open AskUserQuestion tool calls — keyed by tool_use_id, valued
80
+ * with the questions array so we can construct the `answers` reply
81
+ * once a button is clicked. Cleared when the answer is sent or the
82
+ * turn ends. */
83
+ private pendingAsks = new Map<string, { questions: cards.AskQuestion[]; i: number }>()
68
84
  private turnCounter = 0
69
85
  // Last seen sessionId — preserved across `kill`/`stop` so a later
70
86
  // `restart` can resume the same Claude conversation even after the
@@ -73,6 +89,13 @@ export class Session {
73
89
  private startedAt: number = 0
74
90
  private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
75
91
  private lastTurnDelta: LastTurnDelta | null = null
92
+ /** Local mirror of the SDK's task list — built incrementally from
93
+ * TaskCreate / TaskUpdate input+output pairs and rendered as a footer
94
+ * on every Task* panel. Lives for the lifetime of the Session
95
+ * instance; daemon restart wipes it (the SDK doesn't replay history).
96
+ * Not authoritative — Claude calling TaskList is still the source of
97
+ * truth; this mirror is purely for the panel readout. */
98
+ private currentTodos = new Map<number, cards.Todo>()
76
99
  status: Status = 'stopped'
77
100
 
78
101
  constructor(
@@ -270,13 +293,14 @@ export class Session {
270
293
  const turn = this.currentTurn
271
294
  const meta = turn?.toolByUseId.get(pending.toolUseId)
272
295
  if (turn && meta) {
296
+ const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
273
297
  if (decision === 'deny') {
274
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌')
298
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
275
299
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
276
300
  } else {
277
301
  const label = decision === 'allow_always' ? '始终允许' : '已允许'
278
302
  meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
279
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote)
303
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
280
304
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
281
305
  }
282
306
  }
@@ -293,6 +317,42 @@ export class Session {
293
317
  }
294
318
  }
295
319
 
320
+ /** Handle a click on an AskUserQuestion option button. We construct
321
+ * the SDK-shaped `answers` map (question text → picked option label),
322
+ * send it back as a `tool_result`, and freeze the panel showing the
323
+ * picked option. The permission half of the flow was already auto-
324
+ * allowed in `renderPermission`, so no second action needed there. */
325
+ async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
326
+ const pending = this.pendingAsks.get(toolUseId)
327
+ if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
328
+ const q = pending.questions[questionIdx]
329
+ const opt = q?.options?.[optionIdx]
330
+ if (!q || !opt) {
331
+ log(`session "${this.sessionName}": ask answer out of range q=${questionIdx} o=${optionIdx}`)
332
+ return
333
+ }
334
+ // Build the answers map. SDK keys answers by the question text
335
+ // (matches what the AskUserQuestion contract expects). For
336
+ // unanswered secondary questions we don't include them — the SDK
337
+ // tolerates a partial map; Claude will see whichever it got.
338
+ const answers: Record<string, string> = { [q.question]: opt.label }
339
+ const content = JSON.stringify({ answers })
340
+ this.proc?.sendToolResult(toolUseId, content)
341
+ this.pendingAsks.delete(toolUseId)
342
+
343
+ // Repaint the panel to reflect the final choice — same element_id,
344
+ // status flips to ✅, body shows what was picked.
345
+ const turn = this.currentTurn
346
+ const meta = turn?.toolByUseId.get(toolUseId)
347
+ if (turn && meta) {
348
+ meta.output = content
349
+ meta.isError = false
350
+ const resolvedNote = `\n\n✅ **已回答** by ${user || '匿名'}: ${opt.label}`
351
+ const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', resolvedNote)
352
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
353
+ }
354
+ }
355
+
296
356
  async onConsoleAction(action: string): Promise<void> {
297
357
  log(`session "${this.sessionName}": console action=${action}`)
298
358
  switch (action) {
@@ -446,6 +506,14 @@ export class Session {
446
506
  )
447
507
  }
448
508
 
509
+ private isTaskWorkflow(name: string): boolean {
510
+ return name.startsWith('Task') && name !== 'Task'
511
+ }
512
+
513
+ private todosArray(): cards.Todo[] {
514
+ return [...this.currentTodos.values()]
515
+ }
516
+
449
517
  private addTool(toolUseId: string, name: string, input: any): void {
450
518
  if (!this.currentTurn) return
451
519
  // Close current assistant segment (if any) so the tool panel renders
@@ -458,7 +526,27 @@ export class Session {
458
526
  }
459
527
  const i = this.currentTurn.toolCount++
460
528
  this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
461
- const el = cards.toolCallElement(i, name, input, null, '⏳')
529
+ // AskUserQuestion is a client-side tool daemon renders the choice
530
+ // UI in-line and supplies the tool_result itself once the user
531
+ // clicks. Branch BEFORE the generic toolCallElement so we never
532
+ // fall through to a JSON dump or, worse, get clobbered by the
533
+ // permission flow (which would render 🔐 three-button buttons that
534
+ // don't match the actual N options).
535
+ if (name === 'AskUserQuestion') {
536
+ const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
537
+ this.pendingAsks.set(toolUseId, { questions, i })
538
+ const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔')
539
+ void cardkit.addElement(this.currentTurn.cardId, el, {
540
+ type: 'insert_before',
541
+ targetElementId: cards.ELEMENTS.footer,
542
+ })
543
+ return
544
+ }
545
+ // Pending Task* panels still show the *pre-op* todo mirror so users
546
+ // can read the current state immediately, without waiting for the
547
+ // tool to return.
548
+ const todos = this.isTaskWorkflow(name) ? this.todosArray() : undefined
549
+ const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, todos)
462
550
  void cardkit.addElement(this.currentTurn.cardId, el, {
463
551
  type: 'insert_before',
464
552
  targetElementId: cards.ELEMENTS.footer,
@@ -474,8 +562,89 @@ export class Session {
474
562
  : Array.isArray(content)
475
563
  ? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
476
564
  : JSON.stringify(content)
477
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote)
565
+ // Stash on the meta every Task* op coming after this point may
566
+ // need to re-render this panel with a fresher todo footer, so we
567
+ // can't discard the output after the first paint.
568
+ meta.output = output
569
+ meta.isError = isError
570
+ // Update the local todo mirror BEFORE rendering so the just-
571
+ // completed panel shows the new state too (e.g. a TaskCreate panel
572
+ // already lists the task it just created).
573
+ if (!isError && this.isTaskWorkflow(meta.name)) {
574
+ this.updateTodosFromTask(meta.name, meta.input, output)
575
+ }
576
+ const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
577
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, todos)
478
578
  void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
579
+ // Cascade the new mirror into every prior Task* panel in this turn
580
+ // so any expanded panel reflects the latest state, not the snapshot
581
+ // captured when that op ran.
582
+ if (!isError && this.isTaskWorkflow(meta.name)) {
583
+ this.refreshOtherTaskPanels(toolUseId)
584
+ }
585
+ }
586
+
587
+ /** Roll a single Task* op into the local mirror — best-effort. Output
588
+ * parsing is regex-based (the SDK returns plain text like "Task #7
589
+ * created successfully: …"), so unexpected variants are skipped
590
+ * silently rather than blowing up the panel render. */
591
+ private updateTodosFromTask(name: string, input: any, output: string): void {
592
+ switch (name) {
593
+ case 'TaskCreate': {
594
+ const m = output.match(/Task #(\d+) created/)
595
+ if (!m) return
596
+ const id = Number(m[1])
597
+ this.currentTodos.set(id, {
598
+ id,
599
+ subject: input.subject,
600
+ description: input.description,
601
+ activeForm: input.activeForm,
602
+ status: 'pending',
603
+ })
604
+ return
605
+ }
606
+ case 'TaskUpdate': {
607
+ const id = Number(input.taskId)
608
+ if (!Number.isFinite(id)) return
609
+ // status=deleted is the SDK's tombstone — drop from the mirror
610
+ // so the readout doesn't carry it forever. Server still keeps
611
+ // it; the mirror is just for the panel footer.
612
+ if (input.status === 'deleted') { this.currentTodos.delete(id); return }
613
+ const cur = this.currentTodos.get(id) ?? { id, status: 'pending' as const }
614
+ if (input.status) cur.status = input.status
615
+ if (input.subject) cur.subject = input.subject
616
+ if (input.description) cur.description = input.description
617
+ if (input.owner) cur.owner = input.owner
618
+ if (input.activeForm) cur.activeForm = input.activeForm
619
+ this.currentTodos.set(id, cur)
620
+ return
621
+ }
622
+ // TaskList / TaskGet / TaskStop / TaskOutput / TaskDelete:
623
+ // read-only or parse-heavy — skip mirror update. The panel will
624
+ // still render the SDK's textual result below the operation
625
+ // block, which is enough to disambiguate.
626
+ }
627
+ }
628
+
629
+ /** Re-render every Task* panel in the current turn (except the one
630
+ * that just landed — already up-to-date) so they all show the latest
631
+ * todo mirror in their footers. Cheap: ELEMENTS.tool(i) replace is
632
+ * queued through the per-card Promise chain like any other op. */
633
+ private refreshOtherTaskPanels(skipToolUseId: string): void {
634
+ if (!this.currentTurn) return
635
+ const todos = this.todosArray()
636
+ for (const [id, meta] of this.currentTurn.toolByUseId) {
637
+ if (id === skipToolUseId) continue
638
+ if (!this.isTaskWorkflow(meta.name)) continue
639
+ const status: '⏳' | '✅' | '❌' = meta.output === undefined
640
+ ? '⏳'
641
+ : (meta.isError ? '❌' : '✅')
642
+ const el = cards.toolCallElement(
643
+ meta.i, meta.name, meta.input, meta.output ?? null,
644
+ status, meta.resolvedNote, todos,
645
+ )
646
+ void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
647
+ }
479
648
  }
480
649
 
481
650
  /** Merge the permission ask into the existing tool element in the
@@ -511,6 +680,19 @@ export class Session {
511
680
  this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
512
681
  return
513
682
  }
683
+ // AskUserQuestion: daemon already rendered the choice UI in addTool
684
+ // and is collecting the user's answer asynchronously. The SDK
685
+ // wraps it in the standard permission flow anyway (even under
686
+ // bypassPermissions), so we auto-allow the request — otherwise
687
+ // Claude would block on can_use_tool forever, AND our permission
688
+ // renderer would overwrite the ask UI with the wrong 3-button
689
+ // panel. Keeping it allow-by-default also matches the user's
690
+ // mental model: they expect to choose an option, not "approve"
691
+ // the question itself.
692
+ if (meta.name === 'AskUserQuestion') {
693
+ this.proc?.sendPermissionResponse(req.request_id, 'allow')
694
+ return
695
+ }
514
696
  this.status = 'awaiting_permission'
515
697
  this.pendingPermissions.set(req.request_id, { toolUseId })
516
698
  const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)