@leviyuan/lodestar 0.1.1 → 0.1.3

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.3",
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,20 @@ 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. The SDK
80
+ * routes AskUserQuestion through the can_use_tool flow even under
81
+ * bypass; we have to thread the permission `requestId` through here
82
+ * so onAskAnswer can answer it with `updatedInput.answers` populated.
83
+ * `deferredAnswer` covers the race where the user clicks a button
84
+ * BEFORE can_use_tool arrives (addTool fires on the assistant
85
+ * message; can_use_tool is a separate control_request that lands
86
+ * slightly later). */
87
+ private pendingAsks = new Map<string, {
88
+ questions: cards.AskQuestion[]
89
+ i: number
90
+ requestId?: string
91
+ deferredAnswer?: { questionIdx: number; optionIdx: number; user: string }
92
+ }>()
68
93
  private turnCounter = 0
69
94
  // Last seen sessionId — preserved across `kill`/`stop` so a later
70
95
  // `restart` can resume the same Claude conversation even after the
@@ -73,6 +98,13 @@ export class Session {
73
98
  private startedAt: number = 0
74
99
  private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
75
100
  private lastTurnDelta: LastTurnDelta | null = null
101
+ /** Local mirror of the SDK's task list — built incrementally from
102
+ * TaskCreate / TaskUpdate input+output pairs and rendered as a footer
103
+ * on every Task* panel. Lives for the lifetime of the Session
104
+ * instance; daemon restart wipes it (the SDK doesn't replay history).
105
+ * Not authoritative — Claude calling TaskList is still the source of
106
+ * truth; this mirror is purely for the panel readout. */
107
+ private currentTodos = new Map<number, cards.Todo>()
76
108
  status: Status = 'stopped'
77
109
 
78
110
  constructor(
@@ -270,13 +302,14 @@ export class Session {
270
302
  const turn = this.currentTurn
271
303
  const meta = turn?.toolByUseId.get(pending.toolUseId)
272
304
  if (turn && meta) {
305
+ const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
273
306
  if (decision === 'deny') {
274
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌')
307
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
275
308
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
276
309
  } else {
277
310
  const label = decision === 'allow_always' ? '始终允许' : '已允许'
278
311
  meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
279
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote)
312
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
280
313
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
281
314
  }
282
315
  }
@@ -293,6 +326,72 @@ export class Session {
293
326
  }
294
327
  }
295
328
 
329
+ /** Click handler for an AskUserQuestion option button. The actual
330
+ * "send answer to Claude" plumbing lives in `resolveAsk` — this
331
+ * wrapper just chooses between answering immediately (the common
332
+ * case: can_use_tool has already arrived and parked a requestId on
333
+ * the pendingAsk record) versus deferring (the rare race where the
334
+ * user clicks before can_use_tool lands). */
335
+ async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
336
+ const pending = this.pendingAsks.get(toolUseId)
337
+ if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
338
+ if (pending.requestId) {
339
+ this.resolveAsk(toolUseId, pending.requestId, questionIdx, optionIdx, user)
340
+ } else {
341
+ // can_use_tool hasn't landed yet — park the click. renderPermission
342
+ // will drain it the moment the request arrives.
343
+ pending.deferredAnswer = { questionIdx, optionIdx, user }
344
+ log(`session "${this.sessionName}": ask answer deferred for ${toolUseId} (no requestId yet)`)
345
+ }
346
+ }
347
+
348
+ /** Settle an AskUserQuestion: emit the permission allow with the
349
+ * picked option folded into `updatedInput.answers` (this is the
350
+ * shape the SDK reads to synthesise the tool_result string), repaint
351
+ * the panel ✅, drop bookkeeping. Single source of truth — both the
352
+ * normal click path and the deferred-answer drain go through here. */
353
+ private resolveAsk(
354
+ toolUseId: string,
355
+ requestId: string,
356
+ questionIdx: number,
357
+ optionIdx: number,
358
+ user: string,
359
+ ): void {
360
+ const pending = this.pendingAsks.get(toolUseId)
361
+ if (!pending) return
362
+ const q = pending.questions[questionIdx]
363
+ const opt = q?.options?.[optionIdx]
364
+ if (!q || !opt) {
365
+ log(`session "${this.sessionName}": ask answer out of range q=${questionIdx} o=${optionIdx}`)
366
+ return
367
+ }
368
+ const turn = this.currentTurn
369
+ const meta = turn?.toolByUseId.get(toolUseId)
370
+ const originalInput = meta?.input ?? {}
371
+ // SDK keys the answer record by the question's text — confirmed
372
+ // by the v0.1.2 jsonl trace (empty record formatted to "User has
373
+ // answered your questions: ."). Populate the key matching this
374
+ // question with the chosen option's label.
375
+ const answers: Record<string, string> = { [q.question]: opt.label }
376
+ this.proc?.sendPermissionResponse(requestId, 'allow', {
377
+ updatedInput: { ...originalInput, answers },
378
+ })
379
+ this.pendingPermissions.delete(requestId)
380
+ this.pendingAsks.delete(toolUseId)
381
+
382
+ if (turn && meta) {
383
+ meta.output = JSON.stringify({ answers })
384
+ meta.isError = false
385
+ const resolvedNote = `\n\n✅ **已回答** by ${user || '匿名'}: ${opt.label}`
386
+ const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', resolvedNote)
387
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
388
+ }
389
+
390
+ if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
391
+ this.status = 'working'
392
+ }
393
+ }
394
+
296
395
  async onConsoleAction(action: string): Promise<void> {
297
396
  log(`session "${this.sessionName}": console action=${action}`)
298
397
  switch (action) {
@@ -446,6 +545,14 @@ export class Session {
446
545
  )
447
546
  }
448
547
 
548
+ private isTaskWorkflow(name: string): boolean {
549
+ return name.startsWith('Task') && name !== 'Task'
550
+ }
551
+
552
+ private todosArray(): cards.Todo[] {
553
+ return [...this.currentTodos.values()]
554
+ }
555
+
449
556
  private addTool(toolUseId: string, name: string, input: any): void {
450
557
  if (!this.currentTurn) return
451
558
  // Close current assistant segment (if any) so the tool panel renders
@@ -458,7 +565,27 @@ export class Session {
458
565
  }
459
566
  const i = this.currentTurn.toolCount++
460
567
  this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
461
- const el = cards.toolCallElement(i, name, input, null, '⏳')
568
+ // AskUserQuestion is a client-side tool daemon renders the choice
569
+ // UI in-line and supplies the tool_result itself once the user
570
+ // clicks. Branch BEFORE the generic toolCallElement so we never
571
+ // fall through to a JSON dump or, worse, get clobbered by the
572
+ // permission flow (which would render 🔐 three-button buttons that
573
+ // don't match the actual N options).
574
+ if (name === 'AskUserQuestion') {
575
+ const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
576
+ this.pendingAsks.set(toolUseId, { questions, i })
577
+ const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔')
578
+ void cardkit.addElement(this.currentTurn.cardId, el, {
579
+ type: 'insert_before',
580
+ targetElementId: cards.ELEMENTS.footer,
581
+ })
582
+ return
583
+ }
584
+ // Pending Task* panels still show the *pre-op* todo mirror so users
585
+ // can read the current state immediately, without waiting for the
586
+ // tool to return.
587
+ const todos = this.isTaskWorkflow(name) ? this.todosArray() : undefined
588
+ const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, todos)
462
589
  void cardkit.addElement(this.currentTurn.cardId, el, {
463
590
  type: 'insert_before',
464
591
  targetElementId: cards.ELEMENTS.footer,
@@ -474,8 +601,89 @@ export class Session {
474
601
  : Array.isArray(content)
475
602
  ? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
476
603
  : JSON.stringify(content)
477
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote)
604
+ // Stash on the meta every Task* op coming after this point may
605
+ // need to re-render this panel with a fresher todo footer, so we
606
+ // can't discard the output after the first paint.
607
+ meta.output = output
608
+ meta.isError = isError
609
+ // Update the local todo mirror BEFORE rendering so the just-
610
+ // completed panel shows the new state too (e.g. a TaskCreate panel
611
+ // already lists the task it just created).
612
+ if (!isError && this.isTaskWorkflow(meta.name)) {
613
+ this.updateTodosFromTask(meta.name, meta.input, output)
614
+ }
615
+ const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
616
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, todos)
478
617
  void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
618
+ // Cascade the new mirror into every prior Task* panel in this turn
619
+ // so any expanded panel reflects the latest state, not the snapshot
620
+ // captured when that op ran.
621
+ if (!isError && this.isTaskWorkflow(meta.name)) {
622
+ this.refreshOtherTaskPanels(toolUseId)
623
+ }
624
+ }
625
+
626
+ /** Roll a single Task* op into the local mirror — best-effort. Output
627
+ * parsing is regex-based (the SDK returns plain text like "Task #7
628
+ * created successfully: …"), so unexpected variants are skipped
629
+ * silently rather than blowing up the panel render. */
630
+ private updateTodosFromTask(name: string, input: any, output: string): void {
631
+ switch (name) {
632
+ case 'TaskCreate': {
633
+ const m = output.match(/Task #(\d+) created/)
634
+ if (!m) return
635
+ const id = Number(m[1])
636
+ this.currentTodos.set(id, {
637
+ id,
638
+ subject: input.subject,
639
+ description: input.description,
640
+ activeForm: input.activeForm,
641
+ status: 'pending',
642
+ })
643
+ return
644
+ }
645
+ case 'TaskUpdate': {
646
+ const id = Number(input.taskId)
647
+ if (!Number.isFinite(id)) return
648
+ // status=deleted is the SDK's tombstone — drop from the mirror
649
+ // so the readout doesn't carry it forever. Server still keeps
650
+ // it; the mirror is just for the panel footer.
651
+ if (input.status === 'deleted') { this.currentTodos.delete(id); return }
652
+ const cur = this.currentTodos.get(id) ?? { id, status: 'pending' as const }
653
+ if (input.status) cur.status = input.status
654
+ if (input.subject) cur.subject = input.subject
655
+ if (input.description) cur.description = input.description
656
+ if (input.owner) cur.owner = input.owner
657
+ if (input.activeForm) cur.activeForm = input.activeForm
658
+ this.currentTodos.set(id, cur)
659
+ return
660
+ }
661
+ // TaskList / TaskGet / TaskStop / TaskOutput / TaskDelete:
662
+ // read-only or parse-heavy — skip mirror update. The panel will
663
+ // still render the SDK's textual result below the operation
664
+ // block, which is enough to disambiguate.
665
+ }
666
+ }
667
+
668
+ /** Re-render every Task* panel in the current turn (except the one
669
+ * that just landed — already up-to-date) so they all show the latest
670
+ * todo mirror in their footers. Cheap: ELEMENTS.tool(i) replace is
671
+ * queued through the per-card Promise chain like any other op. */
672
+ private refreshOtherTaskPanels(skipToolUseId: string): void {
673
+ if (!this.currentTurn) return
674
+ const todos = this.todosArray()
675
+ for (const [id, meta] of this.currentTurn.toolByUseId) {
676
+ if (id === skipToolUseId) continue
677
+ if (!this.isTaskWorkflow(meta.name)) continue
678
+ const status: '⏳' | '✅' | '❌' = meta.output === undefined
679
+ ? '⏳'
680
+ : (meta.isError ? '❌' : '✅')
681
+ const el = cards.toolCallElement(
682
+ meta.i, meta.name, meta.input, meta.output ?? null,
683
+ status, meta.resolvedNote, todos,
684
+ )
685
+ void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
686
+ }
479
687
  }
480
688
 
481
689
  /** Merge the permission ask into the existing tool element in the
@@ -511,6 +719,34 @@ export class Session {
511
719
  this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
512
720
  return
513
721
  }
722
+ // AskUserQuestion: SDK routes it through can_use_tool even under
723
+ // bypass. The PAYLOAD of "user has answered" is the permission
724
+ // response itself — specifically `updatedInput.answers`. So we
725
+ // CANNOT auto-allow here (that's the v0.1.2 bug: SDK got an empty
726
+ // answers map and immediately synthesised a "User has answered
727
+ // your questions: ." tool_result). Park the requestId on the
728
+ // pendingAsk record and wait for the user to click an option;
729
+ // onAskAnswer will then send allow + updatedInput.answers in one
730
+ // shot. If the user already clicked between addTool and now —
731
+ // the deferredAnswer slot — settle immediately.
732
+ if (meta.name === 'AskUserQuestion') {
733
+ const ask = this.pendingAsks.get(toolUseId)
734
+ if (!ask) {
735
+ // Defensive: addTool should have populated pendingAsks. If it
736
+ // didn't, fall back to a denial so Claude doesn't hang.
737
+ log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
738
+ this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
739
+ return
740
+ }
741
+ ask.requestId = req.request_id
742
+ this.pendingPermissions.set(req.request_id, { toolUseId })
743
+ if (ask.deferredAnswer) {
744
+ const { questionIdx, optionIdx, user } = ask.deferredAnswer
745
+ ask.deferredAnswer = undefined
746
+ this.resolveAsk(toolUseId, req.request_id, questionIdx, optionIdx, user)
747
+ }
748
+ return
749
+ }
514
750
  this.status = 'awaiting_permission'
515
751
  this.pendingPermissions.set(req.request_id, { toolUseId })
516
752
  const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)