@leviyuan/lodestar 0.1.1 → 0.1.2
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 +3 -0
- package/package.json +1 -1
- package/src/cards.ts +138 -7
- package/src/claude-process.ts +25 -0
- package/src/session.ts +187 -5
package/daemon.ts
CHANGED
|
@@ -140,6 +140,9 @@ async function handleCardAction(data: any): Promise<any> {
|
|
|
140
140
|
case 'menu':
|
|
141
141
|
await session.onUserMessage(`(menu choice ${value.choice + 1})`)
|
|
142
142
|
return { toast: { type: 'success', content: 'OK' } }
|
|
143
|
+
case 'ask':
|
|
144
|
+
await session.onAskAnswer(value.tool_use_id, value.question_idx ?? 0, value.option_idx, userId)
|
|
145
|
+
return { toast: { type: 'success', content: '已回答' } }
|
|
143
146
|
}
|
|
144
147
|
return { toast: { type: 'info', content: 'unknown action' } }
|
|
145
148
|
}
|
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -19,6 +19,44 @@ export const ELEMENTS = {
|
|
|
19
19
|
assistant: (i: number) => `assistant_${i}`,
|
|
20
20
|
} as const
|
|
21
21
|
|
|
22
|
+
/** Minimal projection of an SDK task — used by Session's local mirror,
|
|
23
|
+
* built incrementally from observed TaskCreate / TaskUpdate input+output
|
|
24
|
+
* pairs. Not authoritative (the SDK is the source of truth), but enough
|
|
25
|
+
* to render the "全部任务清单" footer on every Task* panel. */
|
|
26
|
+
export interface Todo {
|
|
27
|
+
id: number
|
|
28
|
+
subject?: string
|
|
29
|
+
description?: string
|
|
30
|
+
status: 'pending' | 'in_progress' | 'completed' | string
|
|
31
|
+
owner?: string
|
|
32
|
+
activeForm?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function todoStatusIcon(s: string): string {
|
|
36
|
+
switch (s) {
|
|
37
|
+
case 'pending': return '☐'
|
|
38
|
+
case 'in_progress': return '🔄'
|
|
39
|
+
case 'completed': return '✅'
|
|
40
|
+
default: return '·'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Render the session's full todo mirror as a markdown list. Empty list
|
|
45
|
+
* yields '' so callers can unconditionally concat. Sorted by numeric id
|
|
46
|
+
* so the order matches creation order regardless of Map iteration. */
|
|
47
|
+
function renderTodoList(todos: Todo[]): string {
|
|
48
|
+
if (!todos || todos.length === 0) return ''
|
|
49
|
+
const sorted = [...todos].sort((a, b) => a.id - b.id)
|
|
50
|
+
const lines = ['', '---', `**📋 当前任务清单(${sorted.length} 项)**`, '']
|
|
51
|
+
for (const t of sorted) {
|
|
52
|
+
const icon = todoStatusIcon(t.status)
|
|
53
|
+
const subject = t.subject ?? '(无 subject)'
|
|
54
|
+
const ownerTag = t.owner ? ` · ${t.owner}` : ''
|
|
55
|
+
lines.push(`- ${icon} **#${t.id}** ${subject}${ownerTag}`)
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
22
60
|
/** Single-line summary used as a collapsible-panel header for a tool call. */
|
|
23
61
|
export function summarizeToolInput(name: string, input: any): string {
|
|
24
62
|
if (!input || typeof input !== 'object') return ''
|
|
@@ -81,8 +119,10 @@ function summarizeTaskWorkflow(name: string, input: any): string {
|
|
|
81
119
|
/** Markdown body for Task* workflow tools — replaces the generic JSON
|
|
82
120
|
* dump with a human-readable description of the operation plus, once the
|
|
83
121
|
* tool result is in, the SDK's text reply (which already contains "Task
|
|
84
|
-
* #N created" / "Updated task #X" / a rendered list for TaskList).
|
|
85
|
-
|
|
122
|
+
* #N created" / "Updated task #X" / a rendered list for TaskList). When
|
|
123
|
+
* `todos` is non-empty, the full mirror is appended as a "📋 当前任务
|
|
124
|
+
* 清单" footer so every Task* panel doubles as a current-state readout. */
|
|
125
|
+
function renderTaskWorkflowBody(name: string, input: any, output: string | null, todos?: Todo[]): string {
|
|
86
126
|
const lines: string[] = []
|
|
87
127
|
switch (name) {
|
|
88
128
|
case 'TaskCreate':
|
|
@@ -118,7 +158,7 @@ function renderTaskWorkflowBody(name: string, input: any, output: string | null)
|
|
|
118
158
|
lines.push('**结果**')
|
|
119
159
|
lines.push(output.slice(0, 3000))
|
|
120
160
|
}
|
|
121
|
-
return lines.join('\n')
|
|
161
|
+
return lines.join('\n') + renderTodoList(todos ?? [])
|
|
122
162
|
}
|
|
123
163
|
|
|
124
164
|
interface MainCardOpts {
|
|
@@ -191,6 +231,10 @@ export function toolCallElement(
|
|
|
191
231
|
output: string | null,
|
|
192
232
|
status: '⏳' | '✅' | '❌' = '⏳',
|
|
193
233
|
resolvedNote?: string,
|
|
234
|
+
/** Session's full todo mirror — only rendered when the tool is a Task*
|
|
235
|
+
* workflow op. Other tools ignore it. Passed in by Session so every
|
|
236
|
+
* Task* panel shows the *current* state, not just this op's diff. */
|
|
237
|
+
todos?: Todo[],
|
|
194
238
|
): object {
|
|
195
239
|
const summary = summarizeToolInput(name, input)
|
|
196
240
|
const headerText = summary
|
|
@@ -198,11 +242,12 @@ export function toolCallElement(
|
|
|
198
242
|
: `${status} 🔧 ${name}`
|
|
199
243
|
const isTaskWorkflow = name.startsWith('Task') && name !== 'Task'
|
|
200
244
|
const noteBlock = resolvedNote ? `\n\n${resolvedNote}` : ''
|
|
201
|
-
// Task* gets a narrative body (operation + result
|
|
202
|
-
// JSON-input + raw-output split — generic dump is
|
|
203
|
-
// tools where users can't predict what fields
|
|
245
|
+
// Task* gets a narrative body (operation + result + current todo list),
|
|
246
|
+
// the rest keeps the JSON-input + raw-output split — generic dump is
|
|
247
|
+
// better for unfamiliar tools where users can't predict what fields
|
|
248
|
+
// matter.
|
|
204
249
|
const body = isTaskWorkflow
|
|
205
|
-
? renderTaskWorkflowBody(name, input, output) + noteBlock
|
|
250
|
+
? renderTaskWorkflowBody(name, input, output, todos) + noteBlock
|
|
206
251
|
: '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
207
252
|
+ noteBlock
|
|
208
253
|
+ (output != null ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```' : '')
|
|
@@ -265,6 +310,92 @@ function permissionButtonColumn(label: string, type: string, requestId: string,
|
|
|
265
310
|
}
|
|
266
311
|
}
|
|
267
312
|
|
|
313
|
+
/** Schema of an AskUserQuestion question, projected to just the fields
|
|
314
|
+
* the panel needs. Mirrors the SDK tool's input — kept loose since the
|
|
315
|
+
* runtime guarantees it matches. */
|
|
316
|
+
export interface AskQuestion {
|
|
317
|
+
question: string
|
|
318
|
+
header?: string
|
|
319
|
+
multiSelect?: boolean
|
|
320
|
+
options: { label: string; description?: string }[]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Tool-panel renderer for `AskUserQuestion` — the SDK's structured
|
|
324
|
+
* multiple-choice question. Daemon takes over the client-side role:
|
|
325
|
+
* instead of letting the request fall through to the generic JSON
|
|
326
|
+
* dump (or worse, the permission flow that misappropriates it), we
|
|
327
|
+
* render each question with one button per option, callbacks tagged
|
|
328
|
+
* `kind:'ask'` so the Lark handler can route the answer back as a
|
|
329
|
+
* `tool_result`.
|
|
330
|
+
*
|
|
331
|
+
* Single-question is the common case; multi-question gets buttons on
|
|
332
|
+
* the first question only and a text-only listing for the rest (an
|
|
333
|
+
* acceptable limitation — these are rare in practice and we can lift
|
|
334
|
+
* it once the UX is validated). */
|
|
335
|
+
export function askUserQuestionElement(
|
|
336
|
+
i: number,
|
|
337
|
+
toolUseId: string,
|
|
338
|
+
questions: AskQuestion[],
|
|
339
|
+
status: '🤔' | '✅' | '❌' = '🤔',
|
|
340
|
+
resolvedNote?: string,
|
|
341
|
+
): object {
|
|
342
|
+
const primary = questions[0]
|
|
343
|
+
const headerTag = primary?.header ? ` · ${primary.header}` : ''
|
|
344
|
+
const headerText = `${status} 🤔 AskUserQuestion${headerTag}`
|
|
345
|
+
const bodyElements: any[] = []
|
|
346
|
+
if (primary) {
|
|
347
|
+
bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
|
|
348
|
+
// Stack option buttons in a column_set — one button per option.
|
|
349
|
+
// Each carries `kind:'ask'` + the toolUseId + question/option idx
|
|
350
|
+
// so the daemon's card action handler can map a click back to
|
|
351
|
+
// exactly one (question, choice) pair.
|
|
352
|
+
bodyElements.push({
|
|
353
|
+
tag: 'column_set',
|
|
354
|
+
columns: primary.options.map((opt, optIdx) => ({
|
|
355
|
+
tag: 'column', width: 'weighted', weight: 1,
|
|
356
|
+
elements: [{
|
|
357
|
+
tag: 'button',
|
|
358
|
+
text: { tag: 'plain_text', content: opt.label },
|
|
359
|
+
type: 'default',
|
|
360
|
+
behaviors: [{
|
|
361
|
+
type: 'callback',
|
|
362
|
+
value: {
|
|
363
|
+
kind: 'ask',
|
|
364
|
+
tool_use_id: toolUseId,
|
|
365
|
+
question_idx: 0,
|
|
366
|
+
option_idx: optIdx,
|
|
367
|
+
},
|
|
368
|
+
}],
|
|
369
|
+
}],
|
|
370
|
+
})),
|
|
371
|
+
})
|
|
372
|
+
// Inline option descriptions below the buttons so the user can
|
|
373
|
+
// read context without hovering.
|
|
374
|
+
const descLines = primary.options
|
|
375
|
+
.map((o, idx) => o.description ? `- **${o.label}** — ${o.description}` : `- **${o.label}**`)
|
|
376
|
+
.join('\n')
|
|
377
|
+
if (descLines) bodyElements.push({ tag: 'markdown', content: descLines })
|
|
378
|
+
}
|
|
379
|
+
// Secondary questions get text-only treatment (TODO: multi-question
|
|
380
|
+
// panels when actually requested by a real prompt).
|
|
381
|
+
for (let qi = 1; qi < questions.length; qi++) {
|
|
382
|
+
const q = questions[qi]
|
|
383
|
+
const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
|
|
384
|
+
bodyElements.push({
|
|
385
|
+
tag: 'markdown',
|
|
386
|
+
content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
if (resolvedNote) bodyElements.push({ tag: 'markdown', content: resolvedNote })
|
|
390
|
+
return {
|
|
391
|
+
tag: 'collapsible_panel',
|
|
392
|
+
element_id: ELEMENTS.tool(i),
|
|
393
|
+
header: { title: { tag: 'plain_text', content: headerText } },
|
|
394
|
+
expanded: true,
|
|
395
|
+
elements: bodyElements,
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
268
399
|
interface ConsoleOpts {
|
|
269
400
|
sessionName: string
|
|
270
401
|
status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
|
package/src/claude-process.ts
CHANGED
|
@@ -312,6 +312,31 @@ export class ClaudeProcess extends EventEmitter {
|
|
|
312
312
|
})
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
/** Feed a `tool_result` back to Claude as if a tool finished — used
|
|
316
|
+
* by the daemon when WE (the client) own the tool's execution side,
|
|
317
|
+
* e.g. AskUserQuestion: the SDK emits `tool_use` and waits for the
|
|
318
|
+
* client to render UI, collect a choice, and supply the result here.
|
|
319
|
+
* `content` is whatever the tool's contract says — for
|
|
320
|
+
* AskUserQuestion this is a JSON string `{"answers": {...}}`.
|
|
321
|
+
*
|
|
322
|
+
* NOTE: this is the same `{type:'user'}` envelope as `sendUserText`,
|
|
323
|
+
* just with a `tool_result` content block instead of plain text. The
|
|
324
|
+
* SDK demultiplexes by `tool_use_id`. */
|
|
325
|
+
sendToolResult(toolUseId: string, content: string, isError = false): void {
|
|
326
|
+
this.write({
|
|
327
|
+
type: 'user',
|
|
328
|
+
message: {
|
|
329
|
+
role: 'user',
|
|
330
|
+
content: [{
|
|
331
|
+
type: 'tool_result',
|
|
332
|
+
tool_use_id: toolUseId,
|
|
333
|
+
content,
|
|
334
|
+
...(isError ? { is_error: true } : {}),
|
|
335
|
+
}],
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
315
340
|
sendHookResponse(requestId: string, output: object = {}): void {
|
|
316
341
|
this.write({
|
|
317
342
|
type: 'control_response',
|
package/src/session.ts
CHANGED
|
@@ -22,7 +22,18 @@ interface TurnState {
|
|
|
22
22
|
userText: string
|
|
23
23
|
thinkingText: string
|
|
24
24
|
toolCount: number
|
|
25
|
-
|
|
25
|
+
/** `output` / `isError` are filled in by completeTool — kept on the
|
|
26
|
+
* meta (instead of being thrown away after the first render) so a
|
|
27
|
+
* later Task* op can re-render every prior Task* panel with the
|
|
28
|
+
* latest todo mirror appended. */
|
|
29
|
+
toolByUseId: Map<string, {
|
|
30
|
+
i: number
|
|
31
|
+
name: string
|
|
32
|
+
input: any
|
|
33
|
+
resolvedNote?: string
|
|
34
|
+
output?: string
|
|
35
|
+
isError?: boolean
|
|
36
|
+
}>
|
|
26
37
|
assistantSegmentCount: number
|
|
27
38
|
currentAssistantSegmentId: string | null
|
|
28
39
|
currentAssistantText: string
|
|
@@ -65,6 +76,11 @@ export class Session {
|
|
|
65
76
|
private proc: ClaudeProcess | null = null
|
|
66
77
|
private currentTurn: TurnState | null = null
|
|
67
78
|
private pendingPermissions = new Map<string, { toolUseId: string }>()
|
|
79
|
+
/** Open AskUserQuestion tool calls — keyed by tool_use_id, valued
|
|
80
|
+
* with the questions array so we can construct the `answers` reply
|
|
81
|
+
* once a button is clicked. Cleared when the answer is sent or the
|
|
82
|
+
* turn ends. */
|
|
83
|
+
private pendingAsks = new Map<string, { questions: cards.AskQuestion[]; i: number }>()
|
|
68
84
|
private turnCounter = 0
|
|
69
85
|
// Last seen sessionId — preserved across `kill`/`stop` so a later
|
|
70
86
|
// `restart` can resume the same Claude conversation even after the
|
|
@@ -73,6 +89,13 @@ export class Session {
|
|
|
73
89
|
private startedAt: number = 0
|
|
74
90
|
private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
|
|
75
91
|
private lastTurnDelta: LastTurnDelta | null = null
|
|
92
|
+
/** Local mirror of the SDK's task list — built incrementally from
|
|
93
|
+
* TaskCreate / TaskUpdate input+output pairs and rendered as a footer
|
|
94
|
+
* on every Task* panel. Lives for the lifetime of the Session
|
|
95
|
+
* instance; daemon restart wipes it (the SDK doesn't replay history).
|
|
96
|
+
* Not authoritative — Claude calling TaskList is still the source of
|
|
97
|
+
* truth; this mirror is purely for the panel readout. */
|
|
98
|
+
private currentTodos = new Map<number, cards.Todo>()
|
|
76
99
|
status: Status = 'stopped'
|
|
77
100
|
|
|
78
101
|
constructor(
|
|
@@ -270,13 +293,14 @@ export class Session {
|
|
|
270
293
|
const turn = this.currentTurn
|
|
271
294
|
const meta = turn?.toolByUseId.get(pending.toolUseId)
|
|
272
295
|
if (turn && meta) {
|
|
296
|
+
const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
|
|
273
297
|
if (decision === 'deny') {
|
|
274
|
-
const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌')
|
|
298
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
|
|
275
299
|
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
276
300
|
} else {
|
|
277
301
|
const label = decision === 'allow_always' ? '始终允许' : '已允许'
|
|
278
302
|
meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
|
|
279
|
-
const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote)
|
|
303
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
|
|
280
304
|
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
281
305
|
}
|
|
282
306
|
}
|
|
@@ -293,6 +317,42 @@ export class Session {
|
|
|
293
317
|
}
|
|
294
318
|
}
|
|
295
319
|
|
|
320
|
+
/** Handle a click on an AskUserQuestion option button. We construct
|
|
321
|
+
* the SDK-shaped `answers` map (question text → picked option label),
|
|
322
|
+
* send it back as a `tool_result`, and freeze the panel showing the
|
|
323
|
+
* picked option. The permission half of the flow was already auto-
|
|
324
|
+
* allowed in `renderPermission`, so no second action needed there. */
|
|
325
|
+
async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
|
|
326
|
+
const pending = this.pendingAsks.get(toolUseId)
|
|
327
|
+
if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
|
|
328
|
+
const q = pending.questions[questionIdx]
|
|
329
|
+
const opt = q?.options?.[optionIdx]
|
|
330
|
+
if (!q || !opt) {
|
|
331
|
+
log(`session "${this.sessionName}": ask answer out of range q=${questionIdx} o=${optionIdx}`)
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
// Build the answers map. SDK keys answers by the question text
|
|
335
|
+
// (matches what the AskUserQuestion contract expects). For
|
|
336
|
+
// unanswered secondary questions we don't include them — the SDK
|
|
337
|
+
// tolerates a partial map; Claude will see whichever it got.
|
|
338
|
+
const answers: Record<string, string> = { [q.question]: opt.label }
|
|
339
|
+
const content = JSON.stringify({ answers })
|
|
340
|
+
this.proc?.sendToolResult(toolUseId, content)
|
|
341
|
+
this.pendingAsks.delete(toolUseId)
|
|
342
|
+
|
|
343
|
+
// Repaint the panel to reflect the final choice — same element_id,
|
|
344
|
+
// status flips to ✅, body shows what was picked.
|
|
345
|
+
const turn = this.currentTurn
|
|
346
|
+
const meta = turn?.toolByUseId.get(toolUseId)
|
|
347
|
+
if (turn && meta) {
|
|
348
|
+
meta.output = content
|
|
349
|
+
meta.isError = false
|
|
350
|
+
const resolvedNote = `\n\n✅ **已回答** by ${user || '匿名'}: ${opt.label}`
|
|
351
|
+
const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', resolvedNote)
|
|
352
|
+
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
296
356
|
async onConsoleAction(action: string): Promise<void> {
|
|
297
357
|
log(`session "${this.sessionName}": console action=${action}`)
|
|
298
358
|
switch (action) {
|
|
@@ -446,6 +506,14 @@ export class Session {
|
|
|
446
506
|
)
|
|
447
507
|
}
|
|
448
508
|
|
|
509
|
+
private isTaskWorkflow(name: string): boolean {
|
|
510
|
+
return name.startsWith('Task') && name !== 'Task'
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private todosArray(): cards.Todo[] {
|
|
514
|
+
return [...this.currentTodos.values()]
|
|
515
|
+
}
|
|
516
|
+
|
|
449
517
|
private addTool(toolUseId: string, name: string, input: any): void {
|
|
450
518
|
if (!this.currentTurn) return
|
|
451
519
|
// Close current assistant segment (if any) so the tool panel renders
|
|
@@ -458,7 +526,27 @@ export class Session {
|
|
|
458
526
|
}
|
|
459
527
|
const i = this.currentTurn.toolCount++
|
|
460
528
|
this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
|
|
461
|
-
|
|
529
|
+
// AskUserQuestion is a client-side tool — daemon renders the choice
|
|
530
|
+
// UI in-line and supplies the tool_result itself once the user
|
|
531
|
+
// clicks. Branch BEFORE the generic toolCallElement so we never
|
|
532
|
+
// fall through to a JSON dump or, worse, get clobbered by the
|
|
533
|
+
// permission flow (which would render 🔐 three-button buttons that
|
|
534
|
+
// don't match the actual N options).
|
|
535
|
+
if (name === 'AskUserQuestion') {
|
|
536
|
+
const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
|
|
537
|
+
this.pendingAsks.set(toolUseId, { questions, i })
|
|
538
|
+
const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔')
|
|
539
|
+
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
540
|
+
type: 'insert_before',
|
|
541
|
+
targetElementId: cards.ELEMENTS.footer,
|
|
542
|
+
})
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
// Pending Task* panels still show the *pre-op* todo mirror so users
|
|
546
|
+
// can read the current state immediately, without waiting for the
|
|
547
|
+
// tool to return.
|
|
548
|
+
const todos = this.isTaskWorkflow(name) ? this.todosArray() : undefined
|
|
549
|
+
const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, todos)
|
|
462
550
|
void cardkit.addElement(this.currentTurn.cardId, el, {
|
|
463
551
|
type: 'insert_before',
|
|
464
552
|
targetElementId: cards.ELEMENTS.footer,
|
|
@@ -474,8 +562,89 @@ export class Session {
|
|
|
474
562
|
: Array.isArray(content)
|
|
475
563
|
? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
|
|
476
564
|
: JSON.stringify(content)
|
|
477
|
-
|
|
565
|
+
// Stash on the meta — every Task* op coming after this point may
|
|
566
|
+
// need to re-render this panel with a fresher todo footer, so we
|
|
567
|
+
// can't discard the output after the first paint.
|
|
568
|
+
meta.output = output
|
|
569
|
+
meta.isError = isError
|
|
570
|
+
// Update the local todo mirror BEFORE rendering so the just-
|
|
571
|
+
// completed panel shows the new state too (e.g. a TaskCreate panel
|
|
572
|
+
// already lists the task it just created).
|
|
573
|
+
if (!isError && this.isTaskWorkflow(meta.name)) {
|
|
574
|
+
this.updateTodosFromTask(meta.name, meta.input, output)
|
|
575
|
+
}
|
|
576
|
+
const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
|
|
577
|
+
const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, todos)
|
|
478
578
|
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
579
|
+
// Cascade the new mirror into every prior Task* panel in this turn
|
|
580
|
+
// so any expanded panel reflects the latest state, not the snapshot
|
|
581
|
+
// captured when that op ran.
|
|
582
|
+
if (!isError && this.isTaskWorkflow(meta.name)) {
|
|
583
|
+
this.refreshOtherTaskPanels(toolUseId)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Roll a single Task* op into the local mirror — best-effort. Output
|
|
588
|
+
* parsing is regex-based (the SDK returns plain text like "Task #7
|
|
589
|
+
* created successfully: …"), so unexpected variants are skipped
|
|
590
|
+
* silently rather than blowing up the panel render. */
|
|
591
|
+
private updateTodosFromTask(name: string, input: any, output: string): void {
|
|
592
|
+
switch (name) {
|
|
593
|
+
case 'TaskCreate': {
|
|
594
|
+
const m = output.match(/Task #(\d+) created/)
|
|
595
|
+
if (!m) return
|
|
596
|
+
const id = Number(m[1])
|
|
597
|
+
this.currentTodos.set(id, {
|
|
598
|
+
id,
|
|
599
|
+
subject: input.subject,
|
|
600
|
+
description: input.description,
|
|
601
|
+
activeForm: input.activeForm,
|
|
602
|
+
status: 'pending',
|
|
603
|
+
})
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
case 'TaskUpdate': {
|
|
607
|
+
const id = Number(input.taskId)
|
|
608
|
+
if (!Number.isFinite(id)) return
|
|
609
|
+
// status=deleted is the SDK's tombstone — drop from the mirror
|
|
610
|
+
// so the readout doesn't carry it forever. Server still keeps
|
|
611
|
+
// it; the mirror is just for the panel footer.
|
|
612
|
+
if (input.status === 'deleted') { this.currentTodos.delete(id); return }
|
|
613
|
+
const cur = this.currentTodos.get(id) ?? { id, status: 'pending' as const }
|
|
614
|
+
if (input.status) cur.status = input.status
|
|
615
|
+
if (input.subject) cur.subject = input.subject
|
|
616
|
+
if (input.description) cur.description = input.description
|
|
617
|
+
if (input.owner) cur.owner = input.owner
|
|
618
|
+
if (input.activeForm) cur.activeForm = input.activeForm
|
|
619
|
+
this.currentTodos.set(id, cur)
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
// TaskList / TaskGet / TaskStop / TaskOutput / TaskDelete:
|
|
623
|
+
// read-only or parse-heavy — skip mirror update. The panel will
|
|
624
|
+
// still render the SDK's textual result below the operation
|
|
625
|
+
// block, which is enough to disambiguate.
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Re-render every Task* panel in the current turn (except the one
|
|
630
|
+
* that just landed — already up-to-date) so they all show the latest
|
|
631
|
+
* todo mirror in their footers. Cheap: ELEMENTS.tool(i) replace is
|
|
632
|
+
* queued through the per-card Promise chain like any other op. */
|
|
633
|
+
private refreshOtherTaskPanels(skipToolUseId: string): void {
|
|
634
|
+
if (!this.currentTurn) return
|
|
635
|
+
const todos = this.todosArray()
|
|
636
|
+
for (const [id, meta] of this.currentTurn.toolByUseId) {
|
|
637
|
+
if (id === skipToolUseId) continue
|
|
638
|
+
if (!this.isTaskWorkflow(meta.name)) continue
|
|
639
|
+
const status: '⏳' | '✅' | '❌' = meta.output === undefined
|
|
640
|
+
? '⏳'
|
|
641
|
+
: (meta.isError ? '❌' : '✅')
|
|
642
|
+
const el = cards.toolCallElement(
|
|
643
|
+
meta.i, meta.name, meta.input, meta.output ?? null,
|
|
644
|
+
status, meta.resolvedNote, todos,
|
|
645
|
+
)
|
|
646
|
+
void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
647
|
+
}
|
|
479
648
|
}
|
|
480
649
|
|
|
481
650
|
/** Merge the permission ask into the existing tool element in the
|
|
@@ -511,6 +680,19 @@ export class Session {
|
|
|
511
680
|
this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
|
|
512
681
|
return
|
|
513
682
|
}
|
|
683
|
+
// AskUserQuestion: daemon already rendered the choice UI in addTool
|
|
684
|
+
// and is collecting the user's answer asynchronously. The SDK
|
|
685
|
+
// wraps it in the standard permission flow anyway (even under
|
|
686
|
+
// bypassPermissions), so we auto-allow the request — otherwise
|
|
687
|
+
// Claude would block on can_use_tool forever, AND our permission
|
|
688
|
+
// renderer would overwrite the ask UI with the wrong 3-button
|
|
689
|
+
// panel. Keeping it allow-by-default also matches the user's
|
|
690
|
+
// mental model: they expect to choose an option, not "approve"
|
|
691
|
+
// the question itself.
|
|
692
|
+
if (meta.name === 'AskUserQuestion') {
|
|
693
|
+
this.proc?.sendPermissionResponse(req.request_id, 'allow')
|
|
694
|
+
return
|
|
695
|
+
}
|
|
514
696
|
this.status = 'awaiting_permission'
|
|
515
697
|
this.pendingPermissions.set(req.request_id, { toolUseId })
|
|
516
698
|
const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
|