kafox-cli 0.10.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Kafox CLI: TUI com streaming, thinking, edit com diff, agent tools (files/shell), MCP e temas",
5
5
  "repository": {
6
6
  "type": "git",
package/src/compact.js CHANGED
@@ -30,6 +30,10 @@ const { chat } = require("./gateway");
30
30
  const DEFAULT_THRESHOLD_TOKENS = 60_000;
31
31
  // Quantas mensagens recentes ficam intactas depois do resumo.
32
32
  const DEFAULT_KEEP_MESSAGES = 8;
33
+
34
+ // Quantos turnos de usuario manter no historico enviado ao modelo. Casa com
35
+ // "~20 mensagens" (10 do usuario + 10 da IA). Mantem o chat leve e o custo baixo.
36
+ const DEFAULT_KEEP_TURNS = 10;
33
37
  // Teto do resumo: o texto gerado vira contexto permanente, entao nao pode crescer.
34
38
  const MAX_SUMMARY_CHARS = 4_000;
35
39
 
@@ -73,6 +77,40 @@ function safeCutIndex(messages, wantedIndex) {
73
77
  return Math.max(1, index);
74
78
  }
75
79
 
80
+ /**
81
+ * Mantem so os ultimos `maxUserTurns` turnos do usuario no historico enviado
82
+ * ao modelo (mais o system). Cada turno arrasta junto a resposta do assistant
83
+ * e os pares de tool, entao o resultado gira em torno de `2 * maxUserTurns`
84
+ * mensagens — ex.: 10 turnos ~ 10 do usuario + 10 da IA.
85
+ *
86
+ * O corte e sempre seguro: usa safeCutIndex pra nao deixar tool orfao nem
87
+ * assistant(tool_calls) sem os resultados. Devolve sempre um array novo.
88
+ */
89
+ function trimHistory(messages, maxUserTurns = DEFAULT_KEEP_TURNS) {
90
+ const list = Array.isArray(messages) ? messages : [];
91
+ if (maxUserTurns <= 0 || list.length === 0) return list;
92
+ // Quantos turnos de usuario existem e onde comecam os ultimos N.
93
+ let seen = 0;
94
+ let wanted = 0;
95
+ for (let i = list.length - 1; i >= 0; i -= 1) {
96
+ if (list[i]?.role === "user") {
97
+ seen += 1;
98
+ if (seen >= maxUserTurns) {
99
+ wanted = i;
100
+ break;
101
+ }
102
+ }
103
+ }
104
+ if (seen < maxUserTurns) return list.slice(); // cabem todos: nada a cortar
105
+ const cut = safeCutIndex(list, wanted);
106
+ const trimmed = list.slice(cut);
107
+ // Recoloca o system (que vive no indice 0) a frente, se existia.
108
+ if (list[0]?.role === "system" && trimmed[0]?.role !== "system") {
109
+ trimmed.unshift(list[0]);
110
+ }
111
+ return trimmed;
112
+ }
113
+
76
114
  /** Texto corrido do historico, para alimentar o resumo. */
77
115
  function transcript(messages, limitPerMessage = 1_500) {
78
116
  return messages
@@ -334,6 +372,7 @@ module.exports = {
334
372
  compactIfNeeded,
335
373
  estimateTokens,
336
374
  safeCutIndex,
375
+ trimHistory,
337
376
  summaryPrompt,
338
377
  buildCompactionNote,
339
378
  scrubSecrets,
@@ -342,5 +381,6 @@ module.exports = {
342
381
  SUMMARY_SECTIONS,
343
382
  DEFAULT_THRESHOLD_TOKENS,
344
383
  DEFAULT_KEEP_MESSAGES,
384
+ DEFAULT_KEEP_TURNS,
345
385
  MAX_SUMMARY_CHARS,
346
386
  };
@@ -115,6 +115,128 @@ class PairingManager {
115
115
  }
116
116
  this.currentCode = null;
117
117
  this.currentCodeHash = null;
118
+ // Pedidos de pareamento aguardando aprovacao do dono do terminal.
119
+ // requestId -> { createdAt, expiresAt, code, device, sessionTitle, status,
120
+ // token?, tokenExpiresAt? }
121
+ this.requests = new Map();
122
+ // Listener chamado quando chega um pedido novo (a TUI usa pra abrir modal).
123
+ this.onRequest = null;
124
+ }
125
+
126
+ /**
127
+ * PASSO 1 do novo fluxo: o celular pede entrada. NAO devolve token ainda —
128
+ * cria um pedido pendente e avisa o terminal (this.onRequest). O dono
129
+ * aprova/nega; so entao o token e emitido (ver resolveRequest/claim).
130
+ * @returns {{requestId:string, expiresAt:number}|null}
131
+ */
132
+ requestPair(code, device = {}, meta = {}) {
133
+ if (!this.validateCode(code)) return null;
134
+ // Consome o codigo JA na criacao do pedido: garante uso unico (dois
135
+ // celulares nao podem abrir pedidos concorrentes com o mesmo codigo).
136
+ const codeHash = sha256(code);
137
+ this.pending.delete(codeHash);
138
+ if (this.currentCodeHash === codeHash) {
139
+ this.currentCode = null;
140
+ this.currentCodeHash = null;
141
+ }
142
+ const requestId = crypto.randomBytes(16).toString("hex");
143
+ const expiresAt = Date.now() + PAIR_TTL_MS;
144
+ const req = {
145
+ createdAt: Date.now(),
146
+ expiresAt,
147
+ code,
148
+ device,
149
+ sessionTitle: String(meta.sessionTitle || "").slice(0, 120),
150
+ sessionId: String(meta.sessionId || "").slice(0, 40),
151
+ status: "pending", // pending | approved | denied
152
+ token: null,
153
+ tokenExpiresAt: 0,
154
+ };
155
+ this.requests.set(requestId, req);
156
+ // Avisa o terminal (best-effort: se nao houver listener, so fica pendente).
157
+ try {
158
+ this.onRequest?.({ requestId, device, sessionTitle: req.sessionTitle, expiresAt });
159
+ } catch {
160
+ /* nao deixa o pedido quebrar por erro do listener */
161
+ }
162
+ this.audit("pair-request", { requestId: requestId.slice(0, 12), device });
163
+ return { requestId, expiresAt };
164
+ }
165
+
166
+ /** PASSO 2 (terminal): aprova ou nega um pedido. Ao aprovar, emite o token. */
167
+ resolveRequest(requestId, approved, device = null) {
168
+ const req = this.requests.get(requestId);
169
+ if (!req) return false;
170
+ if (Date.now() > req.expiresAt) {
171
+ this.requests.delete(requestId);
172
+ return false;
173
+ }
174
+ if (req.status !== "pending") return false;
175
+ if (!approved) {
176
+ req.status = "denied";
177
+ this.audit("pair-denied", { requestId: requestId.slice(0, 12) });
178
+ return true;
179
+ }
180
+ // Aprovado: consome o codigo (single-use) e emite o token de sessao.
181
+ const codeHash = sha256(req.code);
182
+ this.pending.delete(codeHash);
183
+ if (this.currentCodeHash === codeHash) {
184
+ this.currentCode = null;
185
+ this.currentCodeHash = null;
186
+ }
187
+ const token = crypto.randomBytes(32).toString("hex");
188
+ const tokenExpiresAt = Date.now() + TOKEN_TTL_MS;
189
+ this.tokens.set(sha256(token), {
190
+ createdAt: Date.now(),
191
+ expiresAt: tokenExpiresAt,
192
+ code: req.code,
193
+ device: device || req.device,
194
+ });
195
+ this.persistTokens();
196
+ req.status = "approved";
197
+ req.token = token;
198
+ req.tokenExpiresAt = tokenExpiresAt;
199
+ this.audit("pair-approved", { requestId: requestId.slice(0, 12), device: req.device });
200
+ // Notifica o terminal: um pareamento CONCLUIU agora (some com o QR).
201
+ try {
202
+ this.onPaired?.({ requestId, device: device || req.device, sessionTitle: req.sessionTitle });
203
+ } catch {
204
+ /* listener do terminal nunca derruba o pareamento */
205
+ }
206
+ return true;
207
+ }
208
+
209
+ /**
210
+ * PASSO 3 (celular): consulta o status do pedido. Quando aprovado, devolve o
211
+ * token UMA vez e marca o pedido como consumido (nao vaza token em retries).
212
+ * @returns {{status:string, token?:string, expiresAt?:number}}
213
+ */
214
+ pollRequest(requestId) {
215
+ const req = this.requests.get(requestId);
216
+ if (!req) return { status: "unknown" };
217
+ if (req.status === "pending" && Date.now() > req.expiresAt) {
218
+ this.requests.delete(requestId);
219
+ return { status: "expired" };
220
+ }
221
+ if (req.status === "approved") {
222
+ const token = req.token;
223
+ const expiresAt = req.tokenExpiresAt;
224
+ // single-shot: o token so sai do servidor uma vez.
225
+ this.requests.delete(requestId);
226
+ return { status: "approved", token, expiresAt };
227
+ }
228
+ return { status: req.status };
229
+ }
230
+
231
+ /** Pedidos pendentes (para reabrir o modal se a TUI renderizar de novo). */
232
+ pendingRequests() {
233
+ const out = [];
234
+ const now = Date.now();
235
+ for (const [id, req] of [...this.requests]) {
236
+ if (req.status === "pending" && now <= req.expiresAt) out.push({ requestId: id, ...req });
237
+ else if (now > req.expiresAt) this.requests.delete(id);
238
+ }
239
+ return out;
118
240
  }
119
241
 
120
242
  /**
@@ -20,6 +20,7 @@ const { EventEmitter } = require("events");
20
20
  const { runAgent } = require("../agent");
21
21
  const { estimateTokens } = require("../compact");
22
22
  const sessions = require("../sessions");
23
+ const { trimHistory } = require("./history");
23
24
  const { EventLog } = require("./protocol");
24
25
 
25
26
  const SAVE_DEBOUNCE_MS = 400;
@@ -39,6 +40,7 @@ class SessionBridge extends EventEmitter {
39
40
  super();
40
41
  this.cwd = cwd;
41
42
  this.sessionId = sessionId || sessions.newId();
43
+ this.title = null; // preenchido no snapshot a partir da 1a mensagem do usuario
42
44
  this.sessionStartedAt = sessionStartedAt || new Date().toISOString();
43
45
  this.messages = Array.isArray(messages) ? messages : [];
44
46
  this.context = Array.isArray(context) ? context : [];
@@ -58,14 +60,23 @@ class SessionBridge extends EventEmitter {
58
60
 
59
61
  /** Snapshot inicial para quem acabou de conectar (historico + estado). */
60
62
  snapshot() {
63
+ // O celular nao sobe o chat inteiro: manda so as ultimas ~20 mensagens
64
+ // (10 do usuario + 10 da IA). O resto fica no PC.
65
+ const trimmed = trimHistory(this.messages);
61
66
  return {
62
67
  sessionId: this.sessionId,
68
+ title:
69
+ this.title ||
70
+ (this.messages.find((m) => m.role === "user")?.text || "").slice(0, 120) ||
71
+ null,
63
72
  model: this.model,
64
73
  cwd: this.cwd,
65
74
  allowAll: this.allowAll,
66
75
  busy: this.busy,
67
76
  streaming: this.streaming,
68
- messages: this.messages,
77
+ messages: trimmed.messages,
78
+ historyTruncated: trimmed.truncated,
79
+ historyTotal: trimmed.total,
69
80
  context: this.context,
70
81
  pendingApproval: this.pendingApproval
71
82
  ? {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Captcha proprio do kafox mobile — sem dependencia externa (sem node:crypto
3
+ * extra, sem servico de terceiros). Desafio aritmetico simples, renderizado
4
+ * como SVG inline no cliente, resolvido no servidor.
5
+ *
6
+ * Modelo de seguranca:
7
+ * - cada desafio e de USO UNICO e expira em CAPTCHA_TTL_MS;
8
+ * - a resposta fica guardada como hash (sha256), nunca em texto;
9
+ * - apos K tentativas o desafio morre (evita forca bruta);
10
+ * - limpeza oportunista dos expirados a cada emissao.
11
+ *
12
+ * Nao substitui Cloudflare Turnstile/hCaptcha, mas corta bots ingenuos e
13
+ * scripts que so reenviam o codigo do QR, que e o suficiente aqui.
14
+ */
15
+
16
+ const crypto = require("crypto");
17
+
18
+ const CAPTCHA_TTL_MS = 3 * 60 * 1000; // 3 minutos
19
+ const MAX_ATTEMPTS = 5;
20
+
21
+ /** @type {Map<string, {hash:string, expires:number, tries:number}>} */
22
+ const store = new Map();
23
+
24
+ function hash(text) {
25
+ return crypto.createHash("sha256").update(String(text).trim().toLowerCase()).digest("hex");
26
+ }
27
+
28
+ function sweep() {
29
+ const now = Date.now();
30
+ for (const [id, entry] of store) {
31
+ if (entry.expires <= now) store.delete(id);
32
+ }
33
+ }
34
+
35
+ function randomInt(min, max) {
36
+ // inteiro uniforme em [min, max] usando bytes aleatorios
37
+ const span = max - min + 1;
38
+ const buf = crypto.randomBytes(4);
39
+ const n = buf.readUInt32BE(0);
40
+ return min + (n % span);
41
+ }
42
+
43
+ /**
44
+ * Emite um novo desafio.
45
+ * @returns {{ id:string, question:string, expiresAt:number }}
46
+ */
47
+ function issue() {
48
+ sweep();
49
+ const a = randomInt(2, 9);
50
+ const b = randomInt(2, 9);
51
+ const usePlus = randomInt(0, 1) === 0;
52
+ let answer;
53
+ let question;
54
+ if (usePlus) {
55
+ answer = a + b;
56
+ question = `${a} + ${b} = ?`;
57
+ } else {
58
+ // subtracao sempre positiva
59
+ const hi = Math.max(a, b);
60
+ const lo = Math.min(a, b);
61
+ answer = hi - lo;
62
+ question = `${hi} - ${lo} = ?`;
63
+ }
64
+ const id = crypto.randomBytes(16).toString("hex");
65
+ store.set(id, { hash: hash(answer), expires: Date.now() + CAPTCHA_TTL_MS, tries: 0 });
66
+ return { id, question, expiresAt: Date.now() + CAPTCHA_TTL_MS };
67
+ }
68
+
69
+ /**
70
+ * Verifica e CONSOME o desafio (uso unico).
71
+ * @returns {{ ok:boolean, reason?:string }}
72
+ */
73
+ function verify(id, answer) {
74
+ const entry = store.get(String(id || ""));
75
+ if (!entry) return { ok: false, reason: "captcha expirado ou invalido" };
76
+ if (entry.expires <= Date.now()) {
77
+ store.delete(id);
78
+ return { ok: false, reason: "captcha expirado" };
79
+ }
80
+ entry.tries += 1;
81
+ if (entry.tries > MAX_ATTEMPTS) {
82
+ store.delete(id);
83
+ return { ok: false, reason: "tentativas demais — recarregue o captcha" };
84
+ }
85
+ if (entry.hash !== hash(answer)) {
86
+ return { ok: false, reason: "captcha incorreto" };
87
+ }
88
+ store.delete(id); // uso unico
89
+ return { ok: true };
90
+ }
91
+
92
+ function size() {
93
+ return store.size;
94
+ }
95
+
96
+ module.exports = { issue, verify, size, CAPTCHA_TTL_MS };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Recorte de historico para o celular.
3
+ *
4
+ * Regra do produto: o celular NUNCA sobe o chat inteiro. Sobe no maximo
5
+ * MAX_USER mensagens do usuario + MAX_ASSISTANT respostas da IA (as mais
6
+ * recentes), deixando a conversa leve no primeiro carregamento.
7
+ *
8
+ * O recorte preserva a ordem cronologica e, quando ha varias, prioriza as
9
+ * mensagens recentes de cada papel. Turnos auxiliares (system/erro/compaction)
10
+ * que caiam entre as selecionadas sao mantidos para o contexto fazer sentido.
11
+ */
12
+
13
+ const MAX_USER = 10;
14
+ const MAX_ASSISTANT = 10;
15
+
16
+ /**
17
+ * @param {Array<{role:string}>} messages
18
+ * @param {{ maxUser?:number, maxAssistant?:number }} [opts]
19
+ * @returns {{ messages:Array, truncated:boolean, total:number, shown:number }}
20
+ */
21
+ function trimHistory(messages, opts = {}) {
22
+ const list = Array.isArray(messages) ? messages : [];
23
+ const maxUser = Number.isInteger(opts.maxUser) ? opts.maxUser : MAX_USER;
24
+ const maxAssistant = Number.isInteger(opts.maxAssistant) ? opts.maxAssistant : MAX_ASSISTANT;
25
+
26
+ // Conta quantas ainda devemos MANTER, varrendo do fim pro inicio.
27
+ let userLeft = maxUser;
28
+ let assistantLeft = maxAssistant;
29
+ const keep = new Array(list.length).fill(false);
30
+
31
+ for (let i = list.length - 1; i >= 0; i--) {
32
+ const role = list[i] && list[i].role;
33
+ if (role === "user") {
34
+ if (userLeft > 0) {
35
+ keep[i] = true;
36
+ userLeft -= 1;
37
+ }
38
+ } else if (role === "assistant") {
39
+ if (assistantLeft > 0) {
40
+ keep[i] = true;
41
+ assistantLeft -= 1;
42
+ }
43
+ } else {
44
+ // system / error / compaction: acompanha o vizinho mais proximo que ficou.
45
+ // Mantem provisoriamente; a segunda passada remove orfaos das bordas.
46
+ keep[i] = true;
47
+ }
48
+ }
49
+
50
+ // Remove auxiliares nas bordas que nao tem nenhum user/assistant mantido
51
+ // depois (ou antes) deles — evita abrir o chat com uma linha solta.
52
+ let first = keep.indexOf(true);
53
+ let last = keep.lastIndexOf(true);
54
+ if (first === -1) return { messages: [], truncated: list.length > 0, total: list.length, shown: 0 };
55
+ while (first <= last && !isKeptTurn(list[first])) first++;
56
+ while (last >= first && !isKeptTurn(list[last])) last--;
57
+
58
+ const out = [];
59
+ for (let i = first; i <= last; i++) {
60
+ if (keep[i]) out.push(list[i]);
61
+ }
62
+ const truncated = out.length < list.length;
63
+ return { messages: out, truncated, total: list.length, shown: out.length };
64
+ }
65
+
66
+ function isKeptTurn(m) {
67
+ const role = m && m.role;
68
+ return role === "user" || role === "assistant";
69
+ }
70
+
71
+ module.exports = { trimHistory, MAX_USER, MAX_ASSISTANT };
@@ -309,6 +309,24 @@ async function runMobile({
309
309
  console.log(chalk.dim(`\n${tail}\n`));
310
310
  note(tail);
311
311
 
312
+ // O celular confirmou o modal e se pareou: some com o QR. Em terminal
313
+ // interativo limpamos a tela e redesenhamos sem o QR; no daemon (log em
314
+ // arquivo) so registramos a linha de sucesso. Usa onPaired (evento real),
315
+ // nao polling de activeTokens(), pra nao confundir token antigo do disco
316
+ // com um pareamento novo.
317
+ function onPaired(ev) {
318
+ const who = ev?.device?.name || "celular";
319
+ const ok = `✓ celular pareado (${who}) · abra ${base}`;
320
+ if (process.stdout.isTTY) {
321
+ process.stdout.write("\x1b[2J\x1b[H"); // limpa e volta ao topo
322
+ console.log(chalk.green(`\n${ok}\n`));
323
+ } else {
324
+ console.log(chalk.green(`\n${ok}\n`));
325
+ }
326
+ note(ok);
327
+ }
328
+ pairing.onPaired = onPaired;
329
+
312
330
  await new Promise((resolve) => {
313
331
  const shutdown = () => {
314
332
  pairing.audit("shutdown", {});
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Rate limit do kafox mobile — janela deslizante por chave (IP + rota).
3
+ * Sem dependencia: mapa em memoria, poda periodica, sem timer pendurado.
4
+ *
5
+ * Objetivo: frear brute-force nas rotas publicas (adivinhar pair_code/sessao
6
+ * pela URL). Conta por IP com janela curta e teto generoso o bastante pra um
7
+ * usuario de verdade nem perceber, mas curto o bastante pra inviabilizar
8
+ * varredura de codigos.
9
+ */
10
+
11
+ const DEFAULTS = {
12
+ // POST /pair: tentativas de pareamento por IP.
13
+ pair: { max: 10, windowMs: 60_000 },
14
+ // GET /pair (poll): consulta de status do pedido. Por IP.
15
+ poll: { max: 120, windowMs: 60_000 },
16
+ // GET /captcha: emitir desafios (evita farmar captcha).
17
+ captcha: { max: 30, windowMs: 60_000 },
18
+ // Rotas autenticadas por token: teto por TOKEN (nao so por IP), pra frear
19
+ // quem varre a URL adivinhando sessao mesmo atras de um unico IP.
20
+ auth: { max: 150, windowMs: 60_000 },
21
+ // Tentativas de token INVALIDO por IP: piso curto. Bater aqui = varredura.
22
+ authFail: { max: 20, windowMs: 60_000 },
23
+ // POST /prompt e /approve: acoes que mexem no agente. Por token.
24
+ action: { max: 40, windowMs: 60_000 },
25
+ // GET / (a propria pagina) + qualquer rota nao classificada.
26
+ global: { max: 240, windowMs: 60_000 },
27
+ };
28
+
29
+ class SlideLimiter {
30
+ constructor(now = () => Date.now()) {
31
+ this.buckets = new Map();
32
+ this.now = now;
33
+ this.lastPrune = 0;
34
+ }
35
+
36
+ /**
37
+ * Registra um hit. Devolve { ok, retryAfter } — ok=false quando estourou.
38
+ * @param {string} key ex: "pair:1.2.3.4"
39
+ * @param {{max:number, windowMs:number}} rule
40
+ */
41
+ hit(key, rule) {
42
+ const now = this.now();
43
+ this.maybePrune(now);
44
+ const hits = this.buckets.get(key) || [];
45
+ // descarta timestamps fora da janela (sliding window)
46
+ const cutoff = now - rule.windowMs;
47
+ let i = 0;
48
+ while (i < hits.length && hits[i] <= cutoff) i += 1;
49
+ if (i > 0) hits.splice(0, i);
50
+
51
+ if (hits.length >= rule.max) {
52
+ const retryAfter = Math.max(1, Math.ceil((hits[0] + rule.windowMs - now) / 1000));
53
+ this.buckets.set(key, hits);
54
+ return { ok: false, retryAfter };
55
+ }
56
+ hits.push(now);
57
+ this.buckets.set(key, hits);
58
+ return { ok: true, remaining: rule.max - hits.length };
59
+ }
60
+
61
+ maybePrune(now) {
62
+ if (now - this.lastPrune < 60_000) return;
63
+ this.lastPrune = now;
64
+ const longest = 5 * 60_000;
65
+ for (const [key, hits] of this.buckets) {
66
+ if (!hits.length || hits[hits.length - 1] < now - longest) this.buckets.delete(key);
67
+ }
68
+ }
69
+
70
+ size() {
71
+ return this.buckets.size;
72
+ }
73
+
74
+ reset() {
75
+ this.buckets.clear();
76
+ }
77
+ }
78
+
79
+ module.exports = { SlideLimiter, DEFAULTS };
@@ -17,6 +17,8 @@
17
17
  const http = require("http");
18
18
  const { PairingManager } = require("./auth");
19
19
  const { renderPage } = require("./view");
20
+ const captcha = require("./captcha");
21
+ const { SlideLimiter, DEFAULTS } = require("./ratelimit");
20
22
 
21
23
  const HEARTBEAT_MS = 5_000;
22
24
 
@@ -48,8 +50,38 @@ function json(res, status, body) {
48
50
  function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml = renderPage() } = {}) {
49
51
  if (!bridge) throw new Error("createMobileServer precisa de um SessionBridge");
50
52
 
53
+ const limiter = new SlideLimiter();
51
54
  const sseClients = new Set();
52
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
+
53
85
  function broadcast(event) {
54
86
  const frame = `data: ${JSON.stringify(event)}\n\n`;
55
87
  for (const client of sseClients) {
@@ -67,6 +99,10 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
67
99
  const url = new URL(req.url, "http://localhost");
68
100
  const pathname = url.pathname;
69
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
+
70
106
  // A pagina e o proprio endpoint de pareamento nao exigem token.
71
107
  if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
72
108
  res.writeHead(200, {
@@ -94,30 +130,94 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
94
130
  return;
95
131
  }
96
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.
97
142
  if (req.method === "POST" && pathname === "/pair") {
143
+ if (blocked(req, res, "pair")) return;
98
144
  let body;
99
145
  try {
100
146
  body = JSON.parse((await readBody(req)) || "{}");
101
147
  } catch {
102
148
  return json(res, 400, { error: "json invalido" });
103
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
+ }
104
155
  const code = String(body.code || "").trim().toUpperCase();
105
156
  const device = {
106
157
  ua: String(req.headers["user-agent"] || "").slice(0, 160),
107
158
  ip: String(req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").slice(0, 80),
108
159
  name: String(body.device || "celular").slice(0, 60),
109
160
  };
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() });
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" });
113
205
  }
114
206
 
115
207
  // A partir daqui, exige token de sessao.
116
208
  const token = req.headers["x-kafox-token"] || url.searchParams.get("t");
117
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;
118
213
  return json(res, 401, { error: "sem token valido" });
119
214
  }
120
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
+
121
221
  if (req.method === "GET" && pathname === "/state") {
122
222
  return json(res, 200, bridge.snapshot());
123
223
  }
@@ -195,6 +295,7 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
195
295
  }
196
296
 
197
297
  if (req.method === "POST" && pathname === "/prompt") {
298
+ if (blocked(req, res, "action", tokenKey)) return;
198
299
  let body;
199
300
  try {
200
301
  body = JSON.parse((await readBody(req)) || "{}");
@@ -211,6 +312,7 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
211
312
  }
212
313
 
213
314
  if (req.method === "POST" && pathname === "/approve") {
315
+ if (blocked(req, res, "action", tokenKey)) return;
214
316
  let body;
215
317
  try {
216
318
  body = JSON.parse((await readBody(req)) || "{}");
@@ -71,6 +71,7 @@ class TuiBridge extends EventEmitter {
71
71
  snapshot() {
72
72
  return {
73
73
  sessionId: this.sessionId,
74
+ title: this.state.title || (this.state.messages || []).find((m) => m.role === "user")?.text || null,
74
75
  model: this.state.model || null,
75
76
  cwd: this.cwd || process.cwd(),
76
77
  allowAll: Boolean(this.state.autoApprove),
@@ -167,6 +167,36 @@ function renderPage() {
167
167
  #pairing .code:focus { outline:none; box-shadow:4px 4px 0 0 var(--neon); }
168
168
  #pairing .err { color:var(--err); min-height:1.2em; font-size:12px; }
169
169
  #pairing button { width:min(92vw,420px); }
170
+ /* captcha: desafio em SVG + campo curto, lado a lado */
171
+ #pairing .captcha { display:flex; align-items:center; gap:10px; width:min(92vw,420px); }
172
+ #pairing .captcha .q { flex:1; height:56px; border:2px solid var(--neon); background:#000;
173
+ display:flex; align-items:center; justify-content:center; }
174
+ #pairing .captcha .q svg { display:block; }
175
+ #pairing .captcha .reload { width:44px; height:56px; flex:0 0 auto; border:2px solid var(--neon);
176
+ background:#000; color:var(--neon); font-size:18px; line-height:1; cursor:pointer; padding:0; }
177
+ #pairing .captcha .reload:active { background:var(--neon); color:#000; }
178
+ #pairing input.small { font-size:22px; font-weight:800; letter-spacing:0.2em; text-align:center;
179
+ border:2px solid var(--neon); background:#000; color:var(--neon); padding:12px;
180
+ width:min(92vw,420px); }
181
+ #pairing input.small:focus { outline:none; box-shadow:4px 4px 0 0 var(--neon); }
182
+ #pairing .hint { color:var(--dim); font-size:11px; letter-spacing:0.06em; max-width:34ch; }
183
+
184
+ /* modal de confirmacao de entrada (pedido de pareamento) */
185
+ #confirm { position:fixed; inset:0; background:rgba(0,0,0,0.92); z-index:21; display:none;
186
+ flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:24px;
187
+ text-align:center; }
188
+ #confirm.show { display:flex; }
189
+ #confirm h2 { font-size:16px; text-transform:uppercase; letter-spacing:0.14em; margin:0; color:var(--neon); }
190
+ #confirm p { color:var(--dim); font-size:12.5px; margin:0; max-width:34ch; line-height:1.6; }
191
+ #confirm .chat { color:#fff; font-size:15px; font-weight:700; border:2px solid #fff;
192
+ padding:10px 16px; max-width:min(92vw,420px); word-break:break-word; min-height:1.2em; }
193
+ #confirm .chat:empty { display:none; }
194
+ #confirm .err { color:var(--err); min-height:1.2em; font-size:12.5px; max-width:34ch; }
195
+ #confirm .wait { color:var(--neon); font-size:13px; letter-spacing:0.1em; }
196
+ #confirm button { width:min(92vw,420px); }
197
+ #confirm[data-state="waiting"] #confirmBtn,
198
+ #confirm[data-state="waiting"] #confirmCancel { display:none; }
199
+ #confirm:not([data-state="waiting"]) .wait { display:none; }
170
200
 
171
201
  /* Pensamento recolhido: mostra so a primeira linha como "resumo" clicavel. */
172
202
  .ln.think { cursor:pointer; }
@@ -196,9 +226,24 @@ function renderPage() {
196
226
  <p>se a câmera não leu o QR, digite o código que aparece embaixo dele na tela do PC.</p>
197
227
  <input class="code" id="paircode" inputmode="text" autocomplete="off" autocapitalize="characters"
198
228
  spellcheck="false" placeholder="CÓDIGO" maxlength="12" />
229
+ <div class="hint" id="captchaHint">resolva a conta pra provar que você é humano:</div>
230
+ <div class="captcha">
231
+ <div class="q" id="captchaQ"></div>
232
+ <button class="reload" id="captchaReload" type="button" title="outro desafio" aria-label="outro desafio">↻</button>
233
+ </div>
234
+ <input class="small" id="captchaA" inputmode="numeric" autocomplete="off" placeholder="?" maxlength="3" />
199
235
  <div class="err" id="pairerr"></div>
200
236
  <button class="primary" id="pairbtn">parear</button>
201
237
  </div>
238
+ <div id="confirm">
239
+ <h2>entrar no chat?</h2>
240
+ <p id="confirmText">você vai pedir acesso ao terminal deste PC.</p>
241
+ <div class="chat" id="confirmTitle"></div>
242
+ <div class="err" id="confirmErr"></div>
243
+ <div class="wait" id="confirmWait">aguardando aprovação no PC…</div>
244
+ <button class="primary" id="confirmBtn">entrar</button>
245
+ <button id="confirmCancel">cancelar</button>
246
+ </div>
202
247
  </div>
203
248
  <script>
204
249
  (function () {
@@ -414,30 +459,190 @@ function renderPage() {
414
459
  return BASE.replace(/\\/$/, "") + "/" + String(path).replace(/^\\//, "");
415
460
  }
416
461
 
417
- async function pair(code) {
418
- if (!code) return false;
419
- var res;
462
+ // PASSO 1: pede entrada. NAO recebe token ainda — o dono do terminal aprova.
463
+ // Devolve { requestId, session } ou null.
464
+ async function requestEntry(code) {
465
+ if (!code) return null;
466
+ var res, data;
420
467
  try {
421
468
  res = await fetch(url("pair"), {
422
469
  method: "POST", headers: { "content-type": "application/json" },
423
- body: JSON.stringify({ code: code, device: navigator.platform || "celular" }),
470
+ body: JSON.stringify({
471
+ code: code,
472
+ device: navigator.platform || "celular",
473
+ captchaId: captchaId,
474
+ captchaAnswer: ($("captchaA") && $("captchaA").value.trim()) || "",
475
+ }),
424
476
  });
425
477
  } catch (e) {
426
478
  pairErr("sem ligação com o servidor — confira a URL");
427
- return false;
479
+ return null;
428
480
  }
429
- if (!res.ok) { pairErr("código inválido ou expirado — gere outro no PC"); return false; }
430
- var data = await res.json();
431
- token = data.token;
432
- sessionStorage.setItem("kafox_token", token);
433
- sid.textContent = (data.session && data.session.sessionId) || "";
434
- showPairing(false);
435
- return true;
481
+ try { data = await res.json(); } catch (e) { data = {}; }
482
+ if (!res.ok) {
483
+ if (data && data.captcha) {
484
+ pairErr("captcha incorreto — tente de novo");
485
+ loadCaptcha(); // desafio e de uso unico: pede outro
486
+ } else {
487
+ pairErr((data && data.error) || "código inválido ou expirado — gere outro no PC");
488
+ }
489
+ return null;
490
+ }
491
+ return data;
492
+ }
493
+
494
+ // --- captcha anti-bot (desafio emitido pelo servidor) ---
495
+ var captchaId = "";
496
+ var captchaSvg = null;
497
+
498
+ // Desenha a pergunta como SVG com leve ruido/rotacao (dificulta OCR simples),
499
+ // tudo em texto vetorial — nada de canvas/imagem externa.
500
+ function renderCaptchaSvg(question, seed) {
501
+ var chars = String(question).split("");
502
+ var pad = 10, cw = 15, fs = 24;
503
+ var w = pad * 2 + chars.length * cw;
504
+ var h = 56;
505
+ var parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + " " + h + '">'];
506
+ parts.push('<rect width="' + w + '" height="' + h + '" fill="#000"/>');
507
+ // algumas linhas de ruido
508
+ var rnd = function (n) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed % n; };
509
+ for (var k = 0; k < 4; k++) {
510
+ var x1 = rnd(w), y1 = rnd(h), x2 = rnd(w), y2 = rnd(h);
511
+ parts.push('<line x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '" stroke="#bfa682" stroke-width="1" opacity="0.35"/>');
512
+ }
513
+ for (var i = 0; i < chars.length; i++) {
514
+ var ch = chars[i];
515
+ if (ch === " ") { continue; }
516
+ var rot = (rnd(17) - 8);
517
+ var y = Math.round(h / 2 + 6 + (rnd(7) - 3));
518
+ var x = pad + i * cw;
519
+ parts.push(
520
+ '<text x="' + x + '" y="' + y + '" font-family="monospace" font-size="' + fs +
521
+ '" font-weight="700" fill="#bfa682" transform="rotate(' + rot + " " + x + " " + y + ')">' +
522
+ ch.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") +
523
+ "</text>"
524
+ );
525
+ }
526
+ parts.push("</svg>");
527
+ return parts.join("");
436
528
  }
437
529
 
438
- function showPairing(on) { $("pairing").classList[on ? "add" : "remove"]("show"); }
530
+ async function loadCaptcha() {
531
+ var box = $("captchaQ");
532
+ if (box) box.textContent = "…";
533
+ try {
534
+ var res = await fetch(url("captcha"), { cache: "no-store" });
535
+ var data = await res.json();
536
+ captchaId = data.id || "";
537
+ var seed = 0;
538
+ for (var i = 0; i < captchaId.length; i++) seed = (seed * 31 + captchaId.charCodeAt(i)) & 0x7fffffff;
539
+ if (box) box.innerHTML = renderCaptchaSvg(data.question || "", seed);
540
+ var a = $("captchaA");
541
+ if (a) { a.value = ""; }
542
+ } catch (e) {
543
+ captchaId = "";
544
+ if (box) box.textContent = "falha ao carregar";
545
+ }
546
+ }
547
+
548
+ function showPairing(on) {
549
+ $("pairing").classList[on ? "add" : "remove"]("show");
550
+ if (on && !captchaId) loadCaptcha();
551
+ }
439
552
  function pairErr(msg) { $("pairerr").textContent = msg || ""; }
440
553
 
554
+ // Modal de confirmacao: mostra o titulo do chat e pede "entrar".
555
+ function showConfirm(state, msg) {
556
+ var el = $("confirm");
557
+ if (!state) { el.classList.remove("show"); el.removeAttribute("data-state"); return; }
558
+ $("confirmErr").textContent = msg || "";
559
+ el.setAttribute("data-state", state);
560
+ el.classList.add("show");
561
+ }
562
+ function confirmErr(msg) { $("confirmErr").textContent = msg || ""; }
563
+
564
+ // Pareamento (Opção A): o celular se auto-aprova apos confirmar no modal.
565
+ // 1. pede entrada (sem token) -> cria um pedido PENDENTE no servidor;
566
+ // 2. mostra o modal "entrar no chat? <titulo>" com os botoes entrar/cancelar;
567
+ // ao clicar em "entrar", ESTE navegador chama /pair/decide {approved:true},
568
+ // o token de sessao e emitido para ele e o terminal detecta (some o QR).
569
+ // Cancelar nega o pedido (approved:false).
570
+ // Devolve true se pareou; false se negado/expirado/erro/cancelado.
571
+ function pair(code) {
572
+ if (!code) return false;
573
+ return new Promise(async function (resolve) {
574
+ // garante um captcha valido (fluxo QR nao passa por showPairing).
575
+ if (!captchaId) await loadCaptcha();
576
+ // valida o codigo primeiro (rapido). Se for valido, mostra direto o modal
577
+ // "entrar no chat?" — o proprio usuario confirma no celular.
578
+ var req = await requestEntry(code);
579
+ if (!req || !req.requestId) { showConfirm(null); resolve(false); return; }
580
+ $("confirmTitle").textContent = ((req.session && req.session.title) || req.title || "conversa sem título");
581
+ confirmErr("");
582
+ showConfirm("ask");
583
+
584
+ var settled = false;
585
+ function finish(ok, msg) {
586
+ if (settled) return;
587
+ settled = true;
588
+ $("confirmBtn").onclick = null;
589
+ $("confirmCancel").onclick = null;
590
+ if (ok) { showConfirm(null); resolve(true); }
591
+ else { showConfirm("ask", msg); resolve(false); }
592
+ }
593
+
594
+ function decide(approved) {
595
+ return fetch(url("pair/decide"), {
596
+ method: "POST", headers: { "content-type": "application/json" },
597
+ body: JSON.stringify({ id: req.requestId, approved: approved }),
598
+ }).catch(function () { return null; });
599
+ }
600
+
601
+ $("confirmBtn").onclick = function () {
602
+ $("confirmBtn").disabled = true;
603
+ // aprova explicitamente ("tem certeza?") e espera o token desta sessao.
604
+ (async function () {
605
+ await decide(true);
606
+ var decision;
607
+ while (!settled) {
608
+ decision = await awaitDecisionOnce(req.requestId);
609
+ if (decision.status !== "pending") break;
610
+ await new Promise(function (r) { setTimeout(r, 1000); });
611
+ }
612
+ $("confirmBtn").disabled = false;
613
+ if (decision && decision.status === "approved") finish(true);
614
+ else if (decision && decision.status === "denied") finish(false, "entrada negada — gere outro código");
615
+ else finish(false, "pedido expirou — gere outro código no PC");
616
+ })();
617
+ };
618
+ $("confirmCancel").onclick = function () {
619
+ decide(false);
620
+ finish(false, "cancelado — escaneie o QR ou digite o código de novo");
621
+ };
622
+ });
623
+ }
624
+
625
+ // Uma consulta de status (sem loop), para o modal controlar o polling.
626
+ async function awaitDecisionOnce(requestId) {
627
+ var res;
628
+ try {
629
+ res = await fetch(url("pair") + "?id=" + encodeURIComponent(requestId), { cache: "no-store" });
630
+ } catch (e) {
631
+ return { status: "pending" };
632
+ }
633
+ if (res.status === 404) return { status: "expired" };
634
+ var data = await res.json().catch(function () { return {}; });
635
+ if (data.status === "approved" && data.token) {
636
+ token = data.token;
637
+ sessionStorage.setItem("kafox_token", token);
638
+ sid.textContent = (data.session && data.session.sessionId) || "";
639
+ return { status: "approved" };
640
+ }
641
+ if (data.status === "denied") return { status: "denied" };
642
+ if (data.status === "expired" || data.status === "unknown") return { status: "expired" };
643
+ return { status: "pending" };
644
+ }
645
+
441
646
  // Reconstroi o log a partir do historico (um turno por mensagem do usuario,
442
647
  // com as respostas/tools/pensamentos abaixo).
443
648
  function renderHistory(messages) {
@@ -663,14 +868,17 @@ function renderPage() {
663
868
 
664
869
  async function boot() {
665
870
  var m = location.hash.match(/p=([A-Za-z0-9]+)/);
871
+ var scanned = m ? m[1] : "";
666
872
  if (m) {
667
873
  await pair(m[1]);
668
874
  history.replaceState(null, "", location.pathname);
669
875
  }
670
876
  if (!token) {
671
877
  // Sem token (nem por QR, nem salvo): mostra a tela de pareamento manual.
878
+ // Se veio do QR, deixa o codigo preenchido pra so resolver o captcha.
879
+ if (scanned && !$("paircode").value) $("paircode").value = scanned.toUpperCase();
672
880
  showPairing(true);
673
- $("paircode").focus();
881
+ $("captchaA").focus();
674
882
  return;
675
883
  }
676
884
  var okState = await loadState();
@@ -692,6 +900,7 @@ function renderPage() {
692
900
  }
693
901
  }
694
902
  $("pairbtn").onclick = pairManual;
903
+ $("captchaReload").onclick = function () { pairErr(""); loadCaptcha(); };
695
904
  $("paircode").addEventListener("input", function () {
696
905
  this.value = this.value.toUpperCase().replace(/[^A-Z0-9]/g, "");
697
906
  });
package/src/tui.js CHANGED
@@ -16,7 +16,7 @@ const os = require("os");
16
16
  const path = require("path");
17
17
  const chalk = require("chalk");
18
18
  const storage = require("./storage");
19
- const { estimateTokens: countContextTokens, compactIfNeeded } = require("./compact");
19
+ const { estimateTokens: countContextTokens, compactIfNeeded, trimHistory } = require("./compact");
20
20
  const { ensureSession, fetchApi } = require("./auth");
21
21
  const { runAgent } = require("./agent");
22
22
  const { THEMES, themeColors, paintLine, paintPanel, borderColor } = require("./theme");
@@ -1408,6 +1408,29 @@ function box(state, { title, body, hint, width }) {
1408
1408
 
1409
1409
  function approvalBody(state, width) {
1410
1410
  const { name, args, change } = state.approval;
1411
+
1412
+ // Pedido de pareamento do celular: mesmo widget de opcoes, conteudo proprio.
1413
+ if (name === "__pair__") {
1414
+ const body = [];
1415
+ const who = change?.device || "celular";
1416
+ body.push(chalk.bold("Um celular quer entrar nesta conversa."));
1417
+ body.push("");
1418
+ if (change?.title) body.push(truncate("conversa: " + change.title, width - 4));
1419
+ if (change?.base) body.push(chalk.dim(truncate(change.base, width - 4)));
1420
+ body.push(chalk.dim(truncate("dispositivo: " + who, width - 4)));
1421
+ body.push("");
1422
+ const optionsStart = body.length;
1423
+ const opts = state.approval.options || APPROVAL_OPTIONS;
1424
+ for (const [index, option] of opts.entries()) {
1425
+ const selected = index === state.approval.index;
1426
+ const line = padRight(`${selected ? "▸ " : " "}${option.label}`, width - 6);
1427
+ if (selected) body.push(chalk.inverse(chalk.bold(line)));
1428
+ else if (option.id === "deny") body.push(chalk.dim(line));
1429
+ else body.push(line);
1430
+ }
1431
+ return { title: "Pareamento do celular", body, optionsStart };
1432
+ }
1433
+
1411
1434
  const meta = toolMeta(name);
1412
1435
  const body = [];
1413
1436
 
@@ -2420,14 +2443,14 @@ async function runTUI({
2420
2443
  text: "Interrompido por você (Esc).",
2421
2444
  interrupted: true,
2422
2445
  });
2423
- if (result.messages) state.context = result.messages;
2446
+ if (result.messages) state.context = trimHistory(result.messages);
2424
2447
  } else if (result.ok) {
2425
2448
  const text = String(result.text || "").trim();
2426
2449
  // O stream ja foi commitado em finishStreaming(): so empurra aqui se for
2427
2450
  // texto novo (ou se nao houve streaming, ex.: resposta sem onText).
2428
2451
  if (text) commitFinalText(state, text, result.ms);
2429
2452
  state.streamedText = "";
2430
- if (result.messages) state.context = result.messages;
2453
+ if (result.messages) state.context = trimHistory(result.messages);
2431
2454
  // Reconectou de verdade: limpa o alerta e os erros de rede pendentes.
2432
2455
  clearRetry();
2433
2456
  } else {
@@ -2510,10 +2533,15 @@ async function runTUI({
2510
2533
  const pending = state.approval;
2511
2534
  if (!pending) return;
2512
2535
  state.approval = null;
2513
- if (decision === "always") state.autoApprove = true;
2514
- if (decision === "always") persist();
2536
+ // O pedido de pareamento NAO mexe no autoApprove de ferramentas: "lembrar"
2537
+ // ali significa confiar naquele navegador, tratado por quem criou o pedido.
2538
+ const isPair = pending.name === "__pair__";
2539
+ if (decision === "always" && !isPair) state.autoApprove = true;
2540
+ if (decision === "always" && !isPair) persist();
2515
2541
  render();
2516
- pending.resolve(decision !== "deny");
2542
+ // Pareamento precisa do texto da decisao (once/always/deny); ferramentas
2543
+ // so querem o booleano "aprovado?".
2544
+ pending.resolve(isPair ? decision : decision !== "deny");
2517
2545
  }
2518
2546
 
2519
2547
  // Com captura de mouse ligada o terminal entrega a roda para a TUI, mas em
@@ -2593,7 +2621,8 @@ async function runTUI({
2593
2621
  }
2594
2622
 
2595
2623
  function moveApproval(delta) {
2596
- const count = APPROVAL_OPTIONS.length;
2624
+ const opts = state.approval.options || APPROVAL_OPTIONS;
2625
+ const count = opts.length;
2597
2626
  state.approval.index = (state.approval.index + delta + count) % count;
2598
2627
  render();
2599
2628
  }
@@ -2610,10 +2639,11 @@ async function runTUI({
2610
2639
 
2611
2640
  // Aprovacao de ferramenta: navega e confirma.
2612
2641
  if (state.approval) {
2642
+ const opts = state.approval.options || APPROVAL_OPTIONS;
2613
2643
  if (key.name === "up" || key.name === "down" || key.name === "tab") {
2614
2644
  return moveApproval(key.name === "up" ? -1 : 1);
2615
2645
  }
2616
- if (key.name === "return") return settleApproval(APPROVAL_OPTIONS[state.approval.index].id);
2646
+ if (key.name === "return") return settleApproval(opts[state.approval.index].id);
2617
2647
  if (key.name === "escape") return settleApproval("deny");
2618
2648
  if (key.name === "a") return settleApproval("always");
2619
2649
  if (key.name === "y") return settleApproval("once");