@leviyuan/lodestar 0.1.0 → 0.1.1

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
@@ -152,6 +152,7 @@ function fmt(m: any[]): string {
152
152
  async function boot(): Promise<void> {
153
153
  log(`lodestar-daemon: pid ${process.pid} starting`)
154
154
  feishu.loadSessionChatMap()
155
+ feishu.loadSessionResumeMap()
155
156
  await feishu.refreshChatList()
156
157
  setInterval(() => { void feishu.refreshChatList() }, 5 * 60 * 1000)
157
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -23,6 +23,13 @@ export const ELEMENTS = {
23
23
  export function summarizeToolInput(name: string, input: any): string {
24
24
  if (!input || typeof input !== 'object') return ''
25
25
  const truncate = (s: string, n: number) => s.length > n ? s.slice(0, n) + '…' : s
26
+ // Task workflow tools (TaskCreate / TaskUpdate / TaskList / ...) carry
27
+ // structured fields that summarize much better as natural language than
28
+ // as truncated JSON. Routed first so they don't fall through to the
29
+ // generic Agent/Task case below.
30
+ if (name.startsWith('Task') && name !== 'Task') {
31
+ return truncate(summarizeTaskWorkflow(name, input), 80)
32
+ }
26
33
  switch (name) {
27
34
  case 'Bash': return truncate(String(input.command ?? ''), 80)
28
35
  case 'Read':
@@ -44,6 +51,76 @@ export function summarizeToolInput(name: string, input: any): string {
44
51
  return ''
45
52
  }
46
53
 
54
+ /** Header summary for Task* workflow tools — `Task` (singular) is the
55
+ * separate subagent-spawn tool and is handled above; everything else
56
+ * (TaskCreate / TaskUpdate / TaskList / TaskGet / TaskStop / TaskOutput /
57
+ * TaskDelete) summarises through here. */
58
+ function summarizeTaskWorkflow(name: string, input: any): string {
59
+ switch (name) {
60
+ case 'TaskCreate':
61
+ return `📝 创建: ${input.subject ?? '(无 subject)'}`
62
+ case 'TaskUpdate': {
63
+ const parts: string[] = []
64
+ if (input.status) parts.push(`→ ${input.status}`)
65
+ if (input.owner) parts.push(`owner=${input.owner}`)
66
+ if (input.subject) parts.push(`subject="${input.subject}"`)
67
+ if (input.addBlocks) parts.push(`blocks=[${(input.addBlocks ?? []).join(',')}]`)
68
+ if (input.addBlockedBy) parts.push(`blockedBy=[${(input.addBlockedBy ?? []).join(',')}]`)
69
+ const tail = parts.length ? ' ' + parts.join(', ') : ''
70
+ return `✏️ #${input.taskId ?? '?'}${tail}`
71
+ }
72
+ case 'TaskList': return '📋 查询任务列表'
73
+ case 'TaskGet': return `🔍 查询 #${input.taskId ?? '?'}`
74
+ case 'TaskStop': return `⏹ 停止 #${input.taskId ?? '?'}`
75
+ case 'TaskOutput': return `📤 取输出 #${input.taskId ?? '?'}`
76
+ case 'TaskDelete': return `🗑 删除 #${input.taskId ?? '?'}`
77
+ }
78
+ return name
79
+ }
80
+
81
+ /** Markdown body for Task* workflow tools — replaces the generic JSON
82
+ * dump with a human-readable description of the operation plus, once the
83
+ * 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
+ function renderTaskWorkflowBody(name: string, input: any, output: string | null): string {
86
+ const lines: string[] = []
87
+ switch (name) {
88
+ case 'TaskCreate':
89
+ lines.push(`**📝 创建任务**`)
90
+ if (input.subject) lines.push(`- subject: ${input.subject}`)
91
+ if (input.description) lines.push(`- 描述: ${input.description}`)
92
+ if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
93
+ break
94
+ case 'TaskUpdate': {
95
+ lines.push(`**✏️ 更新 #${input.taskId ?? '?'}**`)
96
+ if (input.status) lines.push(`- status → \`${input.status}\``)
97
+ if (input.subject) lines.push(`- subject: ${input.subject}`)
98
+ if (input.description) lines.push(`- description: ${input.description}`)
99
+ if (input.owner) lines.push(`- owner: ${input.owner}`)
100
+ if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
101
+ if (input.addBlocks) lines.push(`- blocks → ${(input.addBlocks).join(', ')}`)
102
+ if (input.addBlockedBy) lines.push(`- blockedBy → ${(input.addBlockedBy).join(', ')}`)
103
+ if (input.metadata) lines.push(`- metadata: \`${JSON.stringify(input.metadata)}\``)
104
+ break
105
+ }
106
+ case 'TaskList': lines.push('**📋 查询当前任务清单**'); break
107
+ case 'TaskGet': lines.push(`**🔍 查询 #${input.taskId ?? '?'}**`); break
108
+ case 'TaskStop': lines.push(`**⏹ 停止 #${input.taskId ?? '?'}**`); break
109
+ case 'TaskOutput': lines.push(`**📤 取 #${input.taskId ?? '?'} 输出**`); break
110
+ case 'TaskDelete': lines.push(`**🗑 删除 #${input.taskId ?? '?'}**`); break
111
+ default:
112
+ lines.push(`**${name}**`)
113
+ lines.push('```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 1000) + '\n```')
114
+ }
115
+ if (output != null) {
116
+ lines.push('')
117
+ lines.push('---')
118
+ lines.push('**结果**')
119
+ lines.push(output.slice(0, 3000))
120
+ }
121
+ return lines.join('\n')
122
+ }
123
+
47
124
  interface MainCardOpts {
48
125
  sessionName: string
49
126
  turn: number
@@ -100,70 +177,79 @@ export function thinkingCollapsedPanel(fullText: string): object {
100
177
  }
101
178
  }
102
179
 
103
- /** Element to insert for each tool call. expandable for big results.
180
+ /** Element to insert for each tool call. Expandable for big results.
104
181
  *
105
182
  * Header is a one-line summary: status + name + summarized input.
106
- * Body holds the full input + (after completion) the full output. */
183
+ * Body holds the full input + (after completion) the full output.
184
+ * `resolvedNote` is an optional one-liner appended below the input —
185
+ * used to surface "✅ 允许 by Alice" inline after a permission decision
186
+ * lands but before the actual tool execution completes. */
107
187
  export function toolCallElement(
108
188
  i: number,
109
189
  name: string,
110
190
  input: any,
111
191
  output: string | null,
112
192
  status: '⏳' | '✅' | '❌' = '⏳',
193
+ resolvedNote?: string,
113
194
  ): object {
114
195
  const summary = summarizeToolInput(name, input)
115
196
  const headerText = summary
116
197
  ? `${status} 🔧 ${name}: ${summary}`
117
198
  : `${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
- : ''
199
+ const isTaskWorkflow = name.startsWith('Task') && name !== 'Task'
200
+ const noteBlock = resolvedNote ? `\n\n${resolvedNote}` : ''
201
+ // Task* gets a narrative body (operation + result), the rest keeps the
202
+ // JSON-input + raw-output split — generic dump is better for unfamiliar
203
+ // tools where users can't predict what fields matter.
204
+ const body = isTaskWorkflow
205
+ ? renderTaskWorkflowBody(name, input, output) + noteBlock
206
+ : '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
207
+ + noteBlock
208
+ + (output != null ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```' : '')
122
209
  return {
123
210
  tag: 'collapsible_panel',
124
211
  element_id: ELEMENTS.tool(i),
125
212
  header: { title: { tag: 'plain_text', content: headerText } },
126
213
  expanded: false,
127
214
  elements: [
128
- { tag: 'markdown', content: inputBlock + outputBlock },
215
+ { tag: 'markdown', content: body },
129
216
  ],
130
217
  }
131
218
  }
132
219
 
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 {}
220
+ /** Same tool panel as `toolCallElement`, but with the 🔐 status and
221
+ * three inline action buttons (allow / allow_always / deny). Expanded
222
+ * by default so the user can read the request without clicking through.
223
+ * This is the "merge into tool panel" UX — the permission decision
224
+ * lives on the same row as the tool call instead of as a separate
225
+ * floating card. */
226
+ export function toolCallPermissionElement(
227
+ i: number,
228
+ name: string,
229
+ input: any,
230
+ requestId: string,
231
+ ): object {
232
+ const summary = summarizeToolInput(name, input)
233
+ const headerText = summary
234
+ ? `🔐 等审批 · ${name}: ${summary}`
235
+ : `🔐 等审批 · ${name}`
236
+ const inputBlock = '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
145
237
  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
- },
238
+ tag: 'collapsible_panel',
239
+ element_id: ELEMENTS.tool(i),
240
+ header: { title: { tag: 'plain_text', content: headerText } },
241
+ expanded: true,
242
+ elements: [
243
+ { tag: 'markdown', content: inputBlock },
244
+ {
245
+ tag: 'column_set',
246
+ columns: [
247
+ permissionButtonColumn('✅ 允许', 'primary', requestId, 'allow'),
248
+ permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
249
+ permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
250
+ ],
251
+ },
252
+ ],
167
253
  }
168
254
  }
169
255
 
@@ -179,64 +265,97 @@ function permissionButtonColumn(label: string, type: string, requestId: string,
179
265
  }
180
266
  }
181
267
 
182
- export function permissionResolvedCard(
183
- toolName: string,
184
- decision: 'allow' | 'allow_always' | 'deny',
185
- user: string,
186
- ): object {
187
- const ok = decision !== 'deny'
188
- const label = decision === 'allow_always' ? '始终允许' : decision === 'allow' ? '已允许' : '已拒绝'
189
- 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
- },
202
- }
203
- }
204
-
205
268
  interface ConsoleOpts {
206
269
  sessionName: string
207
270
  status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
208
271
  model?: string
209
272
  effort?: string
210
- uptime?: string
211
- lastActivity?: string
273
+ /** ms since this ClaudeProcess spawned — formatted to "1h 23m" inside. */
274
+ uptimeMs?: number
275
+ /** Current context-window occupancy estimate (input + cache tokens of
276
+ * the last assistant message). 0 if no turn has completed yet. */
277
+ contextTokens?: number
278
+ /** Window upper bound. Defaults to 1M (claude-opus-4-7[1m]). */
279
+ contextLimit?: number
280
+ cumStats?: { tokens: number; costUsd: number; turns: number }
281
+ lastTurn?: { tokens: number; costUsd: number; durationMs: number }
282
+ sessionId?: string | null
212
283
  hasSession: boolean
213
284
  }
214
285
 
286
+ /** Format token counts as a compact human-readable string: 1,234 → 1.2K. */
287
+ function fmtTokens(n: number): string {
288
+ if (!n) return '0'
289
+ if (n < 1000) return String(n)
290
+ if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '') + 'K'
291
+ return (n / 1_000_000).toFixed(2).replace(/\.?0+$/, '') + 'M'
292
+ }
293
+
294
+ function fmtCost(c: number): string {
295
+ if (c < 0.01) return `$${c.toFixed(4)}`
296
+ return `$${c.toFixed(2)}`
297
+ }
298
+
299
+ function fmtDurationMs(ms: number): string {
300
+ if (ms < 1000) return `${ms}ms`
301
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
302
+ const m = Math.floor(ms / 60_000)
303
+ const s = Math.round((ms % 60_000) / 1000)
304
+ return `${m}m ${s}s`
305
+ }
306
+
307
+ function fmtUptime(ms: number): string {
308
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`
309
+ const totalMin = Math.floor(ms / 60_000)
310
+ if (totalMin < 60) return `${totalMin}m`
311
+ const h = Math.floor(totalMin / 60)
312
+ const m = totalMin % 60
313
+ if (h < 24) return `${h}h ${m}m`
314
+ const d = Math.floor(h / 24)
315
+ return `${d}d ${h % 24}h`
316
+ }
317
+
215
318
  export function consoleCard(opts: ConsoleOpts): object {
216
- const { sessionName, status, model, effort, uptime, lastActivity, hasSession } = opts
319
+ const {
320
+ sessionName, status, model, effort, uptimeMs,
321
+ contextTokens, contextLimit, cumStats, lastTurn, sessionId, hasSession,
322
+ } = opts
217
323
  const statusEmoji = {
218
324
  idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
219
325
  starting: '🚀 启动中', stopped: '⚪ 未运行',
220
326
  }[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
327
 
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
- ]
328
+ const modelLine = model ? `${model}${effort ? `/${effort}` : ''}` : null
329
+ const headerLine = [statusEmoji, modelLine].filter(Boolean).join(' · ')
330
+
331
+ // Build the metric lines that make this panel useful. Each is "label
332
+ // <tab> value" rendered as plain markdown — keeps it readable inside
333
+ // the small Feishu card area without competing with the button row.
334
+ const lines: string[] = [headerLine]
335
+
336
+ if (contextTokens != null) {
337
+ const limit = contextLimit ?? 1_000_000
338
+ const pct = limit > 0 ? Math.round((contextTokens / limit) * 100) : 0
339
+ lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} / ${fmtTokens(limit)} (${pct}%)`)
340
+ }
341
+ if (uptimeMs != null && uptimeMs > 0) {
342
+ lines.push(`**⏱ 已运行** ${fmtUptime(uptimeMs)}`)
343
+ }
344
+ if (cumStats && (cumStats.tokens > 0 || cumStats.costUsd > 0 || cumStats.turns > 0)) {
345
+ lines.push(`**💬 累计** ${fmtTokens(cumStats.tokens)} tokens · ${fmtCost(cumStats.costUsd)} · ${cumStats.turns} turn${cumStats.turns === 1 ? '' : 's'}`)
346
+ }
347
+ if (lastTurn) {
348
+ lines.push(`**🔄 上一轮** +${fmtTokens(lastTurn.tokens)} · ${fmtCost(lastTurn.costUsd)} · ${fmtDurationMs(lastTurn.durationMs)}`)
349
+ }
350
+ if (sessionId) {
351
+ lines.push(`**🆔 session** \`${sessionId.slice(0, 8)}…\``)
352
+ }
353
+
354
+ void hasSession // accept the field for caller compat; lifecycle is now
355
+ // driven by bare-word commands (`hi` / `kill` / `restart` / `clear`),
356
+ // not buttons — keeps the panel pure-readout and one-handed mobile-
357
+ // friendly. The 'refresh' / 'ls' actions stay in onConsoleAction for
358
+ // backward compat with any still-floating older cards in chat history.
240
359
 
241
360
  const template = status === 'working' ? 'blue'
242
361
  : status === 'awaiting_permission' ? 'orange'
@@ -252,19 +371,7 @@ export function consoleCard(opts: ConsoleOpts): object {
252
371
  },
253
372
  body: {
254
373
  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
- },
374
+ { tag: 'markdown', content: lines.join('\n\n') },
268
375
  ],
269
376
  },
270
377
  }
@@ -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,28 @@ 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,
261
311
  },
262
312
  })
263
313
  }
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,7 @@ 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
+ toolByUseId: Map<string, { i: number; name: string; input: any; resolvedNote?: string }>
26
26
  assistantSegmentCount: number
27
27
  currentAssistantSegmentId: string | null
28
28
  currentAssistantText: string
@@ -41,23 +41,53 @@ export interface SessionOpts {
41
41
  permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
42
42
  }
43
43
 
44
+ /** Per-turn delta extracted from the SDK `result` message — feeds the
45
+ * "上一轮" line in the console panel. */
46
+ interface LastTurnDelta {
47
+ tokens: number // input + cache_* + output for that turn
48
+ costUsd: number
49
+ durationMs: number
50
+ inputTokens: number // input + cache_* (excludes output) — context-window estimate
51
+ }
52
+
53
+ /** Cumulative session counters. Reset on full restart (`clear`), preserved
54
+ * across resume — but resumed conversations start counting from the
55
+ * resume point, not the original turn 0; the SDK doesn't replay historical
56
+ * usage. The session_id continuity is preserved separately by the resume
57
+ * map; cumStats represents "since the current ClaudeProcess was spawned". */
58
+ interface CumStats {
59
+ tokens: number
60
+ costUsd: number
61
+ turns: number
62
+ }
63
+
44
64
  export class Session {
45
65
  private proc: ClaudeProcess | null = null
46
66
  private currentTurn: TurnState | null = null
47
- private pendingPermissions = new Map<string, { messageId: string; toolName: string }>()
67
+ private pendingPermissions = new Map<string, { toolUseId: string }>()
48
68
  private turnCounter = 0
49
69
  // Last seen sessionId — preserved across `kill`/`stop` so a later
50
70
  // `restart` can resume the same Claude conversation even after the
51
71
  // child process is gone.
52
72
  private lastSessionId: string | null = null
53
73
  private startedAt: number = 0
74
+ private cumStats: CumStats = { tokens: 0, costUsd: 0, turns: 0 }
75
+ private lastTurnDelta: LastTurnDelta | null = null
54
76
  status: Status = 'stopped'
55
77
 
56
78
  constructor(
57
79
  public readonly sessionName: string,
58
80
  public readonly chatId: string,
59
81
  private opts: SessionOpts = {},
60
- ) {}
82
+ ) {
83
+ // Restore last-known claude session_id from disk so a daemon restart
84
+ // (systemctl, crash, watchdog) doesn't strand the user with a fresh
85
+ // conversation when they next type `restart`.
86
+ this.lastSessionId = feishu.getSessionResume(sessionName)
87
+ if (this.lastSessionId) {
88
+ log(`session "${sessionName}": restored lastSessionId=${this.lastSessionId.slice(0, 8)}…`)
89
+ }
90
+ }
61
91
 
62
92
  get workDir(): string { return join(feishu.PROJECTS_ROOT, this.sessionName) }
63
93
  isRunning(): boolean { return !!this.proc && this.proc.isAlive() }
@@ -82,7 +112,7 @@ export class Session {
82
112
  this.proc = new ClaudeProcess({
83
113
  workDir: this.workDir,
84
114
  effort: 'max',
85
- permissionMode: this.opts.permissionMode ?? 'default',
115
+ permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
86
116
  appendSystemPrompt: CHANNEL_INSTRUCTIONS,
87
117
  })
88
118
  this.wireProc(this.proc)
@@ -122,7 +152,7 @@ export class Session {
122
152
  this.proc = new ClaudeProcess({
123
153
  workDir: this.workDir,
124
154
  effort: 'max',
125
- permissionMode: 'default',
155
+ permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
126
156
  resumeSessionId: prevSessionId,
127
157
  appendSystemPrompt: CHANNEL_INSTRUCTIONS,
128
158
  })
@@ -132,6 +162,16 @@ export class Session {
132
162
  this.startedAt = Date.now()
133
163
  await feishu.sendText(this.chatId, `🔁 已重启并恢复 session=${prevSessionId.slice(0, 8)}…`)
134
164
  } else {
165
+ // Resume requested but no prior session_id on file — surface it
166
+ // explicitly rather than silently fresh-starting (the old behavior
167
+ // hid the daemon-restart sessionId-loss bug for months).
168
+ if (resume) {
169
+ await feishu.sendText(this.chatId, '⚠️ 没有可恢复的上一会话,将以新会话启动')
170
+ }
171
+ // Fresh conversation — drop cumulative stats so the next `hi` shows
172
+ // zeroed counters instead of bleeding numbers from the prior chat.
173
+ this.cumStats = { tokens: 0, costUsd: 0, turns: 0 }
174
+ this.lastTurnDelta = null
135
175
  await this.start()
136
176
  }
137
177
  }
@@ -168,14 +208,27 @@ export class Session {
168
208
  }
169
209
 
170
210
  async showConsole(): Promise<void> {
171
- const uptime = this.startedAt
172
- ? `${Math.round((Date.now() - this.startedAt) / 1000)}s`
173
- : undefined
211
+ const uptimeMs = this.startedAt ? (Date.now() - this.startedAt) : undefined
212
+ // Strip the `claude-` prefix so the panel stays compact: `opus-4-7`
213
+ // reads better than `claude-opus-4-7` in the small status header.
214
+ const rawModel = this.proc?.lastModel ?? null
215
+ const model = rawModel ? rawModel.replace(/^claude-/, '') : undefined
174
216
  const card = cards.consoleCard({
175
217
  sessionName: this.sessionName,
176
218
  status: this.status,
219
+ model,
177
220
  effort: 'max',
178
- uptime,
221
+ uptimeMs,
222
+ contextTokens: this.currentContextTokens(),
223
+ cumStats: this.cumStats,
224
+ lastTurn: this.lastTurnDelta
225
+ ? {
226
+ tokens: this.lastTurnDelta.tokens,
227
+ costUsd: this.lastTurnDelta.costUsd,
228
+ durationMs: this.lastTurnDelta.durationMs,
229
+ }
230
+ : undefined,
231
+ sessionId: this.proc?.sessionId ?? this.lastSessionId,
179
232
  hasSession: this.isRunning(),
180
233
  })
181
234
  await feishu.sendCard(this.chatId, card)
@@ -212,8 +265,21 @@ export class Session {
212
265
  if (!pending) { log(`session "${this.sessionName}": stray permission ${requestId}`); return }
213
266
  this.pendingPermissions.delete(requestId)
214
267
 
215
- const resolved = cards.permissionResolvedCard(pending.toolName, decision, user)
216
- await feishu.patchCardMessage(pending.messageId, resolved)
268
+ // Update the tool element in the main turn card in place — the
269
+ // permission decision lives on the same row as the tool call.
270
+ const turn = this.currentTurn
271
+ const meta = turn?.toolByUseId.get(pending.toolUseId)
272
+ if (turn && meta) {
273
+ if (decision === 'deny') {
274
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌')
275
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
276
+ } else {
277
+ const label = decision === 'allow_always' ? '始终允许' : '已允许'
278
+ meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
279
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote)
280
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
281
+ }
282
+ }
217
283
 
218
284
  const claudeDecision = decision === 'deny' ? 'deny' : 'allow'
219
285
  this.proc?.sendPermissionResponse(requestId, claudeDecision)
@@ -235,12 +301,22 @@ export class Session {
235
301
  case 'stop': await this.stop(); break
236
302
  case 'start': await this.start(); break
237
303
  case 'resume': await this.restart(true); break
304
+ case 'refresh': await this.showConsole(); break
238
305
  case 'ls': await feishu.sendText(this.chatId, `📁 ${this.workDir}`); break
239
306
  }
240
307
  }
241
308
 
242
309
  // ── Wiring Claude → Feishu ─────────────────────────────────────────
243
310
  private wireProc(p: ClaudeProcess): void {
311
+ p.on('init', () => {
312
+ // Persist the freshly assigned session_id so a later daemon
313
+ // restart can resume this conversation. Skip if unchanged to
314
+ // avoid hammering the file on every init for resumed sessions.
315
+ if (p.sessionId && p.sessionId !== this.lastSessionId) {
316
+ this.lastSessionId = p.sessionId
317
+ feishu.bindSessionResume(this.sessionName, p.sessionId)
318
+ }
319
+ })
244
320
  p.on('assistant_text', ({ text }: { text: string }) => {
245
321
  this.appendAssistant(text)
246
322
  })
@@ -254,13 +330,14 @@ export class Session {
254
330
  this.completeTool(tool_use_id, content, is_error)
255
331
  })
256
332
  p.on('can_use_tool', (req: CanUseToolRequest) => {
257
- void this.renderPermission(req)
333
+ this.renderPermission(req)
258
334
  })
259
335
  p.on('hook_callback', (req: HookCallbackRequest) => {
260
336
  // No hooks registered → fail-safe ack.
261
337
  this.proc?.sendHookResponse(req.request_id, {})
262
338
  })
263
339
  p.on('result', () => {
340
+ this.accumulateResultStats()
264
341
  void this.closeTurnCard()
265
342
  this.status = 'idle'
266
343
  })
@@ -275,6 +352,37 @@ export class Session {
275
352
  })
276
353
  }
277
354
 
355
+ /** Pull per-turn numbers off `proc.lastResult` (set by ClaudeProcess when
356
+ * the `result` message landed) and roll them into cumStats + the
357
+ * "上一轮" delta. Called exactly once per result event, right before
358
+ * closeTurnCard. */
359
+ private accumulateResultStats(): void {
360
+ const r = this.proc?.lastResult
361
+ if (!r) return
362
+ const u = r.usage ?? {}
363
+ const inputTokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
364
+ const outputTokens = u.output_tokens ?? 0
365
+ const tokens = inputTokens + outputTokens
366
+ const costUsd = r.cost_usd ?? 0
367
+ const durationMs = r.duration_ms ?? 0
368
+ this.cumStats.tokens += tokens
369
+ this.cumStats.costUsd += costUsd
370
+ this.cumStats.turns += r.num_turns ?? 1
371
+ this.lastTurnDelta = { tokens, costUsd, durationMs, inputTokens }
372
+ }
373
+
374
+ /** Current context-window occupancy estimate — uses the most recent
375
+ * assistant `usage` (input + caches), since each assistant reply replays
376
+ * the full conversation. Falls back to the last-turn delta when no
377
+ * assistant message has streamed yet this process. */
378
+ private currentContextTokens(): number {
379
+ const u = this.proc?.lastUsage as ClaudeUsage | null | undefined
380
+ if (u) {
381
+ return (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
382
+ }
383
+ return this.lastTurnDelta?.inputTokens ?? 0
384
+ }
385
+
278
386
  private async openTurnCard(userText: string): Promise<void> {
279
387
  const turn = ++this.turnCounter
280
388
  const card = cards.mainConversationCard({
@@ -366,26 +474,47 @@ export class Session {
366
474
  : Array.isArray(content)
367
475
  ? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
368
476
  : JSON.stringify(content)
369
- const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅')
477
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote)
370
478
  void cardkit.replaceElement(this.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
371
479
  }
372
480
 
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')
481
+ /** Merge the permission ask into the existing tool element in the
482
+ * current turn card. The user sees one continuous timeline: ⏳ pending
483
+ * 🔐 awaiting approval (with buttons) → ⏳ allowed / ❌ denied → ✅
484
+ * with output. No floating orange card.
485
+ *
486
+ * `tool_use` is emitted as part of the assistant message and lands on
487
+ * our `addTool` handler BEFORE the SDK's `can_use_tool` control_request
488
+ * arrives — so by the time we get here, `toolByUseId` already has the
489
+ * entry we need to replace.
490
+ *
491
+ * Edge cases (no current turn / missing tool_use_id / unknown id) are
492
+ * surfaced loudly and auto-denied. We don't fall back to a standalone
493
+ * card — per the project's no-fallbacks rule, hidden anomalies are
494
+ * worse than visible deny errors. */
495
+ private renderPermission(req: CanUseToolRequest): void {
496
+ const turn = this.currentTurn
497
+ if (!turn) {
498
+ log(`session "${this.sessionName}": can_use_tool with no current turn — auto-deny req=${req.request_id}`)
499
+ this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no active turn' })
500
+ return
501
+ }
502
+ const toolUseId = req.tool_use_id
503
+ if (!toolUseId) {
504
+ log(`session "${this.sessionName}": can_use_tool without tool_use_id — auto-deny req=${req.request_id}`)
505
+ this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no tool_use_id' })
386
506
  return
387
507
  }
388
- this.pendingPermissions.set(req.request_id, { messageId, toolName: req.tool_name })
508
+ const meta = turn.toolByUseId.get(toolUseId)
509
+ if (!meta) {
510
+ log(`session "${this.sessionName}": can_use_tool for unknown tool_use_id=${toolUseId} — auto-deny req=${req.request_id}`)
511
+ this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
512
+ return
513
+ }
514
+ this.status = 'awaiting_permission'
515
+ this.pendingPermissions.set(req.request_id, { toolUseId })
516
+ const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
517
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
389
518
  }
390
519
 
391
520
  private async closeTurnCard(suffix?: string): Promise<void> {