@leviyuan/lodestar 0.1.6 → 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>()
@@ -225,6 +264,11 @@ async function boot(): Promise<void> {
225
264
  })
226
265
  ws.start({ eventDispatcher: dispatcher })
227
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()
228
272
  }
229
273
 
230
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.6",
3
+ "version": "0.1.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -332,121 +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 hint (pending state only). Feishu schema 2.0
410
- // doesn't actually have `form` / `input` elements — `tag: form`
411
- // gets rejected with code 300315. Instead we route custom
412
- // answers through a plain chat message: daemon.handleMessage
413
- // detects a pending ask and forwards the next inbound text as
414
- // the answer, no new turn opened. This is also more Feishu-
415
- // native: the chat input is right there, no extra widget.
416
- if (!isAnswered) {
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
+ ?? '?'
417
466
  bodyElements.push({
418
467
  tag: 'markdown',
419
- content: '_💬 也可以直接在群里回复你的答案(裸词命令 `hi`/`kill`/`restart`/`clear` 仍然优先)_',
468
+ content: `**${tag}**:${value}`,
420
469
  })
421
- } else if (answered?.customText) {
470
+ }
471
+ const lastUser = [...answered.values()].reverse().find(a => a.user)?.user
472
+ if (lastUser) {
422
473
  bodyElements.push({
423
474
  tag: 'markdown',
424
- content: `✏️ **自定义回答**:${answered.customText}`,
475
+ content: `\n*— 由 ${lastUser} 回答*`,
425
476
  })
426
477
  }
427
- }
428
- // Secondary questions (rare): text-only listing. TODO when needed.
429
- for (let qi = 1; qi < questions.length; qi++) {
430
- const q = questions[qi]
431
- const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
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))
432
489
  bodyElements.push({
433
490
  tag: 'markdown',
434
- content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
435
- })
436
- }
437
- if (isAnswered && answered?.user) {
438
- bodyElements.push({
439
- tag: 'markdown',
440
- content: `\n*— 由 ${answered.user} 回答*`,
491
+ content: '_💬 也可以直接在群里回复你的答案(裸词命令 `hi`/`kill`/`restart`/`clear` 仍然优先)_',
441
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
+ }
442
499
  }
500
+
443
501
  return {
444
502
  tag: 'collapsible_panel',
445
503
  element_id: ELEMENTS.tool(i),
446
504
  header: { title: { tag: 'plain_text', content: headerText } },
447
- // Collapse once answered: header carries the picked answer, body
448
- // is just history at that point.
449
- expanded: !isAnswered,
505
+ expanded: !isTerminal,
450
506
  elements: bodyElements,
451
507
  }
452
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
@@ -340,11 +347,11 @@ export class Session {
340
347
  return this.pendingAsks.size > 0
341
348
  }
342
349
 
343
- /** Funnel an arbitrary chat message into the oldest pending ask as
344
- * a `customText` answer. Falls back to a normal turn if for some
345
- * reason there's nothing pending (defensive — handleMessage's gate
346
- * should prevent that). Picks the first entry by Map insertion
347
- * order, which is the earliest unanswered ask. */
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. */
348
355
  async onAskMessageAnswer(text: string, user: string): Promise<void> {
349
356
  const firstEntry = this.pendingAsks.entries().next()
350
357
  if (firstEntry.done) {
@@ -352,102 +359,121 @@ export class Session {
352
359
  await this.onUserMessage(text)
353
360
  return
354
361
  }
355
- const [toolUseId, _pending] = firstEntry.value
356
- await this.onAskCustomAnswer(toolUseId, 0, text, user)
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)
357
368
  }
358
369
 
359
- /** Click handler for an AskUserQuestion option button. Dispatches
360
- * to `resolveAsk` if can_use_tool has already arrived, otherwise
361
- * parks the click on the pendingAsk record for renderPermission
362
- * to drain. */
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. */
363
375
  async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
364
376
  const pending = this.pendingAsks.get(toolUseId)
365
377
  if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
366
- if (pending.requestId) {
367
- this.resolveAsk(toolUseId, pending.requestId, { questionIdx, optionIdx, user })
368
- } else {
369
- pending.deferredAnswer = { questionIdx, optionIdx, user }
370
- 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
371
381
  }
382
+ this.advanceAsk(toolUseId, { optionIdx, user })
372
383
  }
373
384
 
374
- /** Form-submit handler for the custom-answer input on an
375
- * AskUserQuestion panel. Same dispatch pattern as `onAskAnswer`,
376
- * just routes a free-form string into `resolveAsk` instead of an
377
- * option index. Empty/whitespace input is ignored (no answer
378
- * sent, panel stays pending). */
385
+ /** Custom-text branch. Same staleness rule as onAskAnswer; empty
386
+ * input is silently ignored (panel stays pending). */
379
387
  async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
380
388
  const pending = this.pendingAsks.get(toolUseId)
381
389
  if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
382
390
  const trimmed = (customText ?? '').trim()
383
391
  if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
384
- if (pending.requestId) {
385
- this.resolveAsk(toolUseId, pending.requestId, { questionIdx, customText: trimmed, user })
386
- } else {
387
- pending.deferredAnswer = { questionIdx, customText: trimmed, user }
388
- 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
389
395
  }
396
+ this.advanceAsk(toolUseId, { customText: trimmed, user })
390
397
  }
391
398
 
392
- /** Settle an AskUserQuestion: emit the permission allow with the
393
- * picked option OR custom text folded into `updatedInput.answers`
394
- * (this is the shape the SDK reads to synthesise the tool_result
395
- * string), repaint the panel ✅, drop bookkeeping. Single source
396
- * of truth — option-click, custom-submit, and the deferred drain
397
- * all go through here. */
398
- 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(
399
404
  toolUseId: string,
400
- requestId: string,
401
- answer: { questionIdx: number; optionIdx?: number; customText?: string; user: string },
405
+ answer: { optionIdx?: number; customText?: string; user: string },
402
406
  ): void {
403
407
  const pending = this.pendingAsks.get(toolUseId)
404
- if (!pending) return
405
- const q = pending.questions[answer.questionIdx]
406
- if (!q) {
407
- log(`session "${this.sessionName}": ask answer out of range q=${answer.questionIdx}`)
408
- return
409
- }
410
- // Determine the literal string that will become the SDK's
411
- // `answers` value for this question — custom wins if both are
412
- // somehow set (shouldn't happen, but defensive).
413
- let answerValue: string
414
- if (answer.customText) {
415
- 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
416
416
  } else if (answer.optionIdx !== undefined) {
417
417
  const opt = q.options?.[answer.optionIdx]
418
- if (!opt) {
419
- log(`session "${this.sessionName}": ask option out of range o=${answer.optionIdx}`)
420
- return
421
- }
422
- answerValue = opt.label
418
+ if (!opt) { log(`session "${this.sessionName}": advanceAsk option ${answer.optionIdx} out of range`); return }
419
+ value = opt.label
423
420
  } else {
424
- log(`session "${this.sessionName}": resolveAsk called with neither optionIdx nor customText`)
421
+ log(`session "${this.sessionName}": advanceAsk with neither customText nor optionIdx`)
425
422
  return
426
423
  }
427
- const turn = this.currentTurn
428
- const meta = turn?.toolByUseId.get(toolUseId)
429
- const originalInput = meta?.input ?? {}
430
- // SDK keys the answer record by the question's text — confirmed
431
- // by the v0.1.2 jsonl trace (empty record formatted to "User has
432
- // answered your questions: .").
433
- const answers: Record<string, string> = { [q.question]: answerValue }
434
- this.proc?.sendPermissionResponse(requestId, 'allow', {
435
- 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,
436
429
  })
437
- this.pendingPermissions.delete(requestId)
438
- 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
439
439
 
440
+ const turn = this.currentTurn
441
+ const meta = turn?.toolByUseId.get(toolUseId)
440
442
  if (turn && meta) {
441
- meta.output = JSON.stringify({ answers })
442
- meta.isError = false
443
- const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '', {
444
- optionIdx: answer.optionIdx,
445
- customText: answer.customText,
446
- user: answer.user || '匿名',
447
- })
443
+ const el = cards.askUserQuestionElement(
444
+ meta.i, toolUseId, pending.questions,
445
+ nextIdx === undefined ? '✅' : '🤔',
446
+ { currentIdx: nextIdx, answered: pending.answered },
447
+ )
448
448
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
449
449
  }
450
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)
451
477
  if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
452
478
  this.status = 'working'
453
479
  }
@@ -634,8 +660,19 @@ export class Session {
634
660
  // don't match the actual N options).
635
661
  if (name === 'AskUserQuestion') {
636
662
  const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
637
- this.pendingAsks.set(toolUseId, { questions, i })
638
- 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
+ })
639
676
  void cardkit.addElement(this.currentTurn.cardId, el, {
640
677
  type: 'insert_before',
641
678
  targetElementId: cards.ELEMENTS.footer,
@@ -799,24 +836,16 @@ export class Session {
799
836
  if (meta.name === 'AskUserQuestion') {
800
837
  const ask = this.pendingAsks.get(toolUseId)
801
838
  if (!ask) {
802
- // Defensive: addTool should have populated pendingAsks. If it
803
- // didn't, fall back to a denial so Claude doesn't hang.
804
839
  log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
805
840
  this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
806
841
  return
807
842
  }
808
843
  ask.requestId = req.request_id
809
844
  this.pendingPermissions.set(req.request_id, { toolUseId })
810
- if (ask.deferredAnswer) {
811
- const d = ask.deferredAnswer
812
- ask.deferredAnswer = undefined
813
- this.resolveAsk(toolUseId, req.request_id, {
814
- questionIdx: d.questionIdx,
815
- optionIdx: d.optionIdx,
816
- customText: d.customText,
817
- user: d.user,
818
- })
819
- }
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)
820
849
  return
821
850
  }
822
851
  this.status = 'awaiting_permission'