@leviyuan/lodestar 0.1.5 → 0.1.7

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
@@ -32,7 +32,19 @@ try {
32
32
  mkdirSync(dirname(PID_FILE), { recursive: true })
33
33
  writeFileSync(PID_FILE, String(process.pid))
34
34
 
35
- const cleanup = () => { try { unlinkSync(PID_FILE) } catch {} }
35
+ const cleanup = () => {
36
+ // Snapshot which sessions are still alive so the next boot can
37
+ // revive them — only the ones still running at shutdown, NOT
38
+ // anything the user already `kill`-ed (those are absent from the
39
+ // sessions Map filter below and stay stopped after restart).
40
+ try {
41
+ const alive: string[] = []
42
+ for (const s of sessions.values()) if (s.isRunning()) alive.push(s.sessionName)
43
+ feishu.writeAliveMarker(alive)
44
+ if (alive.length > 0) log(`alive marker: [${alive.join(', ')}]`)
45
+ } catch (e) { log(`alive marker write failed: ${e}`) }
46
+ try { unlinkSync(PID_FILE) } catch {}
47
+ }
36
48
  process.on('exit', cleanup)
37
49
  process.on('SIGTERM', () => { log('SIGTERM'); cleanup(); process.exit(0) })
38
50
  process.on('SIGINT', () => { log('SIGINT'); cleanup(); process.exit(0) })
@@ -51,6 +63,33 @@ function sessionFor(chatId: string, sessionName: string): Session {
51
63
  return s
52
64
  }
53
65
 
66
+ /** Auto-restart any session that was alive when the previous daemon
67
+ * went down. Driven by the marker file written in `cleanup` — that
68
+ * file ONLY lists sessions that were running, so anything the user
69
+ * had explicitly `kill`-ed before shutdown is intentionally absent
70
+ * and stays stopped. Each revived session is `restart(true)`-ed so
71
+ * the SDK gets `--resume <claudeSessionId>` and the in-flight
72
+ * conversation continues without the user typing anything. */
73
+ async function reviveAliveSessions(): Promise<void> {
74
+ const names = feishu.readAndConsumeAliveMarker()
75
+ if (names.length === 0) return
76
+ log(`revive: ${names.length} session(s) marked alive on shutdown: ${names.join(', ')}`)
77
+ for (const sessionName of names) {
78
+ const chatId = feishu.chatIdForSession(sessionName)
79
+ if (!chatId) {
80
+ log(`revive: no chatId binding for "${sessionName}", skip`)
81
+ continue
82
+ }
83
+ const session = sessionFor(chatId, sessionName)
84
+ try {
85
+ await session.restart(true)
86
+ log(`revive: spawned "${sessionName}" (chat ${chatId.slice(0, 8)}…)`)
87
+ } catch (e) {
88
+ log(`revive: restart "${sessionName}" failed: ${e}`)
89
+ }
90
+ }
91
+ }
92
+
54
93
  // ── Inbound message handler ─────────────────────────────────────────────
55
94
  const STALE_THRESHOLD_MS = 10_000
56
95
  const seenMessageIds = new Set<string>()
@@ -108,6 +147,18 @@ async function handleMessage(data: any): Promise<void> {
108
147
  if (await session.runCommand(text)) return
109
148
  }
110
149
 
150
+ // Pending AskUserQuestion: route the message as a custom answer
151
+ // instead of opening a new turn. This is how custom-text answers
152
+ // work in this version — Feishu schema 2.0 doesn't support form/
153
+ // input elements, so the chat box itself is the input. Only applies
154
+ // to text-only messages (an image attachment opens a new turn as
155
+ // usual). Bare-word commands have already been intercepted above.
156
+ if (msgType === 'text' && text && session.hasPendingAsk()) {
157
+ const userId = message.sender?.sender_id?.open_id ?? ''
158
+ await session.onAskMessageAnswer(text, userId)
159
+ return
160
+ }
161
+
111
162
  let filePath: string | undefined
112
163
  if (msgType === 'image' && contentObj.image_key) {
113
164
  filePath = await feishu.downloadAttachment(message.message_id, contentObj.image_key, 'image')
@@ -213,6 +264,11 @@ async function boot(): Promise<void> {
213
264
  })
214
265
  ws.start({ eventDispatcher: dispatcher })
215
266
  log(`lodestar-daemon: WS started, watching ${feishu.chatNameCache.size} groups`)
267
+
268
+ // Auto-revive sessions that were running when we last went down.
269
+ // Runs AFTER the WS is up so any 🔁 revive message lands in the
270
+ // right chat instead of disappearing into the void.
271
+ await reviveAliveSessions()
216
272
  }
217
273
 
218
274
  boot().catch(e => { log(`boot fatal: ${e}`); process.exit(1) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -332,151 +332,177 @@ export interface AskQuestion {
332
332
  * the first question only and a text-only listing for the rest (an
333
333
  * acceptable limitation — these are rare in practice and we can lift
334
334
  * it once the UX is validated). */
335
- /** Final-state info for an AskUserQuestion panel. Mutually-exclusive
336
- * branches inside: either the user picked one of the SDK-provided
337
- * options (`optionIdx`), or they typed a free-form answer
338
- * (`customText`). When both are absent the panel is still pending. */
335
+ /** Per-question final-state. Mutually-exclusive branches: option pick
336
+ * vs. free-form custom text. */
339
337
  export interface AskAnswered {
340
338
  optionIdx?: number
341
339
  customText?: string
342
340
  user?: string
343
341
  }
344
342
 
343
+ /** State the panel renders against. `currentIdx` undefined → terminal
344
+ * (every question answered). Otherwise it's the question currently on
345
+ * screen; everything in `answered` is history. */
346
+ export interface AskState {
347
+ currentIdx?: number
348
+ answered: Map<number, AskAnswered>
349
+ }
350
+
351
+ /** Render one question's body — either as clickable interactive_container
352
+ * rows (when picked === undefined) or as plain markdown summary
353
+ * (already-answered, shown in history-panel context). */
354
+ function renderAskQuestionBody(
355
+ q: AskQuestion,
356
+ toolUseId: string,
357
+ questionIdx: number,
358
+ picked?: AskAnswered,
359
+ ): any[] {
360
+ const els: any[] = []
361
+ els.push({ tag: 'markdown', content: `**${q.question}**` })
362
+ for (let oi = 0; oi < q.options.length; oi++) {
363
+ const opt = q.options[oi]
364
+ const desc = opt.description ? ` · ${opt.description}` : ''
365
+ if (picked) {
366
+ const isPicked = picked.optionIdx === oi
367
+ els.push({
368
+ tag: 'markdown',
369
+ content: isPicked
370
+ ? `✅ **${opt.label}**${desc}`
371
+ : `~~◯ ${opt.label}${desc}~~`,
372
+ })
373
+ } else {
374
+ els.push({
375
+ tag: 'interactive_container',
376
+ background_style: 'default',
377
+ has_border: true,
378
+ corner_radius: '6px',
379
+ padding: '8px 12px',
380
+ margin: '4px 0px 4px 0px',
381
+ behaviors: [{
382
+ type: 'callback',
383
+ value: {
384
+ kind: 'ask',
385
+ tool_use_id: toolUseId,
386
+ question_idx: questionIdx,
387
+ option_idx: oi,
388
+ },
389
+ }],
390
+ elements: [{ tag: 'markdown', content: `**${opt.label}**${desc}` }],
391
+ })
392
+ }
393
+ }
394
+ if (picked?.customText) {
395
+ els.push({ tag: 'markdown', content: `✏️ **自定义回答**:${picked.customText}` })
396
+ }
397
+ return els
398
+ }
399
+
400
+ /** Folded "📜 已答 N 题" panel — option C from the multi-question
401
+ * design discussion. Returns null when there's no history to show. */
402
+ function renderAskHistoryPanel(
403
+ questions: AskQuestion[],
404
+ answered: Map<number, AskAnswered>,
405
+ ): any | null {
406
+ if (answered.size === 0) return null
407
+ const lines: string[] = []
408
+ const sortedIdx = [...answered.keys()].sort((a, b) => a - b)
409
+ for (const idx of sortedIdx) {
410
+ const q = questions[idx]
411
+ const a = answered.get(idx)!
412
+ const tag = q?.header ?? `Q${idx + 1}`
413
+ const value = a.customText
414
+ ?? (a.optionIdx !== undefined ? q?.options[a.optionIdx]?.label : undefined)
415
+ ?? '?'
416
+ lines.push(`- ✅ **${tag}**:${value}`)
417
+ }
418
+ return {
419
+ tag: 'collapsible_panel',
420
+ header: {
421
+ title: { tag: 'plain_text', content: `📜 已答 ${answered.size} 题(点击展开)` },
422
+ },
423
+ expanded: false,
424
+ elements: [{ tag: 'markdown', content: lines.join('\n') }],
425
+ }
426
+ }
427
+
345
428
  export function askUserQuestionElement(
346
429
  i: number,
347
430
  toolUseId: string,
348
431
  questions: AskQuestion[],
349
432
  status: '🤔' | '✅' | '❌' = '🤔',
350
- answered?: AskAnswered,
433
+ state?: AskState,
351
434
  ): object {
352
- const primary = questions[0]
353
- const isAnswered = !!answered && (answered.optionIdx !== undefined || !!answered.customText)
354
- const headerTag = primary?.header ? ` · ${primary.header}` : ''
355
- let headerText: string
356
- if (isAnswered) {
357
- // Surface the picked answer in the header so a collapsed panel
358
- // still tells the reader what was chosen.
359
- const picked = answered!.customText
360
- ?? (answered!.optionIdx !== undefined ? primary?.options[answered!.optionIdx]?.label : undefined)
361
- ?? '?'
362
- headerText = `${status} 已回答${headerTag}:${picked}`
363
- } else {
364
- headerText = `${status} 🤔 AskUserQuestion${headerTag}`
365
- }
435
+ const total = questions.length
436
+ const answered = state?.answered ?? new Map<number, AskAnswered>()
437
+ const currentIdx = state?.currentIdx
438
+ const isTerminal = currentIdx === undefined && answered.size > 0
366
439
  const bodyElements: any[] = []
367
- if (primary) {
368
- bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
369
- // One row per option. While pending, each row is a full-width
370
- // interactive_container so the entire row is the click target.
371
- // Once answered, rows degrade to plain markdown (Feishu has no
372
- // generic non-interactive container `div` doesn't exist, it
373
- // rejects the PUT with code 300121).
374
- for (let optIdx = 0; optIdx < primary.options.length; optIdx++) {
375
- const opt = primary.options[optIdx]
376
- const isPicked = answered?.optionIdx === optIdx
377
- const desc = opt.description ? ` · ${opt.description}` : ''
378
- if (!isAnswered) {
379
- bodyElements.push({
380
- tag: 'interactive_container',
381
- background_style: 'default',
382
- has_border: true,
383
- corner_radius: '6px',
384
- padding: '8px 12px',
385
- margin: '4px 0px 4px 0px',
386
- behaviors: [{
387
- type: 'callback',
388
- value: {
389
- kind: 'ask',
390
- tool_use_id: toolUseId,
391
- question_idx: 0,
392
- option_idx: optIdx,
393
- },
394
- }],
395
- elements: [{ tag: 'markdown', content: `**${opt.label}**${desc}` }],
396
- })
397
- } else if (isPicked) {
398
- bodyElements.push({
399
- tag: 'markdown',
400
- content: `✅ **${opt.label}**${desc}`,
401
- })
402
- } else {
403
- bodyElements.push({
404
- tag: 'markdown',
405
- content: `~~◯ ${opt.label}${desc}~~`,
406
- })
407
- }
440
+ let headerText: string
441
+
442
+ if (isTerminal) {
443
+ // All questions resolved collapse and roll up answers in header
444
+ // + body. Single-question case keeps the old "已回答:xxx" header
445
+ // style; multi-question gets a "已回答 · N 题" count and a flat
446
+ // listing of Q→A pairs in the body.
447
+ if (total === 1) {
448
+ const q0 = questions[0]
449
+ const a0 = answered.get(0)
450
+ const value = a0?.customText
451
+ ?? (a0?.optionIdx !== undefined ? q0?.options[a0.optionIdx]?.label : undefined)
452
+ ?? '?'
453
+ const headerTag = q0?.header ? ` · ${q0.header}` : ''
454
+ headerText = `${status} 已回答${headerTag}:${value}`
455
+ } else {
456
+ headerText = `${status} 已回答 · ${total} 题`
408
457
  }
409
- // Custom-answer input (pending state only). Feishu form submit
410
- // packages the input value under `form_value` in the callback
411
- // payload; daemon.handleCardAction reads it as
412
- // `value.form_value.custom_answer`.
413
- if (!isAnswered) {
414
- bodyElements.push({ tag: 'markdown', content: '_或自己输入:_' })
458
+ const sortedIdx = [...answered.keys()].sort((a, b) => a - b)
459
+ for (const idx of sortedIdx) {
460
+ const q = questions[idx]
461
+ const a = answered.get(idx)!
462
+ const tag = q?.header ?? `Q${idx + 1}`
463
+ const value = a.customText
464
+ ?? (a.optionIdx !== undefined ? q?.options[a.optionIdx]?.label : undefined)
465
+ ?? '?'
415
466
  bodyElements.push({
416
- tag: 'form',
417
- name: `ask_form_${toolUseId.slice(-12)}`,
418
- elements: [{
419
- tag: 'column_set',
420
- columns: [
421
- {
422
- tag: 'column', width: 'weighted', weight: 3, vertical_align: 'center',
423
- elements: [{
424
- tag: 'input',
425
- name: 'custom_answer',
426
- placeholder: { tag: 'plain_text', content: '自定义回答…' },
427
- required: false,
428
- }],
429
- },
430
- {
431
- tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center',
432
- elements: [{
433
- tag: 'button',
434
- text: { tag: 'plain_text', content: '发送' },
435
- type: 'primary',
436
- form_action_type: 'submit',
437
- behaviors: [{
438
- type: 'callback',
439
- value: {
440
- kind: 'ask',
441
- tool_use_id: toolUseId,
442
- question_idx: 0,
443
- custom: true,
444
- },
445
- }],
446
- }],
447
- },
448
- ],
449
- }],
467
+ tag: 'markdown',
468
+ content: `**${tag}**:${value}`,
450
469
  })
451
- } else if (answered?.customText) {
470
+ }
471
+ const lastUser = [...answered.values()].reverse().find(a => a.user)?.user
472
+ if (lastUser) {
452
473
  bodyElements.push({
453
474
  tag: 'markdown',
454
- content: `✏️ **自定义回答**:${answered.customText}`,
475
+ content: `\n*— 由 ${lastUser} 回答*`,
455
476
  })
456
477
  }
457
- }
458
- // Secondary questions (rare): text-only listing. TODO when needed.
459
- for (let qi = 1; qi < questions.length; qi++) {
460
- const q = questions[qi]
461
- const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
462
- bodyElements.push({
463
- tag: 'markdown',
464
- content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
465
- })
466
- }
467
- if (isAnswered && answered?.user) {
478
+ } else if (currentIdx !== undefined && questions[currentIdx]) {
479
+ // In-progress: render current question + folded history above.
480
+ // Progress tag in header lets the user see how many are left,
481
+ // even with the history panel folded.
482
+ const q = questions[currentIdx]
483
+ const headerTag = q.header ? ` · ${q.header}` : ''
484
+ const progress = total > 1 ? ` (${currentIdx + 1}/${total})` : ''
485
+ headerText = `${status} 🤔 AskUserQuestion${headerTag}${progress}`
486
+ const history = renderAskHistoryPanel(questions, answered)
487
+ if (history) bodyElements.push(history)
488
+ bodyElements.push(...renderAskQuestionBody(q, toolUseId, currentIdx))
468
489
  bodyElements.push({
469
490
  tag: 'markdown',
470
- content: `\n*— ${answered.user} 回答*`,
491
+ content: '_💬 也可以直接在群里回复你的答案(裸词命令 `hi`/`kill`/`restart`/`clear` 仍然优先)_',
471
492
  })
493
+ } else {
494
+ // Defensive fallback — neither answered nor a valid currentIdx.
495
+ headerText = `${status} 🤔 AskUserQuestion`
496
+ if (questions[0]) {
497
+ bodyElements.push({ tag: 'markdown', content: `**${questions[0].question}**` })
498
+ }
472
499
  }
500
+
473
501
  return {
474
502
  tag: 'collapsible_panel',
475
503
  element_id: ELEMENTS.tool(i),
476
504
  header: { title: { tag: 'plain_text', content: headerText } },
477
- // Collapse once answered: header carries the picked answer, body
478
- // is just history at that point.
479
- expanded: !isAnswered,
505
+ expanded: !isTerminal,
480
506
  elements: bodyElements,
481
507
  }
482
508
  }
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
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
@@ -89,12 +89,19 @@ export class Session {
89
89
  questions: cards.AskQuestion[]
90
90
  i: number
91
91
  requestId?: string
92
- deferredAnswer?: {
93
- questionIdx: number
94
- optionIdx?: number
95
- customText?: string
96
- user: string
97
- }
92
+ /** 累计答案 — key 是 question 原文 (SDK 把这条 record 格式
93
+ * 化进 tool_result), value 是用户选的 option label 或自定
94
+ * 义文字。全部 question 都答完时一并塞进 updatedInput.answers
95
+ * 发回 SDK。 */
96
+ answers: Record<string, string>
97
+ /** 已答详情 (按 question idx 索引),用来给历史面板和 terminal
98
+ * 状态画选中态。answers 同步累计,但这里多保留 customText /
99
+ * optionIdx 字段以便 UI 区分两种回答路径。 */
100
+ answered: Map<number, cards.AskAnswered>
101
+ /** 当前展示的 question idx。undefined 表示全部答完 (terminal)
102
+ * —— 这时若 requestId 已就位则 finalize;否则等 renderPermission
103
+ * 一来立即 finalize。 */
104
+ currentIdx?: number
98
105
  }>()
99
106
  private turnCounter = 0
100
107
  // Last seen sessionId — preserved across `kill`/`stop` so a later
@@ -332,98 +339,141 @@ export class Session {
332
339
  }
333
340
  }
334
341
 
335
- /** Click handler for an AskUserQuestion option button. Dispatches
336
- * to `resolveAsk` if can_use_tool has already arrived, otherwise
337
- * parks the click on the pendingAsk record for renderPermission
338
- * to drain. */
342
+ /** True iff there's at least one open AskUserQuestion awaiting an
343
+ * answer in this session. `daemon.handleMessage` uses this to
344
+ * decide whether an inbound chat message should be a custom answer
345
+ * (routed to onAskMessageAnswer) instead of opening a new turn. */
346
+ hasPendingAsk(): boolean {
347
+ return this.pendingAsks.size > 0
348
+ }
349
+
350
+ /** Funnel an arbitrary chat message into the *current* question
351
+ * of the oldest pending ask as a `customText` answer. Multi-
352
+ * question semantics: from the user's perspective, the chat
353
+ * input always answers whatever question is on screen right now
354
+ * (`pending.currentIdx`), and a new question slides in after. */
355
+ async onAskMessageAnswer(text: string, user: string): Promise<void> {
356
+ const firstEntry = this.pendingAsks.entries().next()
357
+ if (firstEntry.done) {
358
+ log(`session "${this.sessionName}": onAskMessageAnswer with no pending — falling back to onUserMessage`)
359
+ await this.onUserMessage(text)
360
+ return
361
+ }
362
+ const [toolUseId, pending] = firstEntry.value
363
+ if (pending.currentIdx === undefined) {
364
+ log(`session "${this.sessionName}": pending ask ${toolUseId} already terminal — ignoring message`)
365
+ return
366
+ }
367
+ await this.onAskCustomAnswer(toolUseId, pending.currentIdx, text, user)
368
+ }
369
+
370
+ /** Click handler for an option button. The click must target the
371
+ * question currently on screen (`pending.currentIdx`); a stale
372
+ * click (e.g. user clicked an older render before it swapped in
373
+ * the next question) is logged and dropped — better than double-
374
+ * answering. */
339
375
  async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
340
376
  const pending = this.pendingAsks.get(toolUseId)
341
377
  if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
342
- if (pending.requestId) {
343
- this.resolveAsk(toolUseId, pending.requestId, { questionIdx, optionIdx, user })
344
- } else {
345
- pending.deferredAnswer = { questionIdx, optionIdx, user }
346
- log(`session "${this.sessionName}": ask answer deferred for ${toolUseId} (no requestId yet)`)
378
+ if (questionIdx !== pending.currentIdx) {
379
+ log(`session "${this.sessionName}": stale ask click q=${questionIdx} current=${pending.currentIdx}`)
380
+ return
347
381
  }
382
+ this.advanceAsk(toolUseId, { optionIdx, user })
348
383
  }
349
384
 
350
- /** Form-submit handler for the custom-answer input on an
351
- * AskUserQuestion panel. Same dispatch pattern as `onAskAnswer`,
352
- * just routes a free-form string into `resolveAsk` instead of an
353
- * option index. Empty/whitespace input is ignored (no answer
354
- * sent, panel stays pending). */
385
+ /** Custom-text branch. Same staleness rule as onAskAnswer; empty
386
+ * input is silently ignored (panel stays pending). */
355
387
  async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
356
388
  const pending = this.pendingAsks.get(toolUseId)
357
389
  if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
358
390
  const trimmed = (customText ?? '').trim()
359
391
  if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
360
- if (pending.requestId) {
361
- this.resolveAsk(toolUseId, pending.requestId, { questionIdx, customText: trimmed, user })
362
- } else {
363
- pending.deferredAnswer = { questionIdx, customText: trimmed, user }
364
- log(`session "${this.sessionName}": ask custom deferred for ${toolUseId} (no requestId yet)`)
392
+ if (questionIdx !== pending.currentIdx) {
393
+ log(`session "${this.sessionName}": stale ask custom q=${questionIdx} current=${pending.currentIdx}`)
394
+ return
365
395
  }
396
+ this.advanceAsk(toolUseId, { customText: trimmed, user })
366
397
  }
367
398
 
368
- /** Settle an AskUserQuestion: emit the permission allow with the
369
- * picked option OR custom text folded into `updatedInput.answers`
370
- * (this is the shape the SDK reads to synthesise the tool_result
371
- * string), repaint the panel ✅, drop bookkeeping. Single source
372
- * of truth — option-click, custom-submit, and the deferred drain
373
- * all go through here. */
374
- private resolveAsk(
399
+ /** Record an answer for the current question, advance the state
400
+ * machine, repaint. If every question is now answered, finalize
401
+ * (or defer the finalize until can_use_tool lands the race is
402
+ * handled by renderPermission). */
403
+ private advanceAsk(
375
404
  toolUseId: string,
376
- requestId: string,
377
- answer: { questionIdx: number; optionIdx?: number; customText?: string; user: string },
405
+ answer: { optionIdx?: number; customText?: string; user: string },
378
406
  ): void {
379
407
  const pending = this.pendingAsks.get(toolUseId)
380
- if (!pending) return
381
- const q = pending.questions[answer.questionIdx]
382
- if (!q) {
383
- log(`session "${this.sessionName}": ask answer out of range q=${answer.questionIdx}`)
384
- return
385
- }
386
- // Determine the literal string that will become the SDK's
387
- // `answers` value for this question — custom wins if both are
388
- // somehow set (shouldn't happen, but defensive).
389
- let answerValue: string
390
- if (answer.customText) {
391
- answerValue = answer.customText
408
+ if (!pending || pending.currentIdx === undefined) return
409
+ const cur = pending.currentIdx
410
+ const q = pending.questions[cur]
411
+ if (!q) { log(`session "${this.sessionName}": advanceAsk currentIdx=${cur} out of range`); return }
412
+ // Resolve the literal answer value — custom text wins if both set.
413
+ let value: string
414
+ if (answer.customText !== undefined) {
415
+ value = answer.customText
392
416
  } else if (answer.optionIdx !== undefined) {
393
417
  const opt = q.options?.[answer.optionIdx]
394
- if (!opt) {
395
- log(`session "${this.sessionName}": ask option out of range o=${answer.optionIdx}`)
396
- return
397
- }
398
- answerValue = opt.label
418
+ if (!opt) { log(`session "${this.sessionName}": advanceAsk option ${answer.optionIdx} out of range`); return }
419
+ value = opt.label
399
420
  } else {
400
- log(`session "${this.sessionName}": resolveAsk called with neither optionIdx nor customText`)
421
+ log(`session "${this.sessionName}": advanceAsk with neither customText nor optionIdx`)
401
422
  return
402
423
  }
403
- const turn = this.currentTurn
404
- const meta = turn?.toolByUseId.get(toolUseId)
405
- const originalInput = meta?.input ?? {}
406
- // SDK keys the answer record by the question's text — confirmed
407
- // by the v0.1.2 jsonl trace (empty record formatted to "User has
408
- // answered your questions: .").
409
- const answers: Record<string, string> = { [q.question]: answerValue }
410
- this.proc?.sendPermissionResponse(requestId, 'allow', {
411
- updatedInput: { ...originalInput, answers },
424
+ pending.answers[q.question] = value
425
+ pending.answered.set(cur, {
426
+ optionIdx: answer.optionIdx,
427
+ customText: answer.customText,
428
+ user: answer.user,
412
429
  })
413
- this.pendingPermissions.delete(requestId)
414
- this.pendingAsks.delete(toolUseId)
430
+ // Next unanswered idx — linear from cur+1. Implementation
431
+ // always moves forward; we don't currently let users revisit a
432
+ // previous question (would need richer UI affordance for that).
433
+ const total = pending.questions.length
434
+ let nextIdx: number | undefined = undefined
435
+ for (let i = cur + 1; i < total; i++) {
436
+ if (!pending.answered.has(i)) { nextIdx = i; break }
437
+ }
438
+ pending.currentIdx = nextIdx
415
439
 
440
+ const turn = this.currentTurn
441
+ const meta = turn?.toolByUseId.get(toolUseId)
416
442
  if (turn && meta) {
417
- meta.output = JSON.stringify({ answers })
418
- meta.isError = false
419
- const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '', {
420
- optionIdx: answer.optionIdx,
421
- customText: answer.customText,
422
- user: answer.user || '匿名',
423
- })
443
+ const el = cards.askUserQuestionElement(
444
+ meta.i, toolUseId, pending.questions,
445
+ nextIdx === undefined ? '✅' : '🤔',
446
+ { currentIdx: nextIdx, answered: pending.answered },
447
+ )
424
448
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
425
449
  }
426
450
 
451
+ if (nextIdx === undefined) {
452
+ // All done. Finalize iff we have the permission request id;
453
+ // otherwise renderPermission will pick it up when it arrives.
454
+ if (pending.requestId) this.finalizeAsk(toolUseId)
455
+ else log(`session "${this.sessionName}": ask ${toolUseId} all answered, waiting for can_use_tool`)
456
+ }
457
+ }
458
+
459
+ /** Settle a fully-answered AskUserQuestion: emit the SDK allow
460
+ * with the full `answers` record folded into `updatedInput`,
461
+ * drop bookkeeping, restore status. The terminal panel paint was
462
+ * already done by the final advanceAsk; this is just protocol. */
463
+ private finalizeAsk(toolUseId: string): void {
464
+ const pending = this.pendingAsks.get(toolUseId)
465
+ if (!pending || !pending.requestId) return
466
+ const meta = this.currentTurn?.toolByUseId.get(toolUseId)
467
+ const originalInput = meta?.input ?? {}
468
+ this.proc?.sendPermissionResponse(pending.requestId, 'allow', {
469
+ updatedInput: { ...originalInput, answers: pending.answers },
470
+ })
471
+ this.pendingPermissions.delete(pending.requestId)
472
+ if (meta) {
473
+ meta.output = JSON.stringify({ answers: pending.answers })
474
+ meta.isError = false
475
+ }
476
+ this.pendingAsks.delete(toolUseId)
427
477
  if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
428
478
  this.status = 'working'
429
479
  }
@@ -610,8 +660,19 @@ export class Session {
610
660
  // don't match the actual N options).
611
661
  if (name === 'AskUserQuestion') {
612
662
  const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
613
- this.pendingAsks.set(toolUseId, { questions, i })
614
- const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔')
663
+ const startIdx = questions.length > 0 ? 0 : undefined
664
+ const answered = new Map<number, cards.AskAnswered>()
665
+ this.pendingAsks.set(toolUseId, {
666
+ questions,
667
+ i,
668
+ answers: {},
669
+ answered,
670
+ currentIdx: startIdx,
671
+ })
672
+ const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔', {
673
+ currentIdx: startIdx,
674
+ answered,
675
+ })
615
676
  void cardkit.addElement(this.currentTurn.cardId, el, {
616
677
  type: 'insert_before',
617
678
  targetElementId: cards.ELEMENTS.footer,
@@ -775,24 +836,16 @@ export class Session {
775
836
  if (meta.name === 'AskUserQuestion') {
776
837
  const ask = this.pendingAsks.get(toolUseId)
777
838
  if (!ask) {
778
- // Defensive: addTool should have populated pendingAsks. If it
779
- // didn't, fall back to a denial so Claude doesn't hang.
780
839
  log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
781
840
  this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
782
841
  return
783
842
  }
784
843
  ask.requestId = req.request_id
785
844
  this.pendingPermissions.set(req.request_id, { toolUseId })
786
- if (ask.deferredAnswer) {
787
- const d = ask.deferredAnswer
788
- ask.deferredAnswer = undefined
789
- this.resolveAsk(toolUseId, req.request_id, {
790
- questionIdx: d.questionIdx,
791
- optionIdx: d.optionIdx,
792
- customText: d.customText,
793
- user: d.user,
794
- })
795
- }
845
+ // Fast-clicker race: the user may have answered every question
846
+ // while we were still waiting for can_use_tool to arrive. If so,
847
+ // advanceAsk parked the all-done state and we drain it now.
848
+ if (ask.currentIdx === undefined) this.finalizeAsk(toolUseId)
796
849
  return
797
850
  }
798
851
  this.status = 'awaiting_permission'