kafox-cli 0.9.9 → 0.10.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.
@@ -0,0 +1,399 @@
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
+ const captcha = require("./captcha");
21
+ const { SlideLimiter, DEFAULTS } = require("./ratelimit");
22
+
23
+ const HEARTBEAT_MS = 5_000;
24
+
25
+ function readBody(req, limit = 64 * 1024) {
26
+ return new Promise((resolve, reject) => {
27
+ let data = "";
28
+ req.on("data", (chunk) => {
29
+ data += chunk;
30
+ if (data.length > limit) {
31
+ reject(new Error("corpo grande demais"));
32
+ req.destroy();
33
+ }
34
+ });
35
+ req.on("end", () => resolve(data));
36
+ req.on("error", reject);
37
+ });
38
+ }
39
+
40
+ function json(res, status, body) {
41
+ const payload = JSON.stringify(body);
42
+ res.writeHead(status, {
43
+ "content-type": "application/json; charset=utf-8",
44
+ "content-length": Buffer.byteLength(payload),
45
+ "cache-control": "no-store",
46
+ });
47
+ res.end(payload);
48
+ }
49
+
50
+ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml = renderPage() } = {}) {
51
+ if (!bridge) throw new Error("createMobileServer precisa de um SessionBridge");
52
+
53
+ const limiter = new SlideLimiter();
54
+ const sseClients = new Set();
55
+
56
+ // IP do cliente. Atras do Caddy (reverse_proxy) vem em x-forwarded-for; em
57
+ // acesso direto, o socket. Normaliza pro primeiro IP da cadeia.
58
+ function clientIp(req) {
59
+ const fwd = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
60
+ return fwd || req.socket.remoteAddress || "desconhecido";
61
+ }
62
+
63
+ // Aplica o rate limit da rota. Devolve true se JA respondeu (bloqueado).
64
+ function blocked(req, res, ruleName, overrideKey) {
65
+ const rule = DEFAULTS[ruleName];
66
+ if (!rule) return false;
67
+ const key = `${ruleName}:${overrideKey || clientIp(req)}`;
68
+ const verdict = limiter.hit(key, rule);
69
+ if (verdict.ok) return false;
70
+ res.writeHead(429, {
71
+ "content-type": "application/json; charset=utf-8",
72
+ "retry-after": String(verdict.retryAfter),
73
+ "cache-control": "no-store",
74
+ });
75
+ res.end(
76
+ JSON.stringify({
77
+ error: "muitas requisições — aguarde alguns instantes",
78
+ code: "rate_limited",
79
+ retryAfter: verdict.retryAfter,
80
+ })
81
+ );
82
+ return true;
83
+ }
84
+
85
+ function broadcast(event) {
86
+ const frame = `data: ${JSON.stringify(event)}\n\n`;
87
+ for (const client of sseClients) {
88
+ try {
89
+ client.write(frame);
90
+ } catch {
91
+ sseClients.delete(client);
92
+ }
93
+ }
94
+ }
95
+
96
+ bridge.on("event", broadcast);
97
+
98
+ const server = http.createServer(async (req, res) => {
99
+ const url = new URL(req.url, "http://localhost");
100
+ const pathname = url.pathname;
101
+
102
+ // Teto global por IP: nenhuma rota escapa de um piso minimo de protecao
103
+ // contra varredura de URLs.
104
+ if (blocked(req, res, "global")) return;
105
+
106
+ // A pagina e o proprio endpoint de pareamento nao exigem token.
107
+ if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
108
+ res.writeHead(200, {
109
+ "content-type": "text/html; charset=utf-8",
110
+ "cache-control": "no-store",
111
+ });
112
+ res.end(pageHtml);
113
+ return;
114
+ }
115
+
116
+ if (req.method === "GET" && pathname === "/health") {
117
+ json(res, 200, { ok: true, busy: bridge.busy, sessionId: bridge.sessionId });
118
+ return;
119
+ }
120
+
121
+ // Favicon: o navegador pede sozinho e nao manda token. Sem isso, ele leva
122
+ // 401 e polui o console. Servimos um "K" brutalist em SVG.
123
+ if (req.method === "GET" && (pathname === "/favicon.ico" || pathname === "/favicon.svg")) {
124
+ const svg =
125
+ "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>" +
126
+ "<rect width='32' height='32' fill='#000'/>" +
127
+ "<text x='6' y='24' font-size='22' fill='#fff' font-family='monospace' font-weight='bold'>K</text></svg>";
128
+ res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "max-age=86400" });
129
+ res.end(svg);
130
+ return;
131
+ }
132
+
133
+ // CAPTCHA: emite um desafio novo (publico, de uso unico). O celular pede
134
+ // isso antes de tentar parear. Sem ele, o POST /pair e rejeitado.
135
+ if (req.method === "GET" && pathname === "/captcha") {
136
+ if (blocked(req, res, "captcha")) return;
137
+ return json(res, 200, captcha.issue());
138
+ }
139
+
140
+ // PASSO 1: o celular pede entrada (com o codigo do QR). Nao recebe token
141
+ // ainda — cria um pedido pendente e o dono do terminal aprova/nega.
142
+ if (req.method === "POST" && pathname === "/pair") {
143
+ if (blocked(req, res, "pair")) return;
144
+ let body;
145
+ try {
146
+ body = JSON.parse((await readBody(req)) || "{}");
147
+ } catch {
148
+ return json(res, 400, { error: "json invalido" });
149
+ }
150
+ // Portao anti-bot: valida e consome o captcha ANTES de olhar o codigo.
151
+ const captchaCheck = captcha.verify(body.captchaId, body.captchaAnswer);
152
+ if (!captchaCheck.ok) {
153
+ return json(res, 400, { error: captchaCheck.reason || "captcha invalido", captcha: true });
154
+ }
155
+ const code = String(body.code || "").trim().toUpperCase();
156
+ const device = {
157
+ ua: String(req.headers["user-agent"] || "").slice(0, 160),
158
+ ip: String(req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").slice(0, 80),
159
+ name: String(body.device || "celular").slice(0, 60),
160
+ };
161
+ const snap = bridge.snapshot();
162
+ const request = pairing.requestPair(code, device, {
163
+ sessionTitle: snap.title || snap.name || "",
164
+ sessionId: snap.sessionId || "",
165
+ });
166
+ if (!request) return json(res, 401, { error: "codigo invalido ou expirado" });
167
+ return json(res, 200, {
168
+ requestId: request.requestId,
169
+ expiresAt: request.expiresAt,
170
+ status: "pending",
171
+ session: { sessionId: snap.sessionId, title: snap.title || snap.name || "" },
172
+ });
173
+ }
174
+
175
+ // PASSO 3: o celular consulta o status do pedido. Quando aprovado, recebe o
176
+ // token UMA vez. Rota publica (autenticada pelo requestId aleatorio).
177
+ if (req.method === "GET" && pathname === "/pair") {
178
+ if (blocked(req, res, "poll")) return;
179
+ const requestId = String(url.searchParams.get("id") || "");
180
+ if (!requestId) return json(res, 400, { error: "id obrigatorio" });
181
+ const status = pairing.pollRequest(requestId);
182
+ if (status.status === "unknown") return json(res, 404, { error: "pedido nao encontrado" });
183
+ if (status.status === "approved") {
184
+ return json(res, 200, { status: "approved", token: status.token, expiresAt: status.expiresAt, session: bridge.snapshot() });
185
+ }
186
+ return json(res, 200, { status: status.status });
187
+ }
188
+
189
+ // PASSO 2: o terminal aprova/nega um pedido pendente. Exige token de um
190
+ // pareamento JA existente OU vem do proprio dono (rota local). Aqui so
191
+ // aceitamos quando ha um pedido valido — a rota e usada pela TUI no PC.
192
+ if (req.method === "POST" && pathname === "/pair/decide") {
193
+ let body;
194
+ try {
195
+ body = JSON.parse((await readBody(req)) || "{}");
196
+ } catch {
197
+ return json(res, 400, { error: "json invalido" });
198
+ }
199
+ const requestId = String(body.id || "");
200
+ const approved = Boolean(body.approved);
201
+ if (!requestId) return json(res, 400, { error: "id obrigatorio" });
202
+ const ok = pairing.resolveRequest(requestId, approved);
203
+ if (!ok) return json(res, 404, { error: "pedido nao encontrado ou ja resolvido" });
204
+ return json(res, 200, { ok: true, status: approved ? "approved" : "denied" });
205
+ }
206
+
207
+ // A partir daqui, exige token de sessao.
208
+ const token = req.headers["x-kafox-token"] || url.searchParams.get("t");
209
+ if (!pairing.validateToken(token)) {
210
+ // Teto curto por IP para TOKEN INVALIDO: bater aqui e sinal de varredura
211
+ // de sessao pela URL. Vale mesmo antes de responder o 401.
212
+ if (blocked(req, res, "authFail")) return;
213
+ return json(res, 401, { error: "sem token valido" });
214
+ }
215
+
216
+ // Token valido: conta por TOKEN (nao so por IP), pra um IP sozinho nao
217
+ // conseguir varrer sessoes sem estourar o bucket da propria sessao.
218
+ const tokenKey = String(token).slice(0, 16);
219
+ if (blocked(req, res, "auth", tokenKey)) return;
220
+
221
+ if (req.method === "GET" && pathname === "/state") {
222
+ return json(res, 200, bridge.snapshot());
223
+ }
224
+
225
+ // Long-polling: retorna os eventos novos e FECHA a resposta. Diferente do
226
+ // SSE, uma resposta normal com fim e entregue de imediato pelo Cloudflare,
227
+ // que costuma segurar/abortar streams text/event-stream de navegadores.
228
+ if (req.method === "GET" && pathname === "/poll") {
229
+ const since = Number.parseInt(url.searchParams.get("since") || "0", 10) || 0;
230
+ const waitMs = Math.min(Math.max(Number.parseInt(url.searchParams.get("wait") || "15000", 10) || 15000, 0), 25000);
231
+ const existing = bridge.log.since(since);
232
+ if (existing.length) {
233
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
234
+ return res.end(JSON.stringify({ events: existing, seq: bridge.log.seq }));
235
+ }
236
+ let done = false;
237
+ let unsub = null;
238
+ const finish = (events) => {
239
+ if (done) return;
240
+ done = true;
241
+ clearTimeout(timer);
242
+ if (unsub) unsub();
243
+ try {
244
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
245
+ res.end(JSON.stringify({ events, seq: bridge.log.seq }));
246
+ } catch {
247
+ /* cliente sumiu */
248
+ }
249
+ };
250
+ const onEvent = (ev) => finish([ev]);
251
+ const timer = setTimeout(() => finish([]), waitMs);
252
+ timer.unref?.();
253
+ unsub = bridge.log.subscribe(onEvent);
254
+ req.on("close", () => { done = true; clearTimeout(timer); if (unsub) unsub(); });
255
+ return;
256
+ }
257
+
258
+ if (req.method === "GET" && pathname === "/events") {
259
+ const since = Number.parseInt(url.searchParams.get("since") || "0", 10) || 0;
260
+ // Cache-Control com no-transform evita que proxies (Cloudflare/Caddy)
261
+ // reescrevam/segurem o stream. X-Accel-Buffering desliga o buffer do nginx.
262
+ res.writeHead(200, {
263
+ "content-type": "text/event-stream; charset=utf-8",
264
+ "cache-control": "no-store, no-transform",
265
+ connection: "keep-alive",
266
+ // impedem buffering em proxies (Caddy/nginx) — SSE sem isso trava.
267
+ "x-accel-buffering": "no",
268
+ "transfer-encoding": "chunked",
269
+ });
270
+ // Cloudflare (e outros edges) seguram o SSE num buffer antes de comecar a
271
+ // repassar. Preenchemos esse buffer com um comentario de padding logo na
272
+ // abertura (8KB) para forcar o flush e o streaming continuo no navegador.
273
+ res.write(": kafox mobile\n");
274
+ res.write(`:${" ".repeat(8192)}\n\n`);
275
+ // replay do que o cliente perdeu, depois assina o ao vivo.
276
+ for (const event of bridge.log.since(since)) res.write(`data: ${JSON.stringify(event)}\n\n`);
277
+
278
+ sseClients.add(res);
279
+ const heartbeat = setInterval(() => {
280
+ try {
281
+ res.write(": ping\n\n");
282
+ } catch {
283
+ /* tratado no close */
284
+ }
285
+ }, HEARTBEAT_MS);
286
+ heartbeat.unref?.();
287
+
288
+ const cleanup = () => {
289
+ clearInterval(heartbeat);
290
+ sseClients.delete(res);
291
+ };
292
+ req.on("close", cleanup);
293
+ req.on("error", cleanup);
294
+ return;
295
+ }
296
+
297
+ if (req.method === "POST" && pathname === "/prompt") {
298
+ if (blocked(req, res, "action", tokenKey)) return;
299
+ let body;
300
+ try {
301
+ body = JSON.parse((await readBody(req)) || "{}");
302
+ } catch {
303
+ return json(res, 400, { error: "json invalido" });
304
+ }
305
+ const text = String(body.text || "");
306
+ if (!text.trim()) return json(res, 400, { error: "texto vazio" });
307
+ if (bridge.busy) return json(res, 409, { error: "ocupado" });
308
+ pairing.audit("prompt", { chars: text.length });
309
+ // roda em background; o resultado chega pelo SSE.
310
+ bridge.prompt(text).catch(() => {});
311
+ return json(res, 202, { ok: true });
312
+ }
313
+
314
+ if (req.method === "POST" && pathname === "/approve") {
315
+ if (blocked(req, res, "action", tokenKey)) return;
316
+ let body;
317
+ try {
318
+ body = JSON.parse((await readBody(req)) || "{}");
319
+ } catch {
320
+ return json(res, 400, { error: "json invalido" });
321
+ }
322
+ const decision = ["once", "always", "deny"].includes(body.decision) ? body.decision : "deny";
323
+ const ok = bridge.approve(body.id, decision);
324
+ pairing.audit("approve", { decision, id: body.id });
325
+ return json(res, ok ? 200 : 409, { ok });
326
+ }
327
+
328
+ if (req.method === "POST" && pathname === "/abort") {
329
+ pairing.audit("abort", {});
330
+ bridge.abort();
331
+ return json(res, 200, { ok: true });
332
+ }
333
+
334
+ json(res, 404, { error: "nao encontrado" });
335
+ });
336
+
337
+ server.on("close", () => bridge.off("event", broadcast));
338
+
339
+ return { server, pairing, sseClients };
340
+ }
341
+
342
+ /**
343
+ * Descobre quem esta segurando uma porta local (best-effort, sem depender de
344
+ * `lsof`). Usa `ss -tlnp` quando disponivel; devolve [] se nao conseguir.
345
+ */
346
+ function portHolders(port) {
347
+ try {
348
+ const { execSync } = require("child_process");
349
+ const out = execSync(`ss -tlnp 2>/dev/null | grep ':${Number(port)}\\b' || true`, {
350
+ encoding: "utf8",
351
+ timeout: 3000,
352
+ });
353
+ const holders = [];
354
+ for (const line of out.split("\n")) {
355
+ const m = line.match(/users:\(\("([^"]+)",pid=(\d+)/);
356
+ if (m) holders.push({ name: m[1], pid: Number(m[2]) });
357
+ }
358
+ return holders;
359
+ } catch {
360
+ return [];
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Escuta com fallback de porta: tenta `port`, e se estiver ocupada (EADDRINUSE)
366
+ * tenta as proximas `maxTries` portas. Resolve com a porta efetivamente usada.
367
+ *
368
+ * Sem isso, subir dois `kafox mobile` estourava EADDRINUSE e derrubava o CLI.
369
+ */
370
+ function listenWithFallback(server, { port, host = "127.0.0.1", maxTries = 12 } = {}) {
371
+ return new Promise((resolve, reject) => {
372
+ let attempt = 0;
373
+ let current = Number(port);
374
+
375
+ const onError = (error) => {
376
+ if (error.code === "EADDRINUSE" && attempt < maxTries) {
377
+ const holders = portHolders(current);
378
+ const who = holders.length ? ` (ocupada por ${holders.map((h) => `${h.name} pid ${h.pid}`).join(", ")})` : "";
379
+ process.stderr.write(` porta ${current} em uso${who}; tentando ${current + 1}…\n`);
380
+ attempt += 1;
381
+ current += 1;
382
+ server.once("error", onError);
383
+ server.listen(current, host);
384
+ return;
385
+ }
386
+ server.removeListener("error", onError);
387
+ reject(error);
388
+ };
389
+
390
+ server.once("error", onError);
391
+ server.once("listening", () => {
392
+ server.removeListener("error", onError);
393
+ resolve(current);
394
+ });
395
+ server.listen(current, host);
396
+ });
397
+ }
398
+
399
+ module.exports = { createMobileServer, listenWithFallback, portHolders };
@@ -0,0 +1,290 @@
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
+ title: this.state.title || (this.state.messages || []).find((m) => m.role === "user")?.text || null,
75
+ model: this.state.model || null,
76
+ cwd: this.cwd || process.cwd(),
77
+ allowAll: Boolean(this.state.autoApprove),
78
+ readonly: this.readonly,
79
+ busy: Boolean(this.state.busy),
80
+ streaming: this.state.streaming
81
+ ? { text: this.state.streaming.text, reasoning: this.state.streaming.reasoning }
82
+ : null,
83
+ messages: this.state.messages || [],
84
+ context: [],
85
+ pendingApproval: this.state.approval
86
+ ? {
87
+ id: this._approvalId(),
88
+ name: this.state.approval.name,
89
+ args: this.state.approval.args,
90
+ change: this.state.approval.change ?? null,
91
+ }
92
+ : null,
93
+ seq: this.log.seq,
94
+ };
95
+ }
96
+
97
+ emitEvent(type, payload = {}) {
98
+ const event = this.log.push(type, payload);
99
+ this.emit("event", event);
100
+ return event;
101
+ }
102
+
103
+ /** Enfileira um turno vindo do celular na MESMA fila do teclado. */
104
+ async prompt(text) {
105
+ const line = String(text || "").trim();
106
+ if (!line) return { ok: false, error: "vazio" };
107
+ if (this.readonly) return { ok: false, error: "modo somente leitura" };
108
+ if (this.state.busy || this.state.queue?.length) return { ok: false, error: "ocupado" };
109
+ // Nao emitimos "user" aqui: o watcher ja espelha a mensagem que a TUI cria.
110
+ await this.api.submitText(line);
111
+ return { ok: true };
112
+ }
113
+
114
+ /** Resolve a aprovacao pendente (mesma que o teclado mostraria). */
115
+ approve(id, decision = "once") {
116
+ if (this.readonly) return false;
117
+ const pending = this.state.approval;
118
+ if (!pending) return false;
119
+ if (id && this._approvalId() !== id) return false;
120
+ this.api.settleApproval(decision === "always" ? "always" : decision === "deny" ? "deny" : "once");
121
+ // O evento "approval-resolved" sai pelo watcher (ao ver approval === null).
122
+ return true;
123
+ }
124
+
125
+ abort() {
126
+ if (this.readonly) return false;
127
+ this.api.abortTurn();
128
+ return true;
129
+ }
130
+
131
+ start() {
132
+ if (this.timer) return;
133
+ this._seed();
134
+ this.timer = setInterval(() => this._tick(), WATCH_MS);
135
+ this.timer.unref?.();
136
+ }
137
+
138
+ stop() {
139
+ if (this.timer) clearInterval(this.timer);
140
+ this.timer = null;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Espelho do estado da TUI -> eventos para o celular
145
+ // ---------------------------------------------------------------------------
146
+
147
+ _approvalId() {
148
+ // A TUI nao da id a aprovacao; derivamos um estavel a partir do nome+args
149
+ // para o celular poder correlacionar o botao com a caixa do PC.
150
+ const ap = this.state.approval;
151
+ if (!ap) return null;
152
+ if (!ap.__mobileId) {
153
+ const base = JSON.stringify({ n: ap.name, a: ap.args || {} });
154
+ let hash = 0;
155
+ for (let i = 0; i < base.length; i += 1) hash = (hash * 31 + base.charCodeAt(i)) | 0;
156
+ ap.__mobileId = `ap_${(hash >>> 0).toString(36)}`;
157
+ }
158
+ return ap.__mobileId;
159
+ }
160
+
161
+ /** Marca o historico ja existente como "espelhado" (nao reenvia pro celular). */
162
+ _seed() {
163
+ const messages = this.state.messages || [];
164
+ this.snap.messages = messages.length;
165
+ for (const m of messages) {
166
+ if (m.role === "tool" && m.tool?.id) {
167
+ this.snap.toolsSeen.set(m.tool.id, { status: m.tool.status, summary: m.tool.summary });
168
+ }
169
+ }
170
+ }
171
+
172
+ _tick() {
173
+ const state = this.state;
174
+ const messages = state.messages || [];
175
+
176
+ // 1) Mensagens commitadas novas (user/assistant/thinking/tool/system/compaction).
177
+ while (this.snap.messages < messages.length) {
178
+ const m = messages[this.snap.messages];
179
+ this._emitMessage(m);
180
+ this.snap.messages += 1;
181
+ }
182
+
183
+ // 2) Streaming ao vivo (texto/reasoning) — a TUI muta state.streaming.
184
+ const streaming = state.streaming;
185
+ const text = streaming?.text || "";
186
+ if (text.length > this.snap.streamText.length) {
187
+ const chunk = text.slice(this.snap.streamText.length);
188
+ this.emitEvent("token", { text: chunk });
189
+ }
190
+ this.snap.streamText = text;
191
+
192
+ const reasoning = streaming?.reasoning || "";
193
+ if (reasoning.length > this.snap.streamReasoning.length) {
194
+ const chunk = reasoning.slice(this.snap.streamReasoning.length);
195
+ this.emitEvent("thought", { text: chunk });
196
+ }
197
+ this.snap.streamReasoning = reasoning;
198
+
199
+ // 3) Tool cards em andamento: status/summary mudam sem nova mensagem.
200
+ for (const m of messages) {
201
+ if (m.role !== "tool" || !m.tool?.id) continue;
202
+ const prev = this.snap.toolsSeen.get(m.tool.id);
203
+ const cur = { status: m.tool.status, summary: m.tool.summary };
204
+ if (!prev) {
205
+ this.emitEvent("tool-start", {
206
+ id: m.tool.id,
207
+ name: m.tool.name,
208
+ args: m.tool.args,
209
+ label: m.tool.label,
210
+ change: m.tool.change,
211
+ fromMcp: Boolean(m.tool.meta?.mcp),
212
+ subagent: m.tool.subagent,
213
+ });
214
+ } else if (prev.status !== cur.status) {
215
+ this.emitEvent("tool-result", {
216
+ id: m.tool.id,
217
+ name: m.tool.name,
218
+ output: m.tool.output || "",
219
+ change: m.tool.change,
220
+ ok: m.tool.ok !== false,
221
+ skipped: m.tool.skipped,
222
+ summary: m.tool.summary || "",
223
+ ms: m.tool.ms || 0,
224
+ });
225
+ }
226
+ this.snap.toolsSeen.set(m.tool.id, cur);
227
+ }
228
+
229
+ // 4) Aprovacao abrindo/fechando.
230
+ const apId = this.state.approval ? this._approvalId() : null;
231
+ if (apId && apId !== this.snap.approvalId) {
232
+ this.emitEvent("approval-request", {
233
+ id: apId,
234
+ name: this.state.approval.name,
235
+ args: this.state.approval.args,
236
+ change: this.state.approval.change ?? null,
237
+ fromMcp: Boolean(this.state.approval.fromMcp),
238
+ });
239
+ } else if (!apId && this.snap.approvalId) {
240
+ this.emitEvent("approval-resolved", { id: this.snap.approvalId, allowed: true });
241
+ }
242
+ this.snap.approvalId = apId;
243
+
244
+ // 5) Inicio/fim de turno.
245
+ const busy = Boolean(state.busy);
246
+ if (busy && !this.snap.busy) {
247
+ this.snap.streamText = "";
248
+ this.snap.streamReasoning = "";
249
+ this.emitEvent("stream-start", {});
250
+ } else if (!busy && this.snap.busy) {
251
+ this.emitEvent("done", { text: this.snap.streamText, ms: 0 });
252
+ this.snap.streamText = "";
253
+ this.snap.streamReasoning = "";
254
+ }
255
+ this.snap.busy = busy;
256
+ }
257
+
258
+ _emitMessage(m) {
259
+ if (!m) return;
260
+ if (m.role === "user") this.emitEvent("user", { text: m.text });
261
+ else if (m.role === "thinking") this.emitEvent("thought", { text: m.text });
262
+ else if (m.role === "compaction") {
263
+ this.emitEvent("compact", {
264
+ before: m.compaction?.before ?? 0,
265
+ after: m.compaction?.after ?? 0,
266
+ summary: m.text || "",
267
+ });
268
+ } else if (m.role === "system") this.emitEvent("error", { text: m.text });
269
+ else if (m.role === "tool" && m.tool?.id) {
270
+ // Ja tratado no diff de tools; garante o card caso o watcher tenha perdido.
271
+ if (!this.snap.toolsSeen.has(m.tool.id)) {
272
+ this.emitEvent("tool-start", {
273
+ id: m.tool.id,
274
+ name: m.tool.name,
275
+ args: m.tool.args,
276
+ label: m.tool.label,
277
+ change: m.tool.change,
278
+ });
279
+ }
280
+ } else if (m.role === "assistant") {
281
+ // O texto do assistant chega pelo stream; so usa como fallback quando nao
282
+ // houve streaming (evita card duplicado no celular).
283
+ if (!m.ms || !this.snap.streamText) {
284
+ // nada: assistant final ja coberto por "done"
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ module.exports = { TuiBridge };