@leviyuan/lodestar 0.1.2 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/session.ts +85 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/session.ts CHANGED
@@ -76,11 +76,20 @@ export class Session {
76
76
  private proc: ClaudeProcess | null = null
77
77
  private currentTurn: TurnState | null = null
78
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 }>()
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
+ }>()
84
93
  private turnCounter = 0
85
94
  // Last seen sessionId — preserved across `kill`/`stop` so a later
86
95
  // `restart` can resume the same Claude conversation even after the
@@ -317,40 +326,70 @@ export class Session {
317
326
  }
318
327
  }
319
328
 
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. */
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). */
325
335
  async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
326
336
  const pending = this.pendingAsks.get(toolUseId)
327
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
328
362
  const q = pending.questions[questionIdx]
329
363
  const opt = q?.options?.[optionIdx]
330
364
  if (!q || !opt) {
331
365
  log(`session "${this.sessionName}": ask answer out of range q=${questionIdx} o=${optionIdx}`)
332
366
  return
333
367
  }
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.
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.
338
375
  const answers: Record<string, string> = { [q.question]: opt.label }
339
- const content = JSON.stringify({ answers })
340
- this.proc?.sendToolResult(toolUseId, content)
376
+ this.proc?.sendPermissionResponse(requestId, 'allow', {
377
+ updatedInput: { ...originalInput, answers },
378
+ })
379
+ this.pendingPermissions.delete(requestId)
341
380
  this.pendingAsks.delete(toolUseId)
342
381
 
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
382
  if (turn && meta) {
348
- meta.output = content
383
+ meta.output = JSON.stringify({ answers })
349
384
  meta.isError = false
350
385
  const resolvedNote = `\n\n✅ **已回答** by ${user || '匿名'}: ${opt.label}`
351
386
  const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', resolvedNote)
352
387
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
353
388
  }
389
+
390
+ if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
391
+ this.status = 'working'
392
+ }
354
393
  }
355
394
 
356
395
  async onConsoleAction(action: string): Promise<void> {
@@ -680,17 +719,32 @@ export class Session {
680
719
  this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
681
720
  return
682
721
  }
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.
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.
692
732
  if (meta.name === 'AskUserQuestion') {
693
- this.proc?.sendPermissionResponse(req.request_id, 'allow')
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
+ }
694
748
  return
695
749
  }
696
750
  this.status = 'awaiting_permission'