kafox-cli 0.9.8 → 0.10.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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Servidor HTTP do kafox mobile (node:http, sem dependencia).
3
+ *
4
+ * Rotas:
5
+ * GET / -> pagina do celular (view.js)
6
+ * GET /events -> SSE com os eventos da sessao (?since=<seq> p/ replay)
7
+ * GET /state -> snapshot inicial (exige token)
8
+ * POST /pair -> troca pair_code por session_token
9
+ * POST /prompt -> enfileira um turno { text }
10
+ * POST /approve -> resolve aprovacao { id, decision }
11
+ * POST /abort -> aborta o turno
12
+ *
13
+ * O Caddy faz o TLS e o reverse_proxy (web.kafox.dev -> 127.0.0.1:porta); aqui
14
+ * so escutamos em loopback. Nada de buffer no SSE: header + flush por evento.
15
+ */
16
+
17
+ const http = require("http");
18
+ const { PairingManager } = require("./auth");
19
+ const { renderPage } = require("./view");
20
+
21
+ const HEARTBEAT_MS = 5_000;
22
+
23
+ function readBody(req, limit = 64 * 1024) {
24
+ return new Promise((resolve, reject) => {
25
+ let data = "";
26
+ req.on("data", (chunk) => {
27
+ data += chunk;
28
+ if (data.length > limit) {
29
+ reject(new Error("corpo grande demais"));
30
+ req.destroy();
31
+ }
32
+ });
33
+ req.on("end", () => resolve(data));
34
+ req.on("error", reject);
35
+ });
36
+ }
37
+
38
+ function json(res, status, body) {
39
+ const payload = JSON.stringify(body);
40
+ res.writeHead(status, {
41
+ "content-type": "application/json; charset=utf-8",
42
+ "content-length": Buffer.byteLength(payload),
43
+ "cache-control": "no-store",
44
+ });
45
+ res.end(payload);
46
+ }
47
+
48
+ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml = renderPage() } = {}) {
49
+ if (!bridge) throw new Error("createMobileServer precisa de um SessionBridge");
50
+
51
+ const sseClients = new Set();
52
+
53
+ function broadcast(event) {
54
+ const frame = `data: ${JSON.stringify(event)}\n\n`;
55
+ for (const client of sseClients) {
56
+ try {
57
+ client.write(frame);
58
+ } catch {
59
+ sseClients.delete(client);
60
+ }
61
+ }
62
+ }
63
+
64
+ bridge.on("event", broadcast);
65
+
66
+ const server = http.createServer(async (req, res) => {
67
+ const url = new URL(req.url, "http://localhost");
68
+ const pathname = url.pathname;
69
+
70
+ // A pagina e o proprio endpoint de pareamento nao exigem token.
71
+ if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
72
+ res.writeHead(200, {
73
+ "content-type": "text/html; charset=utf-8",
74
+ "cache-control": "no-store",
75
+ });
76
+ res.end(pageHtml);
77
+ return;
78
+ }
79
+
80
+ if (req.method === "GET" && pathname === "/health") {
81
+ json(res, 200, { ok: true, busy: bridge.busy, sessionId: bridge.sessionId });
82
+ return;
83
+ }
84
+
85
+ // Favicon: o navegador pede sozinho e nao manda token. Sem isso, ele leva
86
+ // 401 e polui o console. Servimos um "K" brutalist em SVG.
87
+ if (req.method === "GET" && (pathname === "/favicon.ico" || pathname === "/favicon.svg")) {
88
+ const svg =
89
+ "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>" +
90
+ "<rect width='32' height='32' fill='#000'/>" +
91
+ "<text x='6' y='24' font-size='22' fill='#fff' font-family='monospace' font-weight='bold'>K</text></svg>";
92
+ res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "max-age=86400" });
93
+ res.end(svg);
94
+ return;
95
+ }
96
+
97
+ if (req.method === "POST" && pathname === "/pair") {
98
+ let body;
99
+ try {
100
+ body = JSON.parse((await readBody(req)) || "{}");
101
+ } catch {
102
+ return json(res, 400, { error: "json invalido" });
103
+ }
104
+ const code = String(body.code || "").trim().toUpperCase();
105
+ const device = {
106
+ ua: String(req.headers["user-agent"] || "").slice(0, 160),
107
+ ip: String(req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").slice(0, 80),
108
+ name: String(body.device || "celular").slice(0, 60),
109
+ };
110
+ const claim = pairing.claim(code, device);
111
+ if (!claim) return json(res, 401, { error: "codigo invalido ou expirado" });
112
+ return json(res, 200, { token: claim.token, expiresAt: claim.expiresAt, session: bridge.snapshot() });
113
+ }
114
+
115
+ // A partir daqui, exige token de sessao.
116
+ const token = req.headers["x-kafox-token"] || url.searchParams.get("t");
117
+ if (!pairing.validateToken(token)) {
118
+ return json(res, 401, { error: "sem token valido" });
119
+ }
120
+
121
+ if (req.method === "GET" && pathname === "/state") {
122
+ return json(res, 200, bridge.snapshot());
123
+ }
124
+
125
+ // Long-polling: retorna os eventos novos e FECHA a resposta. Diferente do
126
+ // SSE, uma resposta normal com fim e entregue de imediato pelo Cloudflare,
127
+ // que costuma segurar/abortar streams text/event-stream de navegadores.
128
+ if (req.method === "GET" && pathname === "/poll") {
129
+ const since = Number.parseInt(url.searchParams.get("since") || "0", 10) || 0;
130
+ const waitMs = Math.min(Math.max(Number.parseInt(url.searchParams.get("wait") || "15000", 10) || 15000, 0), 25000);
131
+ const existing = bridge.log.since(since);
132
+ if (existing.length) {
133
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
134
+ return res.end(JSON.stringify({ events: existing, seq: bridge.log.seq }));
135
+ }
136
+ let done = false;
137
+ let unsub = null;
138
+ const finish = (events) => {
139
+ if (done) return;
140
+ done = true;
141
+ clearTimeout(timer);
142
+ if (unsub) unsub();
143
+ try {
144
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
145
+ res.end(JSON.stringify({ events, seq: bridge.log.seq }));
146
+ } catch {
147
+ /* cliente sumiu */
148
+ }
149
+ };
150
+ const onEvent = (ev) => finish([ev]);
151
+ const timer = setTimeout(() => finish([]), waitMs);
152
+ timer.unref?.();
153
+ unsub = bridge.log.subscribe(onEvent);
154
+ req.on("close", () => { done = true; clearTimeout(timer); if (unsub) unsub(); });
155
+ return;
156
+ }
157
+
158
+ if (req.method === "GET" && pathname === "/events") {
159
+ const since = Number.parseInt(url.searchParams.get("since") || "0", 10) || 0;
160
+ // Cache-Control com no-transform evita que proxies (Cloudflare/Caddy)
161
+ // reescrevam/segurem o stream. X-Accel-Buffering desliga o buffer do nginx.
162
+ res.writeHead(200, {
163
+ "content-type": "text/event-stream; charset=utf-8",
164
+ "cache-control": "no-store, no-transform",
165
+ connection: "keep-alive",
166
+ // impedem buffering em proxies (Caddy/nginx) — SSE sem isso trava.
167
+ "x-accel-buffering": "no",
168
+ "transfer-encoding": "chunked",
169
+ });
170
+ // Cloudflare (e outros edges) seguram o SSE num buffer antes de comecar a
171
+ // repassar. Preenchemos esse buffer com um comentario de padding logo na
172
+ // abertura (8KB) para forcar o flush e o streaming continuo no navegador.
173
+ res.write(": kafox mobile\n");
174
+ res.write(`:${" ".repeat(8192)}\n\n`);
175
+ // replay do que o cliente perdeu, depois assina o ao vivo.
176
+ for (const event of bridge.log.since(since)) res.write(`data: ${JSON.stringify(event)}\n\n`);
177
+
178
+ sseClients.add(res);
179
+ const heartbeat = setInterval(() => {
180
+ try {
181
+ res.write(": ping\n\n");
182
+ } catch {
183
+ /* tratado no close */
184
+ }
185
+ }, HEARTBEAT_MS);
186
+ heartbeat.unref?.();
187
+
188
+ const cleanup = () => {
189
+ clearInterval(heartbeat);
190
+ sseClients.delete(res);
191
+ };
192
+ req.on("close", cleanup);
193
+ req.on("error", cleanup);
194
+ return;
195
+ }
196
+
197
+ if (req.method === "POST" && pathname === "/prompt") {
198
+ let body;
199
+ try {
200
+ body = JSON.parse((await readBody(req)) || "{}");
201
+ } catch {
202
+ return json(res, 400, { error: "json invalido" });
203
+ }
204
+ const text = String(body.text || "");
205
+ if (!text.trim()) return json(res, 400, { error: "texto vazio" });
206
+ if (bridge.busy) return json(res, 409, { error: "ocupado" });
207
+ pairing.audit("prompt", { chars: text.length });
208
+ // roda em background; o resultado chega pelo SSE.
209
+ bridge.prompt(text).catch(() => {});
210
+ return json(res, 202, { ok: true });
211
+ }
212
+
213
+ if (req.method === "POST" && pathname === "/approve") {
214
+ let body;
215
+ try {
216
+ body = JSON.parse((await readBody(req)) || "{}");
217
+ } catch {
218
+ return json(res, 400, { error: "json invalido" });
219
+ }
220
+ const decision = ["once", "always", "deny"].includes(body.decision) ? body.decision : "deny";
221
+ const ok = bridge.approve(body.id, decision);
222
+ pairing.audit("approve", { decision, id: body.id });
223
+ return json(res, ok ? 200 : 409, { ok });
224
+ }
225
+
226
+ if (req.method === "POST" && pathname === "/abort") {
227
+ pairing.audit("abort", {});
228
+ bridge.abort();
229
+ return json(res, 200, { ok: true });
230
+ }
231
+
232
+ json(res, 404, { error: "nao encontrado" });
233
+ });
234
+
235
+ server.on("close", () => bridge.off("event", broadcast));
236
+
237
+ return { server, pairing, sseClients };
238
+ }
239
+
240
+ /**
241
+ * Descobre quem esta segurando uma porta local (best-effort, sem depender de
242
+ * `lsof`). Usa `ss -tlnp` quando disponivel; devolve [] se nao conseguir.
243
+ */
244
+ function portHolders(port) {
245
+ try {
246
+ const { execSync } = require("child_process");
247
+ const out = execSync(`ss -tlnp 2>/dev/null | grep ':${Number(port)}\\b' || true`, {
248
+ encoding: "utf8",
249
+ timeout: 3000,
250
+ });
251
+ const holders = [];
252
+ for (const line of out.split("\n")) {
253
+ const m = line.match(/users:\(\("([^"]+)",pid=(\d+)/);
254
+ if (m) holders.push({ name: m[1], pid: Number(m[2]) });
255
+ }
256
+ return holders;
257
+ } catch {
258
+ return [];
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Escuta com fallback de porta: tenta `port`, e se estiver ocupada (EADDRINUSE)
264
+ * tenta as proximas `maxTries` portas. Resolve com a porta efetivamente usada.
265
+ *
266
+ * Sem isso, subir dois `kafox mobile` estourava EADDRINUSE e derrubava o CLI.
267
+ */
268
+ function listenWithFallback(server, { port, host = "127.0.0.1", maxTries = 12 } = {}) {
269
+ return new Promise((resolve, reject) => {
270
+ let attempt = 0;
271
+ let current = Number(port);
272
+
273
+ const onError = (error) => {
274
+ if (error.code === "EADDRINUSE" && attempt < maxTries) {
275
+ const holders = portHolders(current);
276
+ const who = holders.length ? ` (ocupada por ${holders.map((h) => `${h.name} pid ${h.pid}`).join(", ")})` : "";
277
+ process.stderr.write(` porta ${current} em uso${who}; tentando ${current + 1}…\n`);
278
+ attempt += 1;
279
+ current += 1;
280
+ server.once("error", onError);
281
+ server.listen(current, host);
282
+ return;
283
+ }
284
+ server.removeListener("error", onError);
285
+ reject(error);
286
+ };
287
+
288
+ server.once("error", onError);
289
+ server.once("listening", () => {
290
+ server.removeListener("error", onError);
291
+ resolve(current);
292
+ });
293
+ server.listen(current, host);
294
+ });
295
+ }
296
+
297
+ module.exports = { createMobileServer, listenWithFallback, portHolders };
@@ -0,0 +1,289 @@
1
+ /**
2
+ * TuiBridge: adaptador que faz o servidor mobile falar com a sessao REAL da TUI.
3
+ *
4
+ * Diferente do SessionBridge (que roda o seu proprio runAgent, ver bridge.js),
5
+ * este NAO tem agente proprio: a TUI continua dona do estado e o celular entra
6
+ * como mais um cliente. O bridge so:
7
+ *
8
+ * - ENVIA turnos: prompt(text) -> enfileira no mesmo caminho do teclado
9
+ * (api.submitText), entao PC e celular compartilham a MESMA sessao/contexto.
10
+ * - RESOLVE aprovacoes: approve(id, decision) -> fecha a mesma caixa que o
11
+ * teclado abriria (api.settleApproval).
12
+ * - ABORTA o turno: abort() -> api.abortTurn() (o mesmo do Esc).
13
+ * - ESPELHA o estado: um watcher leve compara `state.messages`/`streaming`
14
+ * com a ultima foto e emite eventos numerados (mesmo contrato do
15
+ * SessionBridge, consumido pelo server.js/SSE e por view.js).
16
+ *
17
+ * Por que polling e nao hooks: a TUI muta `state` direto (sem emitter); o
18
+ * watcher de ~120ms e barato (comparacoes numericas) e nao exige tocar em cada
19
+ * ponto do runLine. Nada aqui limita a taxa do PC — so observa.
20
+ *
21
+ * Interface exigida do `api` (exposta por runTUI):
22
+ * submitText(line) -> Promise enfileira um turno como se viesse do teclado
23
+ * settleApproval(decision) "once" | "always" | "deny"
24
+ * abortTurn() aborta o turno corrente
25
+ */
26
+
27
+ const { EventEmitter } = require("events");
28
+ const { EventLog } = require("./protocol");
29
+
30
+ const WATCH_MS = 120;
31
+
32
+ class TuiBridge extends EventEmitter {
33
+ constructor({ state, api, sessionId = null, cwd = process.cwd(), readonly = false } = {}) {
34
+ super();
35
+ if (!state || !api) throw new Error("TuiBridge precisa de { state, api }");
36
+ this.state = state;
37
+ this.api = api;
38
+ this.readonly = Boolean(readonly);
39
+ this.log = new EventLog();
40
+
41
+ // Foto do ultimo estado observado, para detectar diffs.
42
+ this.snap = {
43
+ messages: 0, // quantas mensagens ja foram espelhadas
44
+ streamText: "",
45
+ streamReasoning: "",
46
+ approvalId: null,
47
+ busy: false,
48
+ toolsSeen: new Map(), // id -> { status, summary }
49
+ };
50
+
51
+ this.timer = null;
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Interface consumida pelo servidor (mesma do SessionBridge)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ get busy() {
59
+ return Boolean(this.state.busy);
60
+ }
61
+
62
+ get sessionId() {
63
+ return this.sessionIdValue || this.state.sessionId || null;
64
+ }
65
+
66
+ set sessionId(value) {
67
+ this.sessionIdValue = value;
68
+ }
69
+
70
+ /** Snapshot inicial para o celular: historico + estado atual da TUI. */
71
+ snapshot() {
72
+ return {
73
+ sessionId: this.sessionId,
74
+ model: this.state.model || null,
75
+ cwd: this.cwd || process.cwd(),
76
+ allowAll: Boolean(this.state.autoApprove),
77
+ readonly: this.readonly,
78
+ busy: Boolean(this.state.busy),
79
+ streaming: this.state.streaming
80
+ ? { text: this.state.streaming.text, reasoning: this.state.streaming.reasoning }
81
+ : null,
82
+ messages: this.state.messages || [],
83
+ context: [],
84
+ pendingApproval: this.state.approval
85
+ ? {
86
+ id: this._approvalId(),
87
+ name: this.state.approval.name,
88
+ args: this.state.approval.args,
89
+ change: this.state.approval.change ?? null,
90
+ }
91
+ : null,
92
+ seq: this.log.seq,
93
+ };
94
+ }
95
+
96
+ emitEvent(type, payload = {}) {
97
+ const event = this.log.push(type, payload);
98
+ this.emit("event", event);
99
+ return event;
100
+ }
101
+
102
+ /** Enfileira um turno vindo do celular na MESMA fila do teclado. */
103
+ async prompt(text) {
104
+ const line = String(text || "").trim();
105
+ if (!line) return { ok: false, error: "vazio" };
106
+ if (this.readonly) return { ok: false, error: "modo somente leitura" };
107
+ if (this.state.busy || this.state.queue?.length) return { ok: false, error: "ocupado" };
108
+ // Nao emitimos "user" aqui: o watcher ja espelha a mensagem que a TUI cria.
109
+ await this.api.submitText(line);
110
+ return { ok: true };
111
+ }
112
+
113
+ /** Resolve a aprovacao pendente (mesma que o teclado mostraria). */
114
+ approve(id, decision = "once") {
115
+ if (this.readonly) return false;
116
+ const pending = this.state.approval;
117
+ if (!pending) return false;
118
+ if (id && this._approvalId() !== id) return false;
119
+ this.api.settleApproval(decision === "always" ? "always" : decision === "deny" ? "deny" : "once");
120
+ // O evento "approval-resolved" sai pelo watcher (ao ver approval === null).
121
+ return true;
122
+ }
123
+
124
+ abort() {
125
+ if (this.readonly) return false;
126
+ this.api.abortTurn();
127
+ return true;
128
+ }
129
+
130
+ start() {
131
+ if (this.timer) return;
132
+ this._seed();
133
+ this.timer = setInterval(() => this._tick(), WATCH_MS);
134
+ this.timer.unref?.();
135
+ }
136
+
137
+ stop() {
138
+ if (this.timer) clearInterval(this.timer);
139
+ this.timer = null;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Espelho do estado da TUI -> eventos para o celular
144
+ // ---------------------------------------------------------------------------
145
+
146
+ _approvalId() {
147
+ // A TUI nao da id a aprovacao; derivamos um estavel a partir do nome+args
148
+ // para o celular poder correlacionar o botao com a caixa do PC.
149
+ const ap = this.state.approval;
150
+ if (!ap) return null;
151
+ if (!ap.__mobileId) {
152
+ const base = JSON.stringify({ n: ap.name, a: ap.args || {} });
153
+ let hash = 0;
154
+ for (let i = 0; i < base.length; i += 1) hash = (hash * 31 + base.charCodeAt(i)) | 0;
155
+ ap.__mobileId = `ap_${(hash >>> 0).toString(36)}`;
156
+ }
157
+ return ap.__mobileId;
158
+ }
159
+
160
+ /** Marca o historico ja existente como "espelhado" (nao reenvia pro celular). */
161
+ _seed() {
162
+ const messages = this.state.messages || [];
163
+ this.snap.messages = messages.length;
164
+ for (const m of messages) {
165
+ if (m.role === "tool" && m.tool?.id) {
166
+ this.snap.toolsSeen.set(m.tool.id, { status: m.tool.status, summary: m.tool.summary });
167
+ }
168
+ }
169
+ }
170
+
171
+ _tick() {
172
+ const state = this.state;
173
+ const messages = state.messages || [];
174
+
175
+ // 1) Mensagens commitadas novas (user/assistant/thinking/tool/system/compaction).
176
+ while (this.snap.messages < messages.length) {
177
+ const m = messages[this.snap.messages];
178
+ this._emitMessage(m);
179
+ this.snap.messages += 1;
180
+ }
181
+
182
+ // 2) Streaming ao vivo (texto/reasoning) — a TUI muta state.streaming.
183
+ const streaming = state.streaming;
184
+ const text = streaming?.text || "";
185
+ if (text.length > this.snap.streamText.length) {
186
+ const chunk = text.slice(this.snap.streamText.length);
187
+ this.emitEvent("token", { text: chunk });
188
+ }
189
+ this.snap.streamText = text;
190
+
191
+ const reasoning = streaming?.reasoning || "";
192
+ if (reasoning.length > this.snap.streamReasoning.length) {
193
+ const chunk = reasoning.slice(this.snap.streamReasoning.length);
194
+ this.emitEvent("thought", { text: chunk });
195
+ }
196
+ this.snap.streamReasoning = reasoning;
197
+
198
+ // 3) Tool cards em andamento: status/summary mudam sem nova mensagem.
199
+ for (const m of messages) {
200
+ if (m.role !== "tool" || !m.tool?.id) continue;
201
+ const prev = this.snap.toolsSeen.get(m.tool.id);
202
+ const cur = { status: m.tool.status, summary: m.tool.summary };
203
+ if (!prev) {
204
+ this.emitEvent("tool-start", {
205
+ id: m.tool.id,
206
+ name: m.tool.name,
207
+ args: m.tool.args,
208
+ label: m.tool.label,
209
+ change: m.tool.change,
210
+ fromMcp: Boolean(m.tool.meta?.mcp),
211
+ subagent: m.tool.subagent,
212
+ });
213
+ } else if (prev.status !== cur.status) {
214
+ this.emitEvent("tool-result", {
215
+ id: m.tool.id,
216
+ name: m.tool.name,
217
+ output: m.tool.output || "",
218
+ change: m.tool.change,
219
+ ok: m.tool.ok !== false,
220
+ skipped: m.tool.skipped,
221
+ summary: m.tool.summary || "",
222
+ ms: m.tool.ms || 0,
223
+ });
224
+ }
225
+ this.snap.toolsSeen.set(m.tool.id, cur);
226
+ }
227
+
228
+ // 4) Aprovacao abrindo/fechando.
229
+ const apId = this.state.approval ? this._approvalId() : null;
230
+ if (apId && apId !== this.snap.approvalId) {
231
+ this.emitEvent("approval-request", {
232
+ id: apId,
233
+ name: this.state.approval.name,
234
+ args: this.state.approval.args,
235
+ change: this.state.approval.change ?? null,
236
+ fromMcp: Boolean(this.state.approval.fromMcp),
237
+ });
238
+ } else if (!apId && this.snap.approvalId) {
239
+ this.emitEvent("approval-resolved", { id: this.snap.approvalId, allowed: true });
240
+ }
241
+ this.snap.approvalId = apId;
242
+
243
+ // 5) Inicio/fim de turno.
244
+ const busy = Boolean(state.busy);
245
+ if (busy && !this.snap.busy) {
246
+ this.snap.streamText = "";
247
+ this.snap.streamReasoning = "";
248
+ this.emitEvent("stream-start", {});
249
+ } else if (!busy && this.snap.busy) {
250
+ this.emitEvent("done", { text: this.snap.streamText, ms: 0 });
251
+ this.snap.streamText = "";
252
+ this.snap.streamReasoning = "";
253
+ }
254
+ this.snap.busy = busy;
255
+ }
256
+
257
+ _emitMessage(m) {
258
+ if (!m) return;
259
+ if (m.role === "user") this.emitEvent("user", { text: m.text });
260
+ else if (m.role === "thinking") this.emitEvent("thought", { text: m.text });
261
+ else if (m.role === "compaction") {
262
+ this.emitEvent("compact", {
263
+ before: m.compaction?.before ?? 0,
264
+ after: m.compaction?.after ?? 0,
265
+ summary: m.text || "",
266
+ });
267
+ } else if (m.role === "system") this.emitEvent("error", { text: m.text });
268
+ else if (m.role === "tool" && m.tool?.id) {
269
+ // Ja tratado no diff de tools; garante o card caso o watcher tenha perdido.
270
+ if (!this.snap.toolsSeen.has(m.tool.id)) {
271
+ this.emitEvent("tool-start", {
272
+ id: m.tool.id,
273
+ name: m.tool.name,
274
+ args: m.tool.args,
275
+ label: m.tool.label,
276
+ change: m.tool.change,
277
+ });
278
+ }
279
+ } else if (m.role === "assistant") {
280
+ // O texto do assistant chega pelo stream; so usa como fallback quando nao
281
+ // houve streaming (evita card duplicado no celular).
282
+ if (!m.ms || !this.snap.streamText) {
283
+ // nada: assistant final ja coberto por "done"
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ module.exports = { TuiBridge };