@leviyuan/lodestar 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 leviyuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/leviyuan/lodestar/main/promo.jpg" alt="夜航星 Lodestar" width="100%">
3
+ </p>
4
+
5
+ # 夜航星 (Lodestar)
6
+
7
+ **在你最熟悉的飞书群里,开一段不熄灯的 Claude Code 会话。**
8
+
9
+ ## 项目哲学
10
+
11
+ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、判断和品味,每一样都被乘以一个你以前不敢想的系数。最终走多远,取决于被放大的你有多强。
12
+
13
+ 夜航星让这件事真正发生:在你思考的地方接住想法,在你转身之后继续把它推向终点。一个群,一个项目,一段不熄灯的对话。你醒着它在听,你睡了它还在跑。
14
+
15
+ ## 怎么用
16
+
17
+ 每个飞书群对应一个 Claude 会话。**群名 = `~/` 下的项目目录名**。
18
+
19
+ - 在群里发任意文字 — Claude 接管这一轮,回复以**流式打字机**实时渲染在一张飞书卡片里。
20
+ - 思考过程、每一次工具调用都在卡片里被收纳为**可展开折叠面板**:折起来是概述,展开是详情。你随时能审阅它在做什么。
21
+ - 需要授权的操作(执行命令、修改文件……)会单独弹一张橙色**权限卡片**,你在群里点 `允许` / `始终允许` / `拒绝` 就行。
22
+ - **图片、文件双向互传**:用户发到群里的图/文件,Claude 通过消息里的 `[file: /abs/path]` 提示就能读;Claude 想把文件发回来,在回复任意位置写 `[[send: /abs/path]]`,标记会被剥离,文件以独立消息出现在群里。出站路径限制在该会话的工作目录、`/tmp/lodestar-*` 与 inbox 之内,`/etc`、`~/.ssh`、`~/.config` 等敏感目录被白名单拒绝。
23
+ - 一轮跑完,卡片合上、可转发;下一句话开新一轮。
24
+
25
+ ### 文本控制指令
26
+
27
+ 直接发这四个**裸词**(不需要斜杠,不区分大小写),daemon 拦截、不转发给 Claude:
28
+
29
+ | 指令 | 行为 |
30
+ | --- | --- |
31
+ | `hi` | 未运行时启动;运行中弹一张**控制台卡片**(状态行 + 中断/clear/终止/ls 按钮) |
32
+ | `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
33
+ | `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
34
+ | `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
35
+
36
+ > 这四个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
37
+
38
+ 整个对话在群里、在手机上、在桌面上完整发生。**离开终端,但不离开 Claude Code。**
39
+
40
+ ## 安装
41
+
42
+ ### 1. 准备
43
+
44
+ - 一台能常跑后台进程的机器(自家服务器或闲置主机)
45
+ - [Bun](https://bun.sh) 运行时
46
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 已登录 Anthropic 账号 (`claude auth login`)
47
+ - 一个飞书自建应用 (`cli_xxx`),开通:
48
+ - `im:message:send_as_bot` / `im:message` / `im:chat:readonly` / `im:resource`
49
+ - `cardkit:card:read` `cardkit:card:write`
50
+ `cardkit:card.element:read` `cardkit:card.element:write`
51
+ `cardkit:card.settings:read` `cardkit:card.settings:write`
52
+
53
+ ### 2. 配置
54
+
55
+ 把凭据写到 `~/.config/lodestar/config.toml`:
56
+
57
+ ```toml
58
+ [feishu]
59
+ app_id = "cli_xxxxxxxxxxxxxxxx"
60
+ app_secret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
61
+
62
+ [runtime]
63
+ projects_root = "~/" # 可选,新建群对应的项目目录会落到这里
64
+ ```
65
+
66
+ 也支持 `LODESTAR_CONFIG=/abs/path.toml` 或 `XDG_CONFIG_HOME` 覆盖。
67
+
68
+ ### 3. 启动
69
+
70
+ ```bash
71
+ git clone https://github.com/leviyuan/lodestar.git ~/lodestar
72
+ cd ~/lodestar
73
+ bun install
74
+ bun daemon.ts
75
+ ```
76
+
77
+ 把机器人拉进任意飞书群,发一条消息——Claude 就上线了。
78
+
79
+ > **小贴士**:群名首次出现时,daemon 会自动在 `~/{群名}/` 创建项目目录并 `git init`。换句话说,开新群 = 开新项目。
80
+
81
+ ### 4. 守护进程(可选)
82
+
83
+ 要让 daemon 7×24 跑,最简单的方法是配一个 `systemd --user` 单元:
84
+
85
+ ```ini
86
+ [Unit]
87
+ Description=Lodestar daemon
88
+ After=network-online.target
89
+
90
+ [Service]
91
+ Type=simple
92
+ ExecStart=/home/USER/.bun/bin/bun /home/USER/lodestar/daemon.ts
93
+ Restart=always
94
+ RestartSec=3
95
+
96
+ [Install]
97
+ WantedBy=default.target
98
+ ```
99
+
100
+ `systemctl --user enable --now lodestar`。
101
+
102
+ ## 许可
103
+
104
+ [MIT](LICENSE)
package/daemon.ts ADDED
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Lodestar 2.0 daemon — Feishu (Lark) ↔ Claude Code headless bridge.
4
+ *
5
+ * Listens on Lark WebSocket for inbound messages and card-action
6
+ * callbacks, routes each to a per-chat Session that owns a headless
7
+ * `claude` subprocess and a streaming Card Kit card.
8
+ *
9
+ * Run: bun daemon.ts
10
+ * Stop: SIGTERM
11
+ */
12
+
13
+ import * as lark from '@larksuiteoapi/node-sdk'
14
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
15
+ import { dirname } from 'node:path'
16
+ import { Session } from './src/session'
17
+ import * as feishu from './src/feishu'
18
+ import { config } from './src/config'
19
+ import { log } from './src/log'
20
+ import { PID_FILE } from './src/paths'
21
+
22
+ // ── PID guard ───────────────────────────────────────────────────────────
23
+ try {
24
+ const existing = readFileSync(PID_FILE, 'utf8').trim()
25
+ try {
26
+ process.kill(Number(existing), 0)
27
+ console.error(`lodestar-daemon: already running (pid ${existing})`)
28
+ process.exit(1)
29
+ } catch {}
30
+ } catch {}
31
+
32
+ mkdirSync(dirname(PID_FILE), { recursive: true })
33
+ writeFileSync(PID_FILE, String(process.pid))
34
+
35
+ const cleanup = () => { try { unlinkSync(PID_FILE) } catch {} }
36
+ process.on('exit', cleanup)
37
+ process.on('SIGTERM', () => { log('SIGTERM'); cleanup(); process.exit(0) })
38
+ process.on('SIGINT', () => { log('SIGINT'); cleanup(); process.exit(0) })
39
+ process.on('unhandledRejection', e => log(`unhandledRejection: ${e}`))
40
+ process.on('uncaughtException', e => log(`uncaughtException: ${e}`))
41
+
42
+ // ── Session registry ────────────────────────────────────────────────────
43
+ const sessions = new Map<string, Session>() // key = chatId
44
+
45
+ function sessionFor(chatId: string, sessionName: string): Session {
46
+ let s = sessions.get(chatId)
47
+ if (!s) {
48
+ s = new Session(sessionName, chatId)
49
+ sessions.set(chatId, s)
50
+ }
51
+ return s
52
+ }
53
+
54
+ // ── Inbound message handler ─────────────────────────────────────────────
55
+ const STALE_THRESHOLD_MS = 10_000
56
+ const seenMessageIds = new Set<string>()
57
+
58
+ async function handleMessage(data: any): Promise<void> {
59
+ const message = data?.message
60
+ if (!message) return
61
+
62
+ const msgId = message.message_id as string | undefined
63
+ if (msgId && seenMessageIds.has(msgId)) return
64
+ if (msgId) {
65
+ seenMessageIds.add(msgId)
66
+ if (seenMessageIds.size > 200) {
67
+ const arr = [...seenMessageIds]
68
+ seenMessageIds.clear()
69
+ for (const id of arr.slice(-100)) seenMessageIds.add(id)
70
+ }
71
+ }
72
+
73
+ // Drop replays of stale messages (Lark redelivers unacked events on reconnect).
74
+ const createTime = Number(message.create_time ?? 0)
75
+ if (createTime > 0 && Date.now() - createTime > STALE_THRESHOLD_MS) {
76
+ log(`drop stale message ${msgId} age=${Math.round((Date.now() - createTime) / 1000)}s`)
77
+ if (msgId) void feishu.addReaction(msgId, 'CrossMark')
78
+ return
79
+ }
80
+ if (msgId) void feishu.addReaction(msgId, 'OK')
81
+
82
+ const chatId = message.chat_id as string
83
+ let groupName = feishu.chatNameCache.get(chatId)
84
+ if (!groupName) {
85
+ await feishu.refreshChatList()
86
+ groupName = feishu.chatNameCache.get(chatId)
87
+ }
88
+ if (!groupName) {
89
+ log(`unknown chat ${chatId}, dropping message`)
90
+ await feishu.sendText(chatId, '❌ 无法识别群名,请确认机器人已加入并稍后重试')
91
+ return
92
+ }
93
+ const sessionName = feishu.sanitizeSessionName(groupName)
94
+ feishu.bindSessionToChat(sessionName, chatId)
95
+ const session = sessionFor(chatId, sessionName)
96
+
97
+ let contentObj: any = {}
98
+ try { contentObj = JSON.parse(message.content ?? '{}') } catch {}
99
+ const msgType = message.message_type as string
100
+ let text = (msgType === 'text' ? contentObj.text ?? '' : '').trim()
101
+
102
+ // Text-only control commands — intercept before any work that would
103
+ // forward to Claude (download / spawn / interrupt). Exact match,
104
+ // case-insensitive: `hi` `kill` `restart` `clear`. Bare words are
105
+ // reserved globally by user request — typing "hi" as a literal
106
+ // greeting will trigger the dashboard, not reach Claude.
107
+ if (msgType === 'text' && text) {
108
+ if (await session.runCommand(text)) return
109
+ }
110
+
111
+ let filePath: string | undefined
112
+ if (msgType === 'image' && contentObj.image_key) {
113
+ filePath = await feishu.downloadAttachment(message.message_id, contentObj.image_key, 'image')
114
+ } else if (msgType === 'file' && contentObj.file_key) {
115
+ filePath = await feishu.downloadAttachment(message.message_id, contentObj.file_key, 'file', contentObj.file_name)
116
+ if (!text) text = `(file: ${contentObj.file_name})`
117
+ }
118
+
119
+ if (!text && !filePath) return
120
+ await session.onUserMessage(text || '(empty)', filePath ? [filePath] : [])
121
+ }
122
+
123
+ // ── Card action handler ────────────────────────────────────────────────
124
+ async function handleCardAction(data: any): Promise<any> {
125
+ const action = data?.action
126
+ const value = action?.value
127
+ if (!value?.kind) return
128
+ const chatId = data?.context?.open_chat_id ?? ''
129
+ const userId = data?.operator?.open_id ?? ''
130
+ const session = sessions.get(chatId)
131
+ if (!session) return { toast: { type: 'error', content: '会话不存在,请先发消息启动' } }
132
+
133
+ switch (value.kind) {
134
+ case 'permission':
135
+ await session.onPermissionDecision(value.request_id, value.decision, userId)
136
+ return { toast: { type: value.decision === 'deny' ? 'error' : 'success', content: '已处理' } }
137
+ case 'console':
138
+ await session.onConsoleAction(value.action)
139
+ return { toast: { type: 'info', content: value.action } }
140
+ case 'menu':
141
+ await session.onUserMessage(`(menu choice ${value.choice + 1})`)
142
+ return { toast: { type: 'success', content: 'OK' } }
143
+ }
144
+ return { toast: { type: 'info', content: 'unknown action' } }
145
+ }
146
+
147
+ // ── WebSocket boot ─────────────────────────────────────────────────────
148
+ function fmt(m: any[]): string {
149
+ return m.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')
150
+ }
151
+
152
+ async function boot(): Promise<void> {
153
+ log(`lodestar-daemon: pid ${process.pid} starting`)
154
+ feishu.loadSessionChatMap()
155
+ await feishu.refreshChatList()
156
+ setInterval(() => { void feishu.refreshChatList() }, 5 * 60 * 1000)
157
+
158
+ // Lark WSClient sends pings every ~120s but doesn't verify pongs. On a
159
+ // half-open TCP (NAT idle-kill, network blip) the socket stays OPEN and
160
+ // 'close' never fires — we'd go silently deaf. Stamp every inbound pong
161
+ // and exit(1) after 180s of silence so systemd reconnects us.
162
+ let lastPongAt = Date.now()
163
+ const wsLogger = {
164
+ error: (m: any[]) => log(`[ws-sdk error] ${fmt(m)}`),
165
+ warn: (m: any[]) => log(`[ws-sdk warn] ${fmt(m)}`),
166
+ info: (m: any[]) => log(`[ws-sdk] ${fmt(m)}`),
167
+ debug: (_m: any[]) => { /* drop */ },
168
+ trace: (m: any[]) => {
169
+ if (Array.isArray(m) && m[0] === '[ws]' && m[1] === 'receive pong') {
170
+ lastPongAt = Date.now()
171
+ }
172
+ },
173
+ }
174
+ setInterval(() => {
175
+ const idle = Date.now() - lastPongAt
176
+ if (idle > 180_000) {
177
+ log(`[watchdog] no WS pong for ${Math.round(idle / 1000)}s — exit for systemd restart`)
178
+ process.exit(1)
179
+ }
180
+ }, 30_000)
181
+
182
+ const ws = new lark.WSClient({
183
+ appId: config.feishu.app_id,
184
+ appSecret: config.feishu.app_secret,
185
+ loggerLevel: lark.LoggerLevel.trace,
186
+ logger: wsLogger,
187
+ })
188
+ const dispatcher = new lark.EventDispatcher({})
189
+ dispatcher.register({
190
+ 'im.message.receive_v1': async (d: any) => {
191
+ try { await handleMessage(d) } catch (e) { log(`handleMessage: ${e}`) }
192
+ },
193
+ })
194
+ dispatcher.register({
195
+ 'card.action.trigger': async (d: any) => {
196
+ try { return await handleCardAction(d) } catch (e) { log(`handleCardAction: ${e}`) }
197
+ },
198
+ })
199
+ ws.start({ eventDispatcher: dispatcher })
200
+ log(`lodestar-daemon: WS started, watching ${feishu.chatNameCache.size} groups`)
201
+ }
202
+
203
+ boot().catch(e => { log(`boot fatal: ${e}`); process.exit(1) })
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@leviyuan/lodestar",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Lodestar (夜航星) — IM-native frontend for Claude Code via Feishu Card Kit streaming",
8
+ "type": "module",
9
+ "license": "MIT",
10
+ "author": "leviyuan",
11
+ "homepage": "https://github.com/leviyuan/lodestar#readme",
12
+ "bugs": "https://github.com/leviyuan/lodestar/issues",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/leviyuan/lodestar.git"
16
+ },
17
+ "keywords": [
18
+ "feishu",
19
+ "lark",
20
+ "claude-code",
21
+ "claude",
22
+ "ai",
23
+ "agent",
24
+ "im",
25
+ "chat",
26
+ "card-kit",
27
+ "streaming",
28
+ "lodestar"
29
+ ],
30
+ "scripts": {
31
+ "start": "bun daemon.ts"
32
+ },
33
+ "bin": {
34
+ "lodestar-daemon": "./daemon.ts"
35
+ },
36
+ "files": [
37
+ "daemon.ts",
38
+ "src/",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "engines": {
43
+ "bun": ">=1.0.0"
44
+ },
45
+ "dependencies": {
46
+ "@larksuiteoapi/node-sdk": "^1.44.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/bun": "latest",
50
+ "typescript": "^5.5.0"
51
+ }
52
+ }
package/src/cardkit.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Feishu Card Kit v1 wrapper.
3
+ *
4
+ * Endpoints used (base = https://open.feishu.cn/open-apis/cardkit/v1):
5
+ * POST /cards/id_convert message_id → card_id
6
+ * POST /cards create a card entity
7
+ * PUT /cards/:card_id/elements/:element_id/content stream text (typewriter)
8
+ * POST /cards/:card_id/elements add element
9
+ * PUT /cards/:card_id/elements/:element_id replace element
10
+ * DELETE /cards/:card_id/elements/:element_id remove element
11
+ * PATCH /cards/:card_id/settings toggle streaming_mode etc.
12
+ *
13
+ * Per-card invariants enforced here:
14
+ * - `sequence` is monotonically increasing per card_id
15
+ * - all writes for a card are serialized through a Promise queue
16
+ * - text-streaming PUTs are batched on a 120ms / 32-char heuristic to
17
+ * stay well under cardkit's per-card rate ceiling
18
+ */
19
+
20
+ import { getTenantToken } from './feishu'
21
+ import { log } from './log'
22
+
23
+ const BASE = 'https://open.feishu.cn/open-apis/cardkit/v1'
24
+
25
+ const FLUSH_INTERVAL_MS = 120
26
+ const FLUSH_MIN_DELTA = 32
27
+
28
+ interface CardState {
29
+ sequence: number
30
+ queue: Promise<void>
31
+ buffer: Map<string, string> // element_id → latest full text
32
+ lastSent: Map<string, string> // element_id → text last actually PUT
33
+ flushTimer: ReturnType<typeof setTimeout> | null
34
+ }
35
+
36
+ const cards = new Map<string, CardState>()
37
+
38
+ function state(cardId: string): CardState {
39
+ let s = cards.get(cardId)
40
+ if (!s) {
41
+ s = {
42
+ sequence: 0,
43
+ queue: Promise.resolve(),
44
+ buffer: new Map(),
45
+ lastSent: new Map(),
46
+ flushTimer: null,
47
+ }
48
+ cards.set(cardId, s)
49
+ }
50
+ return s
51
+ }
52
+
53
+ function nextSeq(cardId: string): number {
54
+ const s = state(cardId)
55
+ s.sequence += 1
56
+ return s.sequence
57
+ }
58
+
59
+ async function call(method: string, path: string, body?: object): Promise<any> {
60
+ const token = await getTenantToken()
61
+ const res = await fetch(`${BASE}${path}`, {
62
+ method,
63
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
64
+ ...(body ? { body: JSON.stringify(body) } : {}),
65
+ })
66
+ const json = await res.json() as any
67
+ if (json?.code && json.code !== 0) {
68
+ throw new Error(`cardkit ${method} ${path}: code=${json.code} msg=${json.msg}`)
69
+ }
70
+ return json?.data
71
+ }
72
+
73
+ /** Convert a sent interactive message into a card entity. */
74
+ export async function convertMessageToCard(messageId: string): Promise<string> {
75
+ const data = await call('POST', '/cards/id_convert', { message_id: messageId })
76
+ return data.card_id
77
+ }
78
+
79
+ /** Create a card entity from raw schema-2.0 card JSON. */
80
+ export async function createCardEntity(card: object): Promise<string> {
81
+ const data = await call('POST', '/cards', {
82
+ type: 'card_json',
83
+ data: JSON.stringify(card),
84
+ })
85
+ return data.card_id
86
+ }
87
+
88
+ /** PUT element content (full text) — triggers typewriter on prefix-match.
89
+ *
90
+ * NOTE: CardKit rejects empty-string content with code 99992402 ("field
91
+ * validation failed"); we drop empty/whitespace-only writes here so callers
92
+ * can stream naively without per-call empty checks. */
93
+ export function streamText(cardId: string, elementId: string, content: string): Promise<void> {
94
+ if (!content || !content.trim()) return Promise.resolve()
95
+ const s = state(cardId)
96
+ const seq = nextSeq(cardId)
97
+ s.queue = s.queue.then(async () => {
98
+ try {
99
+ await call('PUT', `/cards/${cardId}/elements/${elementId}/content`, {
100
+ content, sequence: seq,
101
+ })
102
+ s.lastSent.set(elementId, content)
103
+ } catch (e) {
104
+ log(`cardkit streamText ${cardId}/${elementId}: ${e}`)
105
+ }
106
+ })
107
+ return s.queue
108
+ }
109
+
110
+ /** Throttled streaming: buffer + auto-flush every FLUSH_INTERVAL_MS or
111
+ * when the buffered delta crosses FLUSH_MIN_DELTA characters. */
112
+ export function streamTextThrottled(cardId: string, elementId: string, fullContent: string): void {
113
+ if (!fullContent || !fullContent.trim()) return
114
+ const s = state(cardId)
115
+ s.buffer.set(elementId, fullContent)
116
+
117
+ const last = s.lastSent.get(elementId) ?? ''
118
+ const delta = fullContent.length - last.length
119
+ if (delta >= FLUSH_MIN_DELTA) {
120
+ flush(cardId).catch(e => log(`cardkit flush(min-delta) ${cardId}: ${e}`))
121
+ return
122
+ }
123
+ if (!s.flushTimer) {
124
+ s.flushTimer = setTimeout(() => {
125
+ flush(cardId).catch(e => log(`cardkit flush(timer) ${cardId}: ${e}`))
126
+ }, FLUSH_INTERVAL_MS)
127
+ }
128
+ }
129
+
130
+ /** Force an immediate flush of the buffered streams for a card. */
131
+ export async function flush(cardId: string): Promise<void> {
132
+ const s = cards.get(cardId)
133
+ if (!s) return
134
+ if (s.flushTimer) { clearTimeout(s.flushTimer); s.flushTimer = null }
135
+ const pending = [...s.buffer.entries()]
136
+ s.buffer.clear()
137
+ for (const [eid, text] of pending) {
138
+ if (s.lastSent.get(eid) === text) continue
139
+ await streamText(cardId, eid, text)
140
+ }
141
+ }
142
+
143
+ /** Add a new element to the card body or relative to a sibling. */
144
+ export function addElement(
145
+ cardId: string,
146
+ element: object,
147
+ opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
148
+ ): Promise<void> {
149
+ const s = state(cardId)
150
+ const seq = nextSeq(cardId)
151
+ s.queue = s.queue.then(async () => {
152
+ try {
153
+ await call('POST', `/cards/${cardId}/elements`, {
154
+ type: opts.type ?? 'append',
155
+ ...(opts.targetElementId ? { target_element_id: opts.targetElementId } : {}),
156
+ elements: JSON.stringify([element]),
157
+ sequence: seq,
158
+ })
159
+ } catch (e) { log(`cardkit addElement ${cardId}: ${e}`) }
160
+ })
161
+ return s.queue
162
+ }
163
+
164
+ /** Replace an entire element (used to swap a tool placeholder with its result). */
165
+ export function replaceElement(cardId: string, elementId: string, element: object): Promise<void> {
166
+ const s = state(cardId)
167
+ const seq = nextSeq(cardId)
168
+ s.queue = s.queue.then(async () => {
169
+ try {
170
+ await call('PUT', `/cards/${cardId}/elements/${elementId}`, {
171
+ element: JSON.stringify(element),
172
+ sequence: seq,
173
+ })
174
+ } catch (e) { log(`cardkit replaceElement ${cardId}/${elementId}: ${e}`) }
175
+ })
176
+ return s.queue
177
+ }
178
+
179
+ /** Delete an element by id. */
180
+ export function deleteElement(cardId: string, elementId: string): Promise<void> {
181
+ const s = state(cardId)
182
+ const seq = nextSeq(cardId)
183
+ s.queue = s.queue.then(async () => {
184
+ try {
185
+ await call('DELETE', `/cards/${cardId}/elements/${elementId}`, {
186
+ sequence: seq,
187
+ })
188
+ } catch (e) { log(`cardkit deleteElement ${cardId}/${elementId}: ${e}`) }
189
+ })
190
+ return s.queue
191
+ }
192
+
193
+ /** Patch settings — used to flip streaming_mode off when a turn finishes. */
194
+ export function patchSettings(cardId: string, settings: object): Promise<void> {
195
+ const s = state(cardId)
196
+ const seq = nextSeq(cardId)
197
+ s.queue = s.queue.then(async () => {
198
+ try {
199
+ await call('PATCH', `/cards/${cardId}/settings`, {
200
+ settings: JSON.stringify(settings),
201
+ sequence: seq,
202
+ })
203
+ } catch (e) { log(`cardkit patchSettings ${cardId}: ${e}`) }
204
+ })
205
+ return s.queue
206
+ }
207
+
208
+ /** Drop in-memory bookkeeping for a finished card. */
209
+ export async function dispose(cardId: string): Promise<void> {
210
+ const s = cards.get(cardId)
211
+ if (!s) return
212
+ await flush(cardId)
213
+ await s.queue
214
+ cards.delete(cardId)
215
+ }