@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 +45 -1
- package/package.json +1 -1
- package/src/cards.ts +142 -86
- package/src/feishu.ts +32 -2
- package/src/paths.ts +6 -0
- package/src/session.ts +120 -91
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 = () => {
|
|
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
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
|
-
/**
|
|
336
|
-
*
|
|
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
|
-
|
|
433
|
+
state?: AskState,
|
|
351
434
|
): object {
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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:
|
|
468
|
+
content: `**${tag}**:${value}`,
|
|
420
469
|
})
|
|
421
|
-
}
|
|
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:
|
|
475
|
+
content: `\n*— 由 ${lastUser} 回答*`,
|
|
425
476
|
})
|
|
426
477
|
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
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,
|
|
356
|
-
|
|
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
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
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.
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
/**
|
|
375
|
-
*
|
|
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.
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
/**
|
|
393
|
-
*
|
|
394
|
-
* (
|
|
395
|
-
*
|
|
396
|
-
|
|
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
|
-
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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}":
|
|
421
|
+
log(`session "${this.sessionName}": advanceAsk with neither customText nor optionIdx`)
|
|
425
422
|
return
|
|
426
423
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
638
|
-
const
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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'
|