@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 +57 -1
- package/package.json +1 -1
- package/src/cards.ts +143 -117
- package/src/feishu.ts +32 -2
- package/src/paths.ts +6 -0
- package/src/session.ts +137 -84
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>()
|
|
@@ -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
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
|
-
/**
|
|
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
|
-
|
|
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: '
|
|
417
|
-
|
|
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
|
-
}
|
|
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:
|
|
475
|
+
content: `\n*— 由 ${lastUser} 回答*`,
|
|
455
476
|
})
|
|
456
477
|
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -332,98 +339,141 @@ export class Session {
|
|
|
332
339
|
}
|
|
333
340
|
}
|
|
334
341
|
|
|
335
|
-
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* to
|
|
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.
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
/**
|
|
351
|
-
*
|
|
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.
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
/**
|
|
369
|
-
*
|
|
370
|
-
* (
|
|
371
|
-
*
|
|
372
|
-
|
|
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
|
-
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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}":
|
|
421
|
+
log(`session "${this.sessionName}": advanceAsk with neither customText nor optionIdx`)
|
|
401
422
|
return
|
|
402
423
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
614
|
-
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
|
+
})
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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'
|