@leviyuan/lodestar 0.1.0 → 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/README.md CHANGED
@@ -18,7 +18,7 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直
18
18
 
19
19
  - 在群里发任意文字 — Claude 接管这一轮,回复以**流式打字机**实时渲染在一张飞书卡片里。
20
20
  - 思考过程、每一次工具调用都在卡片里被收纳为**可展开折叠面板**:折起来是概述,展开是详情。你随时能审阅它在做什么。
21
- - 需要授权的操作(执行命令、修改文件……)会单独弹一张橙色**权限卡片**,你在群里点 `允许` / `始终允许` / `拒绝` 就行。
21
+ - 需要授权的操作(执行命令、修改文件……)**就在原来那一格工具调用面板里**升级为 🔐 等审批状态,三颗按钮 `允许` / `始终允许` / `拒绝` 直接内嵌在面板里 — 不再弹独立卡片,决策结果与后续 output 串在同一条时序里。默认 `bypassPermissions`,所以这一格只在 SDK 真的拦下来时才出现。
22
22
  - **图片、文件双向互传**:用户发到群里的图/文件,Claude 通过消息里的 `[file: /abs/path]` 提示就能读;Claude 想把文件发回来,在回复任意位置写 `[[send: /abs/path]]`,标记会被剥离,文件以独立消息出现在群里。出站路径限制在该会话的工作目录、`/tmp/lodestar-*` 与 inbox 之内,`/etc`、`~/.ssh`、`~/.config` 等敏感目录被白名单拒绝。
23
23
  - 一轮跑完,卡片合上、可转发;下一句话开新一轮。
24
24
 
@@ -28,7 +28,7 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直
28
28
 
29
29
  | 指令 | 行为 |
30
30
  | --- | --- |
31
- | `hi` | 未运行时启动;运行中弹一张**控制台卡片**(状态行 + 中断/clear/终止/ls 按钮) |
31
+ | `hi` | 未运行时启动;运行中弹一张**状态卡片**(model · 上下文占用 · 累计 tokens/cost · 上一轮 delta · session id) |
32
32
  | `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
33
33
  | `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
34
34
  | `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
@@ -68,10 +68,14 @@ projects_root = "~/" # 可选,新建群对应的项目目录会落到
68
68
  ### 3. 启动
69
69
 
70
70
  ```bash
71
- git clone https://github.com/leviyuan/lodestar.git ~/lodestar
72
- cd ~/lodestar
73
- bun install
74
- bun daemon.ts
71
+ bun install -g @leviyuan/lodestar
72
+ lodestar-daemon
73
+ ```
74
+
75
+ 或者一次性跑(无需全局安装):
76
+
77
+ ```bash
78
+ bunx @leviyuan/lodestar
75
79
  ```
76
80
 
77
81
  把机器人拉进任意飞书群,发一条消息——Claude 就上线了。
@@ -89,7 +93,7 @@ After=network-online.target
89
93
 
90
94
  [Service]
91
95
  Type=simple
92
- ExecStart=/home/USER/.bun/bin/bun /home/USER/lodestar/daemon.ts
96
+ ExecStart=/home/USER/.bun/bin/lodestar-daemon
93
97
  Restart=always
94
98
  RestartSec=3
95
99
 
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
  }
@@ -152,6 +155,7 @@ function fmt(m: any[]): string {
152
155
  async function boot(): Promise<void> {
153
156
  log(`lodestar-daemon: pid ${process.pid} starting`)
154
157
  feishu.loadSessionChatMap()
158
+ feishu.loadSessionResumeMap()
155
159
  await feishu.refreshChatList()
156
160
  setInterval(() => { void feishu.refreshChatList() }, 5 * 60 * 1000)
157
161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -19,10 +19,55 @@ 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 ''
25
63
  const truncate = (s: string, n: number) => s.length > n ? s.slice(0, n) + '…' : s
64
+ // Task workflow tools (TaskCreate / TaskUpdate / TaskList / ...) carry
65
+ // structured fields that summarize much better as natural language than
66
+ // as truncated JSON. Routed first so they don't fall through to the
67
+ // generic Agent/Task case below.
68
+ if (name.startsWith('Task') && name !== 'Task') {
69
+ return truncate(summarizeTaskWorkflow(name, input), 80)
70
+ }
26
71
  switch (name) {
27
72
  case 'Bash': return truncate(String(input.command ?? ''), 80)
28
73
  case 'Read':
@@ -44,6 +89,78 @@ export function summarizeToolInput(name: string, input: any): string {
44
89
  return ''
45
90
  }
46
91
 
92
+ /** Header summary for Task* workflow tools — `Task` (singular) is the
93
+ * separate subagent-spawn tool and is handled above; everything else
94
+ * (TaskCreate / TaskUpdate / TaskList / TaskGet / TaskStop / TaskOutput /
95
+ * TaskDelete) summarises through here. */
96
+ function summarizeTaskWorkflow(name: string, input: any): string {
97
+ switch (name) {
98
+ case 'TaskCreate':
99
+ return `📝 创建: ${input.subject ?? '(无 subject)'}`
100
+ case 'TaskUpdate': {
101
+ const parts: string[] = []
102
+ if (input.status) parts.push(`→ ${input.status}`)
103
+ if (input.owner) parts.push(`owner=${input.owner}`)
104
+ if (input.subject) parts.push(`subject="${input.subject}"`)
105
+ if (input.addBlocks) parts.push(`blocks=[${(input.addBlocks ?? []).join(',')}]`)
106
+ if (input.addBlockedBy) parts.push(`blockedBy=[${(input.addBlockedBy ?? []).join(',')}]`)
107
+ const tail = parts.length ? ' ' + parts.join(', ') : ''
108
+ return `✏️ #${input.taskId ?? '?'}${tail}`
109
+ }
110
+ case 'TaskList': return '📋 查询任务列表'
111
+ case 'TaskGet': return `🔍 查询 #${input.taskId ?? '?'}`
112
+ case 'TaskStop': return `⏹ 停止 #${input.taskId ?? '?'}`
113
+ case 'TaskOutput': return `📤 取输出 #${input.taskId ?? '?'}`
114
+ case 'TaskDelete': return `🗑 删除 #${input.taskId ?? '?'}`
115
+ }
116
+ return name
117
+ }
118
+
119
+ /** Markdown body for Task* workflow tools — replaces the generic JSON
120
+ * dump with a human-readable description of the operation plus, once the
121
+ * tool result is in, the SDK's text reply (which already contains "Task
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 {
126
+ const lines: string[] = []
127
+ switch (name) {
128
+ case 'TaskCreate':
129
+ lines.push(`**📝 创建任务**`)
130
+ if (input.subject) lines.push(`- subject: ${input.subject}`)
131
+ if (input.description) lines.push(`- 描述: ${input.description}`)
132
+ if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
133
+ break
134
+ case 'TaskUpdate': {
135
+ lines.push(`**✏️ 更新 #${input.taskId ?? '?'}**`)
136
+ if (input.status) lines.push(`- status → \`${input.status}\``)
137
+ if (input.subject) lines.push(`- subject: ${input.subject}`)
138
+ if (input.description) lines.push(`- description: ${input.description}`)
139
+ if (input.owner) lines.push(`- owner: ${input.owner}`)
140
+ if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
141
+ if (input.addBlocks) lines.push(`- blocks → ${(input.addBlocks).join(', ')}`)
142
+ if (input.addBlockedBy) lines.push(`- blockedBy → ${(input.addBlockedBy).join(', ')}`)
143
+ if (input.metadata) lines.push(`- metadata: \`${JSON.stringify(input.metadata)}\``)
144
+ break
145
+ }
146
+ case 'TaskList': lines.push('**📋 查询当前任务清单**'); break
147
+ case 'TaskGet': lines.push(`**🔍 查询 #${input.taskId ?? '?'}**`); break
148
+ case 'TaskStop': lines.push(`**⏹ 停止 #${input.taskId ?? '?'}**`); break
149
+ case 'TaskOutput': lines.push(`**📤 取 #${input.taskId ?? '?'} 输出**`); break
150
+ case 'TaskDelete': lines.push(`**🗑 删除 #${input.taskId ?? '?'}**`); break
151
+ default:
152
+ lines.push(`**${name}**`)
153
+ lines.push('```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 1000) + '\n```')
154
+ }
155
+ if (output != null) {
156
+ lines.push('')
157
+ lines.push('---')
158
+ lines.push('**结果**')
159
+ lines.push(output.slice(0, 3000))
160
+ }
161
+ return lines.join('\n') + renderTodoList(todos ?? [])
162
+ }
163
+
47
164
  interface MainCardOpts {
48
165
  sessionName: string
49
166
  turn: number
@@ -100,70 +217,84 @@ export function thinkingCollapsedPanel(fullText: string): object {
100
217
  }
101
218
  }
102
219
 
103
- /** Element to insert for each tool call. expandable for big results.
220
+ /** Element to insert for each tool call. Expandable for big results.
104
221
  *
105
222
  * Header is a one-line summary: status + name + summarized input.
106
- * Body holds the full input + (after completion) the full output. */
223
+ * Body holds the full input + (after completion) the full output.
224
+ * `resolvedNote` is an optional one-liner appended below the input —
225
+ * used to surface "✅ 允许 by Alice" inline after a permission decision
226
+ * lands but before the actual tool execution completes. */
107
227
  export function toolCallElement(
108
228
  i: number,
109
229
  name: string,
110
230
  input: any,
111
231
  output: string | null,
112
232
  status: '⏳' | '✅' | '❌' = '⏳',
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[],
113
238
  ): object {
114
239
  const summary = summarizeToolInput(name, input)
115
240
  const headerText = summary
116
241
  ? `${status} 🔧 ${name}: ${summary}`
117
242
  : `${status} 🔧 ${name}`
118
- const inputBlock = '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
119
- const outputBlock = output != null
120
- ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```'
121
- : ''
243
+ const isTaskWorkflow = name.startsWith('Task') && name !== 'Task'
244
+ const noteBlock = resolvedNote ? `\n\n${resolvedNote}` : ''
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.
249
+ const body = isTaskWorkflow
250
+ ? renderTaskWorkflowBody(name, input, output, todos) + noteBlock
251
+ : '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
252
+ + noteBlock
253
+ + (output != null ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```' : '')
122
254
  return {
123
255
  tag: 'collapsible_panel',
124
256
  element_id: ELEMENTS.tool(i),
125
257
  header: { title: { tag: 'plain_text', content: headerText } },
126
258
  expanded: false,
127
259
  elements: [
128
- { tag: 'markdown', content: inputBlock + outputBlock },
260
+ { tag: 'markdown', content: body },
129
261
  ],
130
262
  }
131
263
  }
132
264
 
133
- interface PermissionOpts {
134
- sessionName: string
135
- toolName: string
136
- description: string
137
- inputPreview: string
138
- requestId: string
139
- }
140
-
141
- export function permissionCard(opts: PermissionOpts): object {
142
- const { sessionName, toolName, description, inputPreview, requestId } = opts
143
- let pretty = inputPreview
144
- try { pretty = JSON.stringify(JSON.parse(inputPreview), null, 2) } catch {}
265
+ /** Same tool panel as `toolCallElement`, but with the 🔐 status and
266
+ * three inline action buttons (allow / allow_always / deny). Expanded
267
+ * by default so the user can read the request without clicking through.
268
+ * This is the "merge into tool panel" UX — the permission decision
269
+ * lives on the same row as the tool call instead of as a separate
270
+ * floating card. */
271
+ export function toolCallPermissionElement(
272
+ i: number,
273
+ name: string,
274
+ input: any,
275
+ requestId: string,
276
+ ): object {
277
+ const summary = summarizeToolInput(name, input)
278
+ const headerText = summary
279
+ ? `🔐 等审批 · ${name}: ${summary}`
280
+ : `🔐 等审批 · ${name}`
281
+ const inputBlock = '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
145
282
  return {
146
- schema: '2.0',
147
- config: { update_multi: true },
148
- header: {
149
- title: { tag: 'plain_text', content: `🔐 权限请求 · ${toolName}` },
150
- subtitle: { tag: 'plain_text', content: sessionName },
151
- template: 'orange',
152
- },
153
- body: {
154
- elements: [
155
- { tag: 'markdown', content: description },
156
- { tag: 'markdown', content: '```\n' + pretty.slice(0, 2000) + '\n```' },
157
- {
158
- tag: 'column_set',
159
- columns: [
160
- permissionButtonColumn('✅ 允许', 'primary', requestId, 'allow'),
161
- permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
162
- permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
163
- ],
164
- },
165
- ],
166
- },
283
+ tag: 'collapsible_panel',
284
+ element_id: ELEMENTS.tool(i),
285
+ header: { title: { tag: 'plain_text', content: headerText } },
286
+ expanded: true,
287
+ elements: [
288
+ { tag: 'markdown', content: inputBlock },
289
+ {
290
+ tag: 'column_set',
291
+ columns: [
292
+ permissionButtonColumn('✅ 允许', 'primary', requestId, 'allow'),
293
+ permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
294
+ permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
295
+ ],
296
+ },
297
+ ],
167
298
  }
168
299
  }
169
300
 
@@ -179,26 +310,89 @@ function permissionButtonColumn(label: string, type: string, requestId: string,
179
310
  }
180
311
  }
181
312
 
182
- export function permissionResolvedCard(
183
- toolName: string,
184
- decision: 'allow' | 'allow_always' | 'deny',
185
- user: string,
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,
186
341
  ): object {
187
- const ok = decision !== 'deny'
188
- const label = decision === 'allow_always' ? '始终允许' : decision === 'allow' ? '已允许' : '已拒绝'
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 })
189
390
  return {
190
- schema: '2.0',
191
- config: { update_multi: true },
192
- header: {
193
- title: { tag: 'plain_text', content: `🔐 权限请求 · ${toolName}` },
194
- template: ok ? 'green' : 'red',
195
- },
196
- body: {
197
- elements: [{
198
- tag: 'markdown',
199
- content: `${ok ? '✅' : '❌'} **${label}** by ${user || '匿名'} · ${new Date().toLocaleTimeString('zh-CN', { hour12: false })}`,
200
- }],
201
- },
391
+ tag: 'collapsible_panel',
392
+ element_id: ELEMENTS.tool(i),
393
+ header: { title: { tag: 'plain_text', content: headerText } },
394
+ expanded: true,
395
+ elements: bodyElements,
202
396
  }
203
397
  }
204
398
 
@@ -207,36 +401,92 @@ interface ConsoleOpts {
207
401
  status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
208
402
  model?: string
209
403
  effort?: string
210
- uptime?: string
211
- lastActivity?: string
404
+ /** ms since this ClaudeProcess spawned — formatted to "1h 23m" inside. */
405
+ uptimeMs?: number
406
+ /** Current context-window occupancy estimate (input + cache tokens of
407
+ * the last assistant message). 0 if no turn has completed yet. */
408
+ contextTokens?: number
409
+ /** Window upper bound. Defaults to 1M (claude-opus-4-7[1m]). */
410
+ contextLimit?: number
411
+ cumStats?: { tokens: number; costUsd: number; turns: number }
412
+ lastTurn?: { tokens: number; costUsd: number; durationMs: number }
413
+ sessionId?: string | null
212
414
  hasSession: boolean
213
415
  }
214
416
 
417
+ /** Format token counts as a compact human-readable string: 1,234 → 1.2K. */
418
+ function fmtTokens(n: number): string {
419
+ if (!n) return '0'
420
+ if (n < 1000) return String(n)
421
+ if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '') + 'K'
422
+ return (n / 1_000_000).toFixed(2).replace(/\.?0+$/, '') + 'M'
423
+ }
424
+
425
+ function fmtCost(c: number): string {
426
+ if (c < 0.01) return `$${c.toFixed(4)}`
427
+ return `$${c.toFixed(2)}`
428
+ }
429
+
430
+ function fmtDurationMs(ms: number): string {
431
+ if (ms < 1000) return `${ms}ms`
432
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
433
+ const m = Math.floor(ms / 60_000)
434
+ const s = Math.round((ms % 60_000) / 1000)
435
+ return `${m}m ${s}s`
436
+ }
437
+
438
+ function fmtUptime(ms: number): string {
439
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`
440
+ const totalMin = Math.floor(ms / 60_000)
441
+ if (totalMin < 60) return `${totalMin}m`
442
+ const h = Math.floor(totalMin / 60)
443
+ const m = totalMin % 60
444
+ if (h < 24) return `${h}h ${m}m`
445
+ const d = Math.floor(h / 24)
446
+ return `${d}d ${h % 24}h`
447
+ }
448
+
215
449
  export function consoleCard(opts: ConsoleOpts): object {
216
- const { sessionName, status, model, effort, uptime, lastActivity, hasSession } = opts
450
+ const {
451
+ sessionName, status, model, effort, uptimeMs,
452
+ contextTokens, contextLimit, cumStats, lastTurn, sessionId, hasSession,
453
+ } = opts
217
454
  const statusEmoji = {
218
455
  idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
219
456
  starting: '🚀 启动中', stopped: '⚪ 未运行',
220
457
  }[status]
221
- const meta = [
222
- `状态: ${statusEmoji}`,
223
- model ? `模型: ${model}${effort ? `/${effort}` : ''}` : null,
224
- uptime ? `运行: ${uptime}` : null,
225
- lastActivity ? `最近: ${lastActivity}` : null,
226
- ].filter(Boolean).join(' · ')
227
458
 
228
- const buttons: [string, string, string][] = hasSession
229
- ? [
230
- ['⏸ 中断', 'interrupt', 'default'],
231
- ['🧹 /clear', 'clear', 'default'],
232
- ['⏹ 终止', 'stop', 'danger'],
233
- ['📁 ls', 'ls', 'default'],
234
- ]
235
- : [
236
- ['🚀 启动', 'start', 'primary'],
237
- ['🔁 续聊', 'resume', 'default'],
238
- ['📁 ls', 'ls', 'default'],
239
- ]
459
+ const modelLine = model ? `${model}${effort ? `/${effort}` : ''}` : null
460
+ const headerLine = [statusEmoji, modelLine].filter(Boolean).join(' · ')
461
+
462
+ // Build the metric lines that make this panel useful. Each is "label
463
+ // <tab> value" rendered as plain markdown — keeps it readable inside
464
+ // the small Feishu card area without competing with the button row.
465
+ const lines: string[] = [headerLine]
466
+
467
+ if (contextTokens != null) {
468
+ const limit = contextLimit ?? 1_000_000
469
+ const pct = limit > 0 ? Math.round((contextTokens / limit) * 100) : 0
470
+ lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} / ${fmtTokens(limit)} (${pct}%)`)
471
+ }
472
+ if (uptimeMs != null && uptimeMs > 0) {
473
+ lines.push(`**⏱ 已运行** ${fmtUptime(uptimeMs)}`)
474
+ }
475
+ if (cumStats && (cumStats.tokens > 0 || cumStats.costUsd > 0 || cumStats.turns > 0)) {
476
+ lines.push(`**💬 累计** ${fmtTokens(cumStats.tokens)} tokens · ${fmtCost(cumStats.costUsd)} · ${cumStats.turns} turn${cumStats.turns === 1 ? '' : 's'}`)
477
+ }
478
+ if (lastTurn) {
479
+ lines.push(`**🔄 上一轮** +${fmtTokens(lastTurn.tokens)} · ${fmtCost(lastTurn.costUsd)} · ${fmtDurationMs(lastTurn.durationMs)}`)
480
+ }
481
+ if (sessionId) {
482
+ lines.push(`**🆔 session** \`${sessionId.slice(0, 8)}…\``)
483
+ }
484
+
485
+ void hasSession // accept the field for caller compat; lifecycle is now
486
+ // driven by bare-word commands (`hi` / `kill` / `restart` / `clear`),
487
+ // not buttons — keeps the panel pure-readout and one-handed mobile-
488
+ // friendly. The 'refresh' / 'ls' actions stay in onConsoleAction for
489
+ // backward compat with any still-floating older cards in chat history.
240
490
 
241
491
  const template = status === 'working' ? 'blue'
242
492
  : status === 'awaiting_permission' ? 'orange'
@@ -252,19 +502,7 @@ export function consoleCard(opts: ConsoleOpts): object {
252
502
  },
253
503
  body: {
254
504
  elements: [
255
- { tag: 'markdown', content: meta || '_(no state)_' },
256
- {
257
- tag: 'column_set',
258
- columns: buttons.map(([label, action, kind]) => ({
259
- tag: 'column', width: 'weighted', weight: 1,
260
- elements: [{
261
- tag: 'button',
262
- text: { tag: 'plain_text', content: label },
263
- type: kind,
264
- behaviors: [{ type: 'callback', value: { kind: 'console', action } }],
265
- }],
266
- })),
267
- },
505
+ { tag: 'markdown', content: lines.join('\n\n') },
268
506
  ],
269
507
  },
270
508
  }
@@ -44,6 +44,23 @@ export interface CanUseToolRequest {
44
44
  tool_use_id?: string
45
45
  }
46
46
 
47
+ /** Subset of the SDK's `usage` object — only the fields the console
48
+ * panel actually shows. Anthropic adds new ones (server_tool_use,
49
+ * service_tier, iterations, …) without breaking existing readers. */
50
+ export interface ClaudeUsage {
51
+ input_tokens?: number
52
+ output_tokens?: number
53
+ cache_creation_input_tokens?: number
54
+ cache_read_input_tokens?: number
55
+ }
56
+
57
+ export interface ClaudeResultMeta {
58
+ cost_usd: number | null
59
+ duration_ms: number | null
60
+ num_turns: number | null
61
+ usage: ClaudeUsage | null
62
+ }
63
+
47
64
  export interface HookCallbackRequest {
48
65
  request_id: string
49
66
  callback_id: string
@@ -60,6 +77,22 @@ export class ClaudeProcess extends EventEmitter {
60
77
  private expectedExit = false
61
78
  sessionId: string | null = null
62
79
  lastAssistantUuid: string | null = null
80
+ /** Model id (`claude-opus-4-7`, …) from the most recent assistant
81
+ * message — surfaced in the console panel so the user sees what's
82
+ * actually running, not whatever default we'd assume. */
83
+ lastModel: string | null = null
84
+ /** Usage from the most recent `assistant` message — input_tokens +
85
+ * cache_* on the *latest* message is the best proxy for current
86
+ * context-window occupancy, since each assistant turn replays the
87
+ * accumulated conversation. */
88
+ lastUsage: ClaudeUsage | null = null
89
+ /** Result-level metadata from the most recent `result` message —
90
+ * per-turn cost / wallclock / num_turns. Cleared to null only on
91
+ * spawn; persists across turns so the console panel can show "last
92
+ * turn" stats even while idle. */
93
+ lastResult: ClaudeResultMeta = {
94
+ cost_usd: null, duration_ms: null, num_turns: null, usage: null,
95
+ }
63
96
 
64
97
  constructor(opts: SpawnOpts) {
65
98
  super()
@@ -184,6 +217,8 @@ export class ClaudeProcess extends EventEmitter {
184
217
  this.emit('thinking', { uuid: msg.uuid, text: block.thinking })
185
218
  }
186
219
  }
220
+ if (msg.message?.usage) this.lastUsage = msg.message.usage as ClaudeUsage
221
+ if (typeof msg.message?.model === 'string') this.lastModel = msg.message.model
187
222
  if (msg.uuid) this.lastAssistantUuid = msg.uuid
188
223
  return
189
224
  }
@@ -201,6 +236,12 @@ export class ClaudeProcess extends EventEmitter {
201
236
  return
202
237
  }
203
238
  if (type === 'result') {
239
+ this.lastResult = {
240
+ cost_usd: typeof msg.total_cost_usd === 'number' ? msg.total_cost_usd : null,
241
+ duration_ms: typeof msg.duration_ms === 'number' ? msg.duration_ms : null,
242
+ num_turns: typeof msg.num_turns === 'number' ? msg.num_turns : null,
243
+ usage: msg.usage ?? null,
244
+ }
204
245
  this.emit('result', msg)
205
246
  return
206
247
  }
@@ -245,19 +286,53 @@ export class ClaudeProcess extends EventEmitter {
245
286
  })
246
287
  }
247
288
 
248
- sendPermissionResponse(requestId: string, decision: 'allow' | 'deny', updatedInput?: any): void {
289
+ /** Response schema for `can_use_tool` control_request (NOT the
290
+ * PreToolUse-hook output shape that lives elsewhere in the SDK).
291
+ * allow → { behavior: 'allow', updatedInput: <record> }
292
+ * deny → { behavior: 'deny', message: <string> }
293
+ * Sending the wrong shape gets Zod-rejected on the Claude side and
294
+ * the decision never lands — the card visually flips to "resolved"
295
+ * but Claude immediately re-fires the same can_use_tool, looking to
296
+ * the user like a "flash back to original" bug. */
297
+ sendPermissionResponse(
298
+ requestId: string,
299
+ decision: 'allow' | 'deny',
300
+ payload?: { updatedInput?: Record<string, unknown>; denyMessage?: string },
301
+ ): void {
302
+ const response = decision === 'allow'
303
+ ? { behavior: 'allow' as const, updatedInput: payload?.updatedInput ?? {} }
304
+ : { behavior: 'deny' as const, message: payload?.denyMessage ?? 'denied by user' }
249
305
  this.write({
250
306
  type: 'control_response',
251
307
  response: {
252
308
  subtype: 'success',
253
309
  request_id: requestId,
254
- response: {
255
- hookSpecificOutput: {
256
- hookEventName: 'PreToolUse',
257
- permissionDecision: decision,
258
- ...(updatedInput ? { permissionDecisionInput: updatedInput } : {}),
259
- },
260
- },
310
+ response,
311
+ },
312
+ })
313
+ }
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
+ }],
261
336
  },
262
337
  })
263
338
  }
package/src/feishu.ts CHANGED
@@ -12,7 +12,7 @@ import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from '
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 } from './paths'
15
+ import { 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
@@ -70,6 +70,42 @@ export function bindSessionToChat(sessionName: string, chatId: string): void {
70
70
  log(`feishu: bound session "${sessionName}" → ${chatId}${prev ? ` (was ${prev})` : ''}`)
71
71
  }
72
72
 
73
+ // ── Session resume map ────────────────────────────────────────────────
74
+ // `sessionName → last-known claude session_id`. Persisted so a daemon
75
+ // restart (systemctl, crash, watchdog) doesn't strand the user with a
76
+ // fresh conversation when they next type `restart`. Updated on every
77
+ // `system/init` from a claude subprocess.
78
+ const lastSessionIdByName = new Map<string, string>()
79
+
80
+ export function loadSessionResumeMap(): void {
81
+ try {
82
+ const obj = JSON.parse(readFileSync(SESSION_RESUME_MAP_FILE, 'utf8'))
83
+ for (const [name, id] of Object.entries(obj)) {
84
+ if (typeof id === 'string') lastSessionIdByName.set(name, id)
85
+ }
86
+ log(`feishu: loaded ${lastSessionIdByName.size} session→resume bindings`)
87
+ } catch {}
88
+ }
89
+
90
+ function saveSessionResumeMap(): void {
91
+ try {
92
+ const obj: Record<string, string> = {}
93
+ for (const [k, v] of lastSessionIdByName) obj[k] = v
94
+ mkdirSync(DATA_DIR, { recursive: true })
95
+ writeFileSync(SESSION_RESUME_MAP_FILE, JSON.stringify(obj, null, 2))
96
+ } catch (e) { log(`feishu: save session-resume-map failed: ${e}`) }
97
+ }
98
+
99
+ export function bindSessionResume(sessionName: string, sessionId: string): void {
100
+ if (lastSessionIdByName.get(sessionName) === sessionId) return
101
+ lastSessionIdByName.set(sessionName, sessionId)
102
+ saveSessionResumeMap()
103
+ }
104
+
105
+ export function getSessionResume(sessionName: string): string | null {
106
+ return lastSessionIdByName.get(sessionName) ?? null
107
+ }
108
+
73
109
  export function chatIdForSession(sessionName: string): string | null {
74
110
  const preferred = preferredChatForSession.get(sessionName)
75
111
  if (preferred && chatNameCache.get(preferred) === sessionName) return preferred
@@ -127,23 +163,6 @@ export async function sendCard(chatId: string, card: object): Promise<string | n
127
163
  } catch (e) { log(`feishu: sendCard failed chat=${chatId}: ${e}`); return null }
128
164
  }
129
165
 
130
- // PATCH a regular interactive message (i.e. a card NOT promoted to a
131
- // cardkit entity). Used for permission cards that flip allow/deny once.
132
- export async function patchCardMessage(messageId: string, card: object): Promise<void> {
133
- try {
134
- const token = await getTenantToken()
135
- const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages/${messageId}`, {
136
- method: 'PATCH',
137
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
138
- body: JSON.stringify({ content: JSON.stringify(card) }),
139
- })
140
- const data = await res.json() as any
141
- if (data?.code && data.code !== 0) {
142
- log(`feishu: patchCardMessage ${messageId} code=${data.code} msg=${data.msg}`)
143
- }
144
- } catch (e) { log(`feishu: patchCardMessage ${messageId} failed: ${e}`) }
145
- }
146
-
147
166
  // ── Reactions ──────────────────────────────────────────────────────────
148
167
  export async function addReaction(messageId: string, emojiType: string): Promise<void> {
149
168
  if (!messageId) return
package/src/paths.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * daemon.pid — single-instance lock (in DATA_DIR)
9
9
  * daemon.log — append-only run log (in DATA_DIR)
10
10
  * session-chat-map.json — duplicate-name routing (in DATA_DIR)
11
+ * session-resume-map.json — last-known claude session_id (in DATA_DIR)
11
12
  * inbox/ — downloaded attachments (in DATA_DIR)
12
13
  */
13
14
 
@@ -38,4 +39,5 @@ export const CONFIG_FILE = process.env.LODESTAR_CONFIG ?? join(CONFIG_DIR, 'conf
38
39
  export const PID_FILE = join(DATA_DIR, 'daemon.pid')
39
40
  export const LOG_FILE = join(DATA_DIR, 'daemon.log')
40
41
  export const SESSION_CHAT_MAP_FILE = join(DATA_DIR, 'session-chat-map.json')
42
+ export const SESSION_RESUME_MAP_FILE = join(DATA_DIR, 'session-resume-map.json')
41
43
  export const INBOX_DIR = join(DATA_DIR, 'inbox')
package/src/session.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { existsSync } from 'node:fs'
11
11
  import { join } from 'node:path'
12
- import { ClaudeProcess, type CanUseToolRequest, type HookCallbackRequest } from './claude-process'
12
+ import { ClaudeProcess, type CanUseToolRequest, type HookCallbackRequest, type ClaudeUsage } from './claude-process'
13
13
  import { CHANNEL_INSTRUCTIONS } from './instructions'
14
14
  import * as cardkit from './cardkit'
15
15
  import * as cards from './cards'
@@ -22,7 +22,18 @@ interface TurnState {
22
22
  userText: string
23
23
  thinkingText: string
24
24
  toolCount: number
25
- toolByUseId: Map<string, { i: number; name: string; input: any }>
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
@@ -41,23 +52,65 @@ export interface SessionOpts {
41
52
  permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
42
53
  }
43
54
 
55
+ /** Per-turn delta extracted from the SDK `result` message — feeds the
56
+ * "上一轮" line in the console panel. */
57
+ interface LastTurnDelta {
58
+ tokens: number // input + cache_* + output for that turn
59
+ costUsd: number
60
+ durationMs: number
61
+ inputTokens: number // input + cache_* (excludes output) — context-window estimate
62
+ }
63
+
64
+ /** Cumulative session counters. Reset on full restart (`clear`), preserved
65
+ * across resume — but resumed conversations start counting from the
66
+ * resume point, not the original turn 0; the SDK doesn't replay historical
67
+ * usage. The session_id continuity is preserved separately by the resume
68
+ * map; cumStats represents "since the current ClaudeProcess was spawned". */
69
+ interface CumStats {
70
+ tokens: number
71
+ costUsd: number
72
+ turns: number
73
+ }
74
+
44
75
  export class Session {
45
76
  private proc: ClaudeProcess | null = null
46
77
  private currentTurn: TurnState | null = null
47
- private pendingPermissions = new Map<string, { messageId: string; toolName: string }>()
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 }>()
48
84
  private turnCounter = 0
49
85
  // Last seen sessionId — preserved across `kill`/`stop` so a later
50
86
  // `restart` can resume the same Claude conversation even after the
51
87
  // child process is gone.
52
88
  private lastSessionId: string | null = null
53
89
  private startedAt: number = 0
90
+ private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
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>()
54
99
  status: Status = 'stopped'
55
100
 
56
101
  constructor(
57
102
  public readonly sessionName: string,
58
103
  public readonly chatId: string,
59
104
  private opts: SessionOpts = {},
60
- ) {}
105
+ ) {
106
+ // Restore last-known claude session_id from disk so a daemon restart
107
+ // (systemctl, crash, watchdog) doesn't strand the user with a fresh
108
+ // conversation when they next type `restart`.
109
+ this.lastSessionId = feishu.getSessionResume(sessionName)
110
+ if (this.lastSessionId) {
111
+ log(`session "${sessionName}": restored lastSessionId=${this.lastSessionId.slice(0, 8)}…`)
112
+ }
113
+ }
61
114
 
62
115
  get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
63
116
  isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
@@ -82,7 +135,7 @@ export class Session {
82
135
  this.proc = new ClaudeProcess({
83
136
  workDir: this.workDir,
84
137
  effort: 'max',
85
- permissionMode: this.opts.permissionMode ?? 'default',
138
+ permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
86
139
  appendSystemPrompt: CHANNEL_INSTRUCTIONS,
87
140
  })
88
141
  this.wireProc(this.proc)
@@ -122,7 +175,7 @@ export class Session {
122
175
  this.proc = new ClaudeProcess({
123
176
  workDir: this.workDir,
124
177
  effort: 'max',
125
- permissionMode: 'default',
178
+ permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
126
179
  resumeSessionId: prevSessionId,
127
180
  appendSystemPrompt: CHANNEL_INSTRUCTIONS,
128
181
  })
@@ -132,6 +185,16 @@ export class Session {
132
185
  this.startedAt = Date.now()
133
186
  await feishu.sendText(this.chatId, `🔁 已重启并恢复 session=${prevSessionId.slice(0, 8)}…`)
134
187
  } else {
188
+ // Resume requested but no prior session_id on file — surface it
189
+ // explicitly rather than silently fresh-starting (the old behavior
190
+ // hid the daemon-restart sessionId-loss bug for months).
191
+ if (resume) {
192
+ await feishu.sendText(this.chatId, '⚠️ 没有可恢复的上一会话,将以新会话启动')
193
+ }
194
+ // Fresh conversation — drop cumulative stats so the next `hi` shows
195
+ // zeroed counters instead of bleeding numbers from the prior chat.
196
+ this.cumStats = { tokens: 0, costUsd: 0, turns: 0 }
197
+ this.lastTurnDelta = null
135
198
  await this.start()
136
199
  }
137
200
  }
@@ -168,14 +231,27 @@ export class Session {
168
231
  }
169
232
 
170
233
  async showConsole(): Promise<void> {
171
- const uptime = this.startedAt
172
- ? `${Math.round((Date.now() - this.startedAt) / 1000)}s`
173
- : undefined
234
+ const uptimeMs = this.startedAt ? (Date.now() - this.startedAt) : undefined
235
+ // Strip the `claude-` prefix so the panel stays compact: `opus-4-7`
236
+ // reads better than `claude-opus-4-7` in the small status header.
237
+ const rawModel = this.proc?.lastModel ?? null
238
+ const model = rawModel ? rawModel.replace(/^claude-/, '') : undefined
174
239
  const card = cards.consoleCard({
175
240
  sessionName: this.sessionName,
176
241
  status: this.status,
242
+ model,
177
243
  effort: 'max',
178
- uptime,
244
+ uptimeMs,
245
+ contextTokens: this.currentContextTokens(),
246
+ cumStats: this.cumStats,
247
+ lastTurn: this.lastTurnDelta
248
+ ? {
249
+ tokens: this.lastTurnDelta.tokens,
250
+ costUsd: this.lastTurnDelta.costUsd,
251
+ durationMs: this.lastTurnDelta.durationMs,
252
+ }
253
+ : undefined,
254
+ sessionId: this.proc?.sessionId ?? this.lastSessionId,
179
255
  hasSession: this.isRunning(),
180
256
  })
181
257
  await feishu.sendCard(this.chatId, card)
@@ -212,8 +288,22 @@ export class Session {
212
288
  if (!pending) { log(`session "${this.sessionName}": stray permission ${requestId}`); return }
213
289
  this.pendingPermissions.delete(requestId)
214
290
 
215
- const resolved = cards.permissionResolvedCard(pending.toolName, decision, user)
216
- await feishu.patchCardMessage(pending.messageId, resolved)
291
+ // Update the tool element in the main turn card in place — the
292
+ // permission decision lives on the same row as the tool call.
293
+ const turn = this.currentTurn
294
+ const meta = turn?.toolByUseId.get(pending.toolUseId)
295
+ if (turn && meta) {
296
+ const todos = this.isTaskWorkflow(meta.name) ? this.todosArray() : undefined
297
+ if (decision === 'deny') {
298
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
299
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
300
+ } else {
301
+ const label = decision === 'allow_always' ? '始终允许' : '已允许'
302
+ meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
303
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
304
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
305
+ }
306
+ }
217
307
 
218
308
  const claudeDecision = decision === 'deny' ? 'deny' : 'allow'
219
309
  this.proc?.sendPermissionResponse(requestId, claudeDecision)
@@ -227,6 +317,42 @@ export class Session {
227
317
  }
228
318
  }
229
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
+
230
356
  async onConsoleAction(action: string): Promise<void> {
231
357
  log(`session "${this.sessionName}": console action=${action}`)
232
358
  switch (action) {
@@ -235,12 +361,22 @@ export class Session {
235
361
  case 'stop': await this.stop(); break
236
362
  case 'start': await this.start(); break
237
363
  case 'resume': await this.restart(true); break
364
+ case 'refresh': await this.showConsole(); break
238
365
  case 'ls': await feishu.sendText(this.chatId, `📁 ${this.workDir}`); break
239
366
  }
240
367
  }
241
368
 
242
369
  // ── Wiring Claude → Feishu ─────────────────────────────────────────
243
370
  private wireProc(p: ClaudeProcess): void {
371
+ p.on('init', () => {
372
+ // Persist the freshly assigned session_id so a later daemon
373
+ // restart can resume this conversation. Skip if unchanged to
374
+ // avoid hammering the file on every init for resumed sessions.
375
+ if (p.sessionId && p.sessionId !== this.lastSessionId) {
376
+ this.lastSessionId = p.sessionId
377
+ feishu.bindSessionResume(this.sessionName, p.sessionId)
378
+ }
379
+ })
244
380
  p.on('assistant_text', ({ text }: { text: string }) => {
245
381
  this.appendAssistant(text)
246
382
  })
@@ -254,13 +390,14 @@ export class Session {
254
390
  this.completeTool(tool_use_id, content, is_error)
255
391
  })
256
392
  p.on('can_use_tool', (req: CanUseToolRequest) => {
257
- void this.renderPermission(req)
393
+ this.renderPermission(req)
258
394
  })
259
395
  p.on('hook_callback', (req: HookCallbackRequest) => {
260
396
  // No hooks registered → fail-safe ack.
261
397
  this.proc?.sendHookResponse(req.request_id, {})
262
398
  })
263
399
  p.on('result', () => {
400
+ this.accumulateResultStats()
264
401
  void this.closeTurnCard()
265
402
  this.status = 'idle'
266
403
  })
@@ -275,6 +412,37 @@ export class Session {
275
412
  })
276
413
  }
277
414
 
415
+ /** Pull per-turn numbers off `proc.lastResult` (set by ClaudeProcess when
416
+ * the `result` message landed) and roll them into cumStats + the
417
+ * "上一轮" delta. Called exactly once per result event, right before
418
+ * closeTurnCard. */
419
+ private accumulateResultStats(): void {
420
+ const r = this.proc?.lastResult
421
+ if (!r) return
422
+ const u = r.usage ?? {}
423
+ const inputTokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
424
+ const outputTokens = u.output_tokens ?? 0
425
+ const tokens = inputTokens + outputTokens
426
+ const costUsd = r.cost_usd ?? 0
427
+ const durationMs = r.duration_ms ?? 0
428
+ this.cumStats.tokens += tokens
429
+ this.cumStats.costUsd += costUsd
430
+ this.cumStats.turns += r.num_turns ?? 1
431
+ this.lastTurnDelta = { tokens, costUsd, durationMs, inputTokens }
432
+ }
433
+
434
+ /** Current context-window occupancy estimate — uses the most recent
435
+ * assistant `usage` (input + caches), since each assistant reply replays
436
+ * the full conversation. Falls back to the last-turn delta when no
437
+ * assistant message has streamed yet this process. */
438
+ private currentContextTokens(): number {
439
+ const u = this.proc?.lastUsage as ClaudeUsage | null | undefined
440
+ if (u) {
441
+ return (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
442
+ }
443
+ return this.lastTurnDelta?.inputTokens ?? 0
444
+ }
445
+
278
446
  private async openTurnCard(userText: string): Promise<void> {
279
447
  const turn = ++this.turnCounter
280
448
  const card = cards.mainConversationCard({
@@ -338,6 +506,14 @@ export class Session {
338
506
  )
339
507
  }
340
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
+
341
517
  private addTool(toolUseId: string, name: string, input: any): void {
342
518
  if (!this.currentTurn) return
343
519
  // Close current assistant segment (if any) so the tool panel renders
@@ -350,7 +526,27 @@ export class Session {
350
526
  }
351
527
  const i = this.currentTurn.toolCount++
352
528
  this.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
353
- const el = cards.toolCallElement(i, name, input, null, '⏳')
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)
354
550
  void cardkit.addElement(this.currentTurn.cardId, el, {
355
551
  type: 'insert_before',
356
552
  targetElementId: cards.ELEMENTS.footer,
@@ -366,26 +562,141 @@ export class Session {
366
562
  : Array.isArray(content)
367
563
  ? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
368
564
  : JSON.stringify(content)
369
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅')
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)
370
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
+ }
371
585
  }
372
586
 
373
- private async renderPermission(req: CanUseToolRequest): Promise<void> {
374
- this.status = 'awaiting_permission'
375
- const card = cards.permissionCard({
376
- sessionName: this.sessionName,
377
- toolName: req.tool_name,
378
- description: `工具 \`${req.tool_name}\` 想在 ~/${this.sessionName} 执行操作`,
379
- inputPreview: JSON.stringify(req.input ?? {}),
380
- requestId: req.request_id,
381
- })
382
- const messageId = await feishu.sendCard(this.chatId, card)
383
- if (!messageId) {
384
- log(`session "${this.sessionName}": permission card send failed; auto-deny`)
385
- this.proc?.sendPermissionResponse(req.request_id, 'deny')
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
+ }
648
+ }
649
+
650
+ /** Merge the permission ask into the existing tool element in the
651
+ * current turn card. The user sees one continuous timeline: ⏳ pending
652
+ * → 🔐 awaiting approval (with buttons) → ⏳ allowed / ❌ denied → ✅
653
+ * with output. No floating orange card.
654
+ *
655
+ * `tool_use` is emitted as part of the assistant message and lands on
656
+ * our `addTool` handler BEFORE the SDK's `can_use_tool` control_request
657
+ * arrives — so by the time we get here, `toolByUseId` already has the
658
+ * entry we need to replace.
659
+ *
660
+ * Edge cases (no current turn / missing tool_use_id / unknown id) are
661
+ * surfaced loudly and auto-denied. We don't fall back to a standalone
662
+ * card — per the project's no-fallbacks rule, hidden anomalies are
663
+ * worse than visible deny errors. */
664
+ private renderPermission(req: CanUseToolRequest): void {
665
+ const turn = this.currentTurn
666
+ if (!turn) {
667
+ log(`session "${this.sessionName}": can_use_tool with no current turn — auto-deny req=${req.request_id}`)
668
+ this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no active turn' })
386
669
  return
387
670
  }
388
- this.pendingPermissions.set(req.request_id, { messageId, toolName: req.tool_name })
671
+ const toolUseId = req.tool_use_id
672
+ if (!toolUseId) {
673
+ log(`session "${this.sessionName}": can_use_tool without tool_use_id — auto-deny req=${req.request_id}`)
674
+ this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no tool_use_id' })
675
+ return
676
+ }
677
+ const meta = turn.toolByUseId.get(toolUseId)
678
+ if (!meta) {
679
+ log(`session "${this.sessionName}": can_use_tool for unknown tool_use_id=${toolUseId} — auto-deny req=${req.request_id}`)
680
+ this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
681
+ return
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
+ }
696
+ this.status = 'awaiting_permission'
697
+ this.pendingPermissions.set(req.request_id, { toolUseId })
698
+ const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
699
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
389
700
  }
390
701
 
391
702
  private async closeTurnCard(suffix?: string): Promise<void> {