@leviyuan/lodestar 0.1.2 → 0.1.4
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/package.json +1 -1
- package/src/cards.ts +41 -21
- package/src/session.ts +93 -33
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -337,6 +337,11 @@ export function askUserQuestionElement(
|
|
|
337
337
|
toolUseId: string,
|
|
338
338
|
questions: AskQuestion[],
|
|
339
339
|
status: '🤔' | '✅' | '❌' = '🤔',
|
|
340
|
+
/** When set, only this option is rendered as "picked" — every other
|
|
341
|
+
* option turns into plain text (no click target). Used after the
|
|
342
|
+
* user has answered so the panel freezes in a sensible terminal
|
|
343
|
+
* state instead of inviting another click. */
|
|
344
|
+
pickedOptionIdx?: number,
|
|
340
345
|
resolvedNote?: string,
|
|
341
346
|
): object {
|
|
342
347
|
const primary = questions[0]
|
|
@@ -345,18 +350,38 @@ export function askUserQuestionElement(
|
|
|
345
350
|
const bodyElements: any[] = []
|
|
346
351
|
if (primary) {
|
|
347
352
|
bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
353
|
+
// One row per option, each a full-width interactive_container so
|
|
354
|
+
// the entire row (label + description) is the click target. Looks
|
|
355
|
+
// cleaner than a row of squat buttons and matches the way IM
|
|
356
|
+
// quick-replies usually present themselves. After the user picks,
|
|
357
|
+
// we still render the same row layout (no JSON dump) but strip
|
|
358
|
+
// the callbacks — selected option marked ✅, others dimmed.
|
|
359
|
+
for (let optIdx = 0; optIdx < primary.options.length; optIdx++) {
|
|
360
|
+
const opt = primary.options[optIdx]
|
|
361
|
+
const isPicked = pickedOptionIdx === optIdx
|
|
362
|
+
const isAnswered = pickedOptionIdx !== undefined
|
|
363
|
+
const labelLine = isPicked
|
|
364
|
+
? `**✅ ${opt.label}**`
|
|
365
|
+
: isAnswered
|
|
366
|
+
? `~~${opt.label}~~`
|
|
367
|
+
: `**${opt.label}**`
|
|
368
|
+
const descLine = opt.description ? `\n${opt.description}` : ''
|
|
369
|
+
const rowContent = { tag: 'markdown', content: `${labelLine}${descLine}` }
|
|
370
|
+
if (isAnswered) {
|
|
371
|
+
// Frozen — no behaviors, plain container so it stops looking
|
|
372
|
+
// clickable.
|
|
373
|
+
bodyElements.push({
|
|
374
|
+
tag: 'div',
|
|
375
|
+
elements: [rowContent],
|
|
376
|
+
})
|
|
377
|
+
} else {
|
|
378
|
+
bodyElements.push({
|
|
379
|
+
tag: 'interactive_container',
|
|
380
|
+
background_style: 'default',
|
|
381
|
+
has_border: true,
|
|
382
|
+
corner_radius: '6px',
|
|
383
|
+
padding: '8px 12px',
|
|
384
|
+
margin: '4px 0',
|
|
360
385
|
behaviors: [{
|
|
361
386
|
type: 'callback',
|
|
362
387
|
value: {
|
|
@@ -366,15 +391,10 @@ export function askUserQuestionElement(
|
|
|
366
391
|
option_idx: optIdx,
|
|
367
392
|
},
|
|
368
393
|
}],
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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 })
|
|
394
|
+
elements: [rowContent],
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
}
|
|
378
398
|
}
|
|
379
399
|
// Secondary questions get text-only treatment (TODO: multi-question
|
|
380
400
|
// panels when actually requested by a real prompt).
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
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
|
-
/**
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
//
|
|
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
|
-
|
|
340
|
-
|
|
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 =
|
|
383
|
+
meta.output = JSON.stringify({ answers })
|
|
349
384
|
meta.isError = false
|
|
350
|
-
const resolvedNote = `\n\n
|
|
351
|
-
const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', resolvedNote)
|
|
385
|
+
const resolvedNote = `\n\n*已由 ${user || '匿名'} 回答*`
|
|
386
|
+
const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', optionIdx, 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> {
|
|
@@ -567,6 +606,12 @@ export class Session {
|
|
|
567
606
|
// can't discard the output after the first paint.
|
|
568
607
|
meta.output = output
|
|
569
608
|
meta.isError = isError
|
|
609
|
+
// AskUserQuestion already had its final panel painted by resolveAsk
|
|
610
|
+
// (✅ + the chosen option marked, others dimmed). The tool_result
|
|
611
|
+
// arriving here is just the SDK's synthesised echo — re-rendering
|
|
612
|
+
// via toolCallElement would clobber the nice option-row layout
|
|
613
|
+
// with a generic JSON dump. Bail out; the panel is done.
|
|
614
|
+
if (meta.name === 'AskUserQuestion') return
|
|
570
615
|
// Update the local todo mirror BEFORE rendering so the just-
|
|
571
616
|
// completed panel shows the new state too (e.g. a TaskCreate panel
|
|
572
617
|
// already lists the task it just created).
|
|
@@ -680,17 +725,32 @@ export class Session {
|
|
|
680
725
|
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
|
|
681
726
|
return
|
|
682
727
|
}
|
|
683
|
-
// AskUserQuestion:
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
// the
|
|
728
|
+
// AskUserQuestion: SDK routes it through can_use_tool even under
|
|
729
|
+
// bypass. The PAYLOAD of "user has answered" is the permission
|
|
730
|
+
// response itself — specifically `updatedInput.answers`. So we
|
|
731
|
+
// CANNOT auto-allow here (that's the v0.1.2 bug: SDK got an empty
|
|
732
|
+
// answers map and immediately synthesised a "User has answered
|
|
733
|
+
// your questions: ." tool_result). Park the requestId on the
|
|
734
|
+
// pendingAsk record and wait for the user to click an option;
|
|
735
|
+
// onAskAnswer will then send allow + updatedInput.answers in one
|
|
736
|
+
// shot. If the user already clicked between addTool and now —
|
|
737
|
+
// the deferredAnswer slot — settle immediately.
|
|
692
738
|
if (meta.name === 'AskUserQuestion') {
|
|
693
|
-
this.
|
|
739
|
+
const ask = this.pendingAsks.get(toolUseId)
|
|
740
|
+
if (!ask) {
|
|
741
|
+
// Defensive: addTool should have populated pendingAsks. If it
|
|
742
|
+
// didn't, fall back to a denial so Claude doesn't hang.
|
|
743
|
+
log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
|
|
744
|
+
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
ask.requestId = req.request_id
|
|
748
|
+
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
749
|
+
if (ask.deferredAnswer) {
|
|
750
|
+
const { questionIdx, optionIdx, user } = ask.deferredAnswer
|
|
751
|
+
ask.deferredAnswer = undefined
|
|
752
|
+
this.resolveAsk(toolUseId, req.request_id, questionIdx, optionIdx, user)
|
|
753
|
+
}
|
|
694
754
|
return
|
|
695
755
|
}
|
|
696
756
|
this.status = 'awaiting_permission'
|