terminal-smart-cli 0.98.0 → 0.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/bin/ts.js +344 -196
- package/hub/cli.js +255 -0
- package/hub/controle.js +262 -0
- package/hub/dpapi.js +55 -0
- package/hub/estado.js +274 -0
- package/hub/exportar-telas.js +149 -0
- package/hub/hub.js +717 -0
- package/hub/mascara.js +67 -0
- package/hub/parar.js +151 -0
- package/hub/principal.js +5 -0
- package/hub/proto/PROTOCOLO.md +789 -0
- package/hub/proto/ts-proto.js +1643 -0
- package/hub/relay-conexao.js +200 -0
- package/hub/telas-arquivo.js +130 -0
- package/hub/tmux.js +189 -0
- package/hub/vps/README.md +109 -0
- package/hub/vps/tmux-agentes.conf +5 -0
- package/hub/vps/ts-hub-exportar-telas.service +30 -0
- package/hub/vps/ts-hub-parar-consumir +67 -0
- package/hub/vps/ts-hub-parar.path +16 -0
- package/hub/vps/ts-hub-parar.service +23 -0
- package/hub/vps/ts-hub-parar.tmpfiles.conf +10 -0
- package/hub/vps/ts-hub.service +39 -0
- package/hub/vps/ts-tmux.service +19 -0
- package/lib/acp.js +8 -2
- package/lib/agent.js +186 -87
- package/lib/chat-local.js +139 -0
- package/lib/core.js +336 -23
- package/lib/doctor.js +13 -5
- package/lib/i18n.js +69 -31
- package/lib/ia-local.js +388 -0
- package/lib/intelligence-core.js +108 -1
- package/lib/keyring.js +0 -1
- package/lib/meta.js +8 -8
- package/lib/personal-agent-llm.js +99 -26
- package/lib/sentinela.js +2 -2
- package/lib/tools.js +638 -8
- package/package.json +4 -2
package/hub/estado.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Estado em disco do `ts hub`: diretório privado, chave do nó, lista de aparelhos e auditoria.
|
|
3
|
+
//
|
|
4
|
+
// <TS_HUB_DIR>/ 0700
|
|
5
|
+
// no-chave.json 0600 chave do nó (Windows: PKCS#8 cifrado com DPAPI)
|
|
6
|
+
// aparelhos.json 0600 aparelhos autorizados + versão da lista publicada
|
|
7
|
+
// lista-versao.json 0600 cópia da maior versão publicada (fora do aparelhos.json)
|
|
8
|
+
// revogados.jsonl 0600 revogações (só acrescenta): ID + instante
|
|
9
|
+
// auditoria.jsonl 0600 comando, aparelho, resultado (nunca chave nem tela)
|
|
10
|
+
// controle.json 0600 endereço do controle local + ficha (só o mesmo usuário)
|
|
11
|
+
// controle.sock 0600 (Linux) socket do controle local
|
|
12
|
+
//
|
|
13
|
+
// No Windows o modo 0600/0700 não vale nada: a proteção é a ACL herdada do perfil do usuário
|
|
14
|
+
// e, para a chave, o DPAPI.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const P = require('./proto/ts-proto.js');
|
|
21
|
+
|
|
22
|
+
const POSIX = process.platform !== 'win32';
|
|
23
|
+
const AUDITORIA_MAX = 5 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
function dirPadrao() {
|
|
26
|
+
return process.env.TS_HUB_DIR || path.join(os.homedir(), '.ts', 'hub');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Cria o diretório com 0700 e corrige o modo se alguém o abriu. */
|
|
30
|
+
function garantirDir(dir) {
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
if (POSIX) {
|
|
33
|
+
const st = fs.statSync(dir);
|
|
34
|
+
if ((st.mode & 0o077) !== 0) fs.chmodSync(dir, 0o700);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nomeTemporario(arq) {
|
|
39
|
+
return arq + '.' + crypto.randomBytes(6).toString('hex') + '.tmp';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Grava com 0600 de forma atômica (temporário + rename). */
|
|
43
|
+
function gravarPrivado(arq, conteudo) {
|
|
44
|
+
const tmp = nomeTemporario(arq);
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(tmp, conteudo, { mode: 0o600, flag: 'wx' });
|
|
47
|
+
if (POSIX) fs.chmodSync(tmp, 0o600);
|
|
48
|
+
fs.renameSync(tmp, arq);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Grava só se o arquivo ainda não existe (devolve false se outro processo chegou antes). */
|
|
56
|
+
function criarPrivadoExclusivo(arq, conteudo) {
|
|
57
|
+
const tmp = nomeTemporario(arq);
|
|
58
|
+
fs.writeFileSync(tmp, conteudo, { mode: 0o600, flag: 'wx' });
|
|
59
|
+
try {
|
|
60
|
+
if (POSIX) fs.chmodSync(tmp, 0o600);
|
|
61
|
+
fs.linkSync(tmp, arq);
|
|
62
|
+
return true;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (e.code === 'EEXIST') return false;
|
|
65
|
+
throw e;
|
|
66
|
+
} finally {
|
|
67
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Avisa e corrige arquivo privado que ficou legível por outros (só POSIX). */
|
|
72
|
+
function corrigirModo(arq, avisar) {
|
|
73
|
+
if (!POSIX) return;
|
|
74
|
+
const st = fs.statSync(arq);
|
|
75
|
+
if ((st.mode & 0o077) !== 0) {
|
|
76
|
+
fs.chmodSync(arq, 0o600);
|
|
77
|
+
if (avisar) avisar('permissão de ' + path.basename(arq) + ' estava aberta; corrigida para 0600');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function lerJson(arq) {
|
|
82
|
+
return JSON.parse(fs.readFileSync(arq, 'utf8'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Chave do nó ────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function protetorPadrao() {
|
|
88
|
+
return process.platform === 'win32' ? require('./dpapi').dpapi : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Carrega a identidade do nó ou gera na primeira vez.
|
|
93
|
+
* `protetor`: { nome, proteger(bytes), abrir(bytes) } (DPAPI no Windows) ou null (arquivo 0600).
|
|
94
|
+
*/
|
|
95
|
+
async function carregarOuCriarIdentidade(dir, opcoes) {
|
|
96
|
+
const o = opcoes || {};
|
|
97
|
+
const protetor = o.protetor === undefined ? protetorPadrao() : o.protetor;
|
|
98
|
+
const arq = path.join(dir, 'no-chave.json');
|
|
99
|
+
|
|
100
|
+
const carregar = async () => {
|
|
101
|
+
corrigirModo(arq, o.avisar);
|
|
102
|
+
const j = lerJson(arq);
|
|
103
|
+
if (!j || j.v !== 1 || typeof j.spki !== 'string') throw new Error('arquivo da chave do nó inválido');
|
|
104
|
+
let dados;
|
|
105
|
+
if (j.protecao === 'arquivo') {
|
|
106
|
+
dados = { pkcs8: j.pkcs8, spki: j.spki };
|
|
107
|
+
} else if (typeof j.protecao === 'string' && typeof j.dados === 'string') {
|
|
108
|
+
if (!protetor || protetor.nome !== j.protecao) throw new Error('a chave do nó está protegida com "' + j.protecao + '", indisponível aqui');
|
|
109
|
+
const claro = await protetor.abrir(Buffer.from(String(j.dados), 'base64'));
|
|
110
|
+
try { dados = JSON.parse(claro.toString('utf8')); } finally { claro.fill(0); }
|
|
111
|
+
}
|
|
112
|
+
if (!dados) {
|
|
113
|
+
throw new Error('proteção da chave do nó desconhecida');
|
|
114
|
+
}
|
|
115
|
+
const ident = await P.importarIdentidadeNo(dados);
|
|
116
|
+
if (ident.spkiB64 !== j.spki) throw new Error('chave do nó não confere com a pública gravada');
|
|
117
|
+
return ident;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (fs.existsSync(arq)) return { identidade: await carregar(), criada: false };
|
|
121
|
+
|
|
122
|
+
const ident = await P.gerarIdentidade({ tipo: 'no' });
|
|
123
|
+
const exp = await P.exportarIdentidadeNo(ident);
|
|
124
|
+
let registro;
|
|
125
|
+
if (protetor) {
|
|
126
|
+
const claro = Buffer.from(JSON.stringify(exp), 'utf8');
|
|
127
|
+
try {
|
|
128
|
+
const blob = await protetor.proteger(claro);
|
|
129
|
+
registro = { v: 1, protecao: protetor.nome, spki: exp.spki, dados: Buffer.from(blob).toString('base64') };
|
|
130
|
+
} finally { claro.fill(0); }
|
|
131
|
+
} else {
|
|
132
|
+
registro = { v: 1, protecao: 'arquivo', spki: exp.spki, pkcs8: exp.pkcs8 };
|
|
133
|
+
}
|
|
134
|
+
if (!criarPrivadoExclusivo(arq, JSON.stringify(registro) + '\n')) {
|
|
135
|
+
// Outro processo gerou ao mesmo tempo: vale a dele.
|
|
136
|
+
return { identidade: await carregar(), criada: false };
|
|
137
|
+
}
|
|
138
|
+
return { identidade: ident, criada: true };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Aparelhos autorizados ──────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
const RX_ID = /^[A-Za-z0-9_-]{22}$/;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Aparelhos autorizados. Restaurar um backup antigo de aparelhos.json traria de volta um
|
|
147
|
+
* aparelho já revogado; por isso a revogação também fica em revogados.jsonl (só acrescenta) e
|
|
148
|
+
* a maior versão da lista em lista-versao.json, os dois FORA do aparelhos.json. Ao carregar:
|
|
149
|
+
* aparelho revogado depois do próprio pareamento continua fora, e uma lista mais velha que a
|
|
150
|
+
* última publicada é sinalizada (`restauradoDeBackup`). Quem restaura o diretório inteiro
|
|
151
|
+
* desfaz isso também: é proteção contra restauração parcial/acidental, não contra quem
|
|
152
|
+
* controla o disco (PROTOCOLO §12).
|
|
153
|
+
*/
|
|
154
|
+
class Aparelhos {
|
|
155
|
+
constructor(dir) {
|
|
156
|
+
this.dir = dir;
|
|
157
|
+
this.arq = path.join(dir, 'aparelhos.json');
|
|
158
|
+
this.arqVersao = path.join(dir, 'lista-versao.json');
|
|
159
|
+
this.arqRevogados = path.join(dir, 'revogados.jsonl');
|
|
160
|
+
this.versaoLista = 0;
|
|
161
|
+
this.restauradoDeBackup = false;
|
|
162
|
+
this.mantidosRevogados = [];
|
|
163
|
+
this._mapa = new Map();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Revogações gravadas: Map(id → instante ms da revogação mais recente). */
|
|
167
|
+
_lerRevogados() {
|
|
168
|
+
const m = new Map();
|
|
169
|
+
let texto = '';
|
|
170
|
+
try { texto = fs.readFileSync(this.arqRevogados, 'utf8'); } catch (_) { return m; }
|
|
171
|
+
for (const linha of texto.split('\n')) {
|
|
172
|
+
if (!linha.trim()) continue;
|
|
173
|
+
let r;
|
|
174
|
+
try { r = JSON.parse(linha); } catch (_) { continue; }
|
|
175
|
+
if (r && RX_ID.test(r.id) && Number.isSafeInteger(r.em)) m.set(r.id, Math.max(m.get(r.id) || 0, r.em));
|
|
176
|
+
}
|
|
177
|
+
return m;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
registrarRevogacao(id, em) {
|
|
181
|
+
fs.appendFileSync(this.arqRevogados, JSON.stringify({ id, em: em || Date.now() }) + '\n', { mode: 0o600 });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async carregar(avisar) {
|
|
185
|
+
let externa = 0;
|
|
186
|
+
try {
|
|
187
|
+
const v = lerJson(this.arqVersao);
|
|
188
|
+
if (v && Number.isSafeInteger(v.versao)) externa = v.versao;
|
|
189
|
+
} catch (_) { /* sem cópia externa ainda */ }
|
|
190
|
+
if (!fs.existsSync(this.arq)) {
|
|
191
|
+
if (externa > 0) { this.restauradoDeBackup = true; this.versaoLista = externa; if (avisar) avisar('aparelhos.json sumiu, mas já houve lista publicada (versão ' + externa + ')'); }
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
corrigirModo(this.arq, avisar);
|
|
195
|
+
const j = lerJson(this.arq);
|
|
196
|
+
if (!j || j.v !== 1 || !Array.isArray(j.aparelhos) || !Number.isSafeInteger(j.versaoLista)) {
|
|
197
|
+
throw new Error('aparelhos.json inválido');
|
|
198
|
+
}
|
|
199
|
+
this.versaoLista = j.versaoLista;
|
|
200
|
+
for (const a of j.aparelhos) {
|
|
201
|
+
if (!a || !RX_ID.test(a.id) || typeof a.spki !== 'string') throw new Error('aparelhos.json inválido');
|
|
202
|
+
if ((await P.idDeSpki(P.deB64u(a.spki))) !== a.id) throw new Error('aparelhos.json inválido');
|
|
203
|
+
this._mapa.set(a.id, {
|
|
204
|
+
id: a.id, spki: a.spki, nome: String(a.nome || ''), impressao: String(a.impressao || ''),
|
|
205
|
+
pareadoEm: String(a.pareadoEm || ''),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
// Revogado depois do próprio pareamento continua revogado (backup antigo não o traz de volta).
|
|
209
|
+
const revogados = this._lerRevogados();
|
|
210
|
+
for (const [id, em] of revogados) {
|
|
211
|
+
const a = this._mapa.get(id);
|
|
212
|
+
if (!a) continue;
|
|
213
|
+
const pareado = Date.parse(a.pareadoEm);
|
|
214
|
+
if (!Number.isFinite(pareado) || pareado <= em) { this._mapa.delete(id); this.mantidosRevogados.push(id); }
|
|
215
|
+
}
|
|
216
|
+
if (externa > this.versaoLista) {
|
|
217
|
+
this.restauradoDeBackup = true;
|
|
218
|
+
this.versaoLista = externa; // a versão publicada nunca anda para trás
|
|
219
|
+
}
|
|
220
|
+
if ((this.restauradoDeBackup || this.mantidosRevogados.length) && avisar) {
|
|
221
|
+
avisar('aparelhos.json mais velho que o estado gravado (backup restaurado?): ' + this.mantidosRevogados.length + ' aparelho(s) revogado(s) continuam revogados');
|
|
222
|
+
}
|
|
223
|
+
if (this.mantidosRevogados.length) this.salvar();
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
salvar() {
|
|
228
|
+
const aparelhos = Array.from(this._mapa.values()).sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
|
|
229
|
+
gravarPrivado(this.arq, JSON.stringify({ v: 1, versaoLista: this.versaoLista, aparelhos }, null, 2) + '\n');
|
|
230
|
+
gravarPrivado(this.arqVersao, JSON.stringify({ v: 1, versao: this.versaoLista }) + '\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
get tamanho() { return this._mapa.size; }
|
|
234
|
+
tem(id) { return typeof id === 'string' && this._mapa.has(id); }
|
|
235
|
+
spki(id) { const a = typeof id === 'string' ? this._mapa.get(id) : undefined; return a ? a.spki : undefined; }
|
|
236
|
+
obter(id) { return this._mapa.get(id); }
|
|
237
|
+
lista() { return Array.from(this._mapa.values()).sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0)); }
|
|
238
|
+
adicionar(a) { this._mapa.set(a.id, Object.assign({}, a)); }
|
|
239
|
+
remover(id) { return this._mapa.delete(id); }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── Auditoria ──────────────────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Uma linha JSON por evento. Campos permitidos: tipo, metodo, aparelho, resultado.
|
|
246
|
+
* Nunca chaves, URL de convite, parâmetros livres nem conteúdo de tela.
|
|
247
|
+
*/
|
|
248
|
+
function criarAuditoria(dir) {
|
|
249
|
+
const arq = path.join(dir, 'auditoria.jsonl');
|
|
250
|
+
return function auditar(ev) {
|
|
251
|
+
const linha = { em: new Date().toISOString(), tipo: String(ev.tipo || '') };
|
|
252
|
+
if (ev.metodo !== undefined) linha.metodo = ev.metodo === null ? null : String(ev.metodo).slice(0, 64);
|
|
253
|
+
if (ev.aparelho !== undefined) linha.aparelho = RX_ID.test(String(ev.aparelho)) ? ev.aparelho : null;
|
|
254
|
+
if (ev.resultado !== undefined) linha.resultado = String(ev.resultado).slice(0, 48);
|
|
255
|
+
try {
|
|
256
|
+
try {
|
|
257
|
+
if (fs.statSync(arq).size > AUDITORIA_MAX) fs.renameSync(arq, path.join(dir, 'auditoria.1.jsonl'));
|
|
258
|
+
} catch (_) {}
|
|
259
|
+
fs.appendFileSync(arq, JSON.stringify(linha) + '\n', { mode: 0o600 });
|
|
260
|
+
} catch (_) { /* auditoria nunca derruba o hub */ }
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = {
|
|
265
|
+
POSIX,
|
|
266
|
+
dirPadrao,
|
|
267
|
+
garantirDir,
|
|
268
|
+
gravarPrivado,
|
|
269
|
+
corrigirModo,
|
|
270
|
+
lerJson,
|
|
271
|
+
carregarOuCriarIdentidade,
|
|
272
|
+
Aparelhos,
|
|
273
|
+
criarAuditoria,
|
|
274
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// `ts hub exportar-telas`: lê o tmux dos agentes e grava instantâneos para o hub ler.
|
|
3
|
+
//
|
|
4
|
+
// Roda como o USUÁRIO DOS AGENTES (quem já enxerga o tmux), nunca como o hub. Assim o hub não
|
|
5
|
+
// tem acesso ao tmux: só lê arquivos prontos (hub/telas-arquivo.js).
|
|
6
|
+
//
|
|
7
|
+
// - tmux por execFile, sem shell, sempre no servidor dedicado `-L ts`;
|
|
8
|
+
// - só janelas da lista permitida (TS_HUB_TELAS_PERMITIDAS: "sessao:janela" ou "sessao:*",
|
|
9
|
+
// separados por vírgula; sem lista, nada é exportado);
|
|
10
|
+
// - histórico de no máximo 200 linhas (TS_HUB_TELAS_HISTORICO, padrão 200);
|
|
11
|
+
// - só SGR sobrevive (sanitizarTela) e os segredos conhecidos são mascarados;
|
|
12
|
+
// - gravação atômica (temporário + rename), índice por último, telas velhas apagadas.
|
|
13
|
+
//
|
|
14
|
+
// Variáveis: TS_HUB_TELAS_DIR (obrigatória), TS_HUB_TELAS_PERMITIDAS (obrigatória),
|
|
15
|
+
// TS_HUB_TELAS_HISTORICO (0–200), TS_HUB_TELAS_INTERVALO_MS (padrão 1000, mínimo 250).
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const P = require('./proto/ts-proto.js');
|
|
21
|
+
const T = require('./tmux');
|
|
22
|
+
const { mascararSegredos } = require('./mascara');
|
|
23
|
+
const { INDICE, arquivoTela, RX_ID, TELA_ARQUIVO_MAX } = require('./telas-arquivo');
|
|
24
|
+
|
|
25
|
+
const RX_ENTRADA = /^[^:,\s][^:,]{0,79}:(\*|[^,]{1,80})$/;
|
|
26
|
+
const TELA_BYTES_MAX = Math.min(256 * 1024, TELA_ARQUIVO_MAX - 4096);
|
|
27
|
+
|
|
28
|
+
function erroConfig(msg) { return Object.assign(new Error(msg), { codigo: 'E_CONFIG' }); }
|
|
29
|
+
|
|
30
|
+
/** "agentes:claude, agentes:*" → [{ sessao, janela|null }]. Lança em entrada malformada. */
|
|
31
|
+
function lerPermitidas(texto) {
|
|
32
|
+
if (typeof texto !== 'string' || !texto.trim()) return [];
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const parte of texto.split(',')) {
|
|
35
|
+
const e = parte.trim();
|
|
36
|
+
if (!e) continue;
|
|
37
|
+
if (!RX_ENTRADA.test(e)) throw erroConfig('TS_HUB_TELAS_PERMITIDAS: entrada inválida (use sessao:janela ou sessao:*)');
|
|
38
|
+
const i = e.indexOf(':');
|
|
39
|
+
const janela = e.slice(i + 1);
|
|
40
|
+
out.push({ sessao: e.slice(0, i), janela: janela === '*' ? null : janela });
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function permitida(lista, t) {
|
|
46
|
+
return lista.some(p => p.sessao === t.sessao && (p.janela === null || p.janela === t.janela));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lerConfig(env) {
|
|
50
|
+
const e = env || process.env;
|
|
51
|
+
const dir = (e.TS_HUB_TELAS_DIR || '').trim();
|
|
52
|
+
if (!dir) throw erroConfig('defina TS_HUB_TELAS_DIR (diretório dos instantâneos que o hub lê)');
|
|
53
|
+
const permitidas = lerPermitidas(e.TS_HUB_TELAS_PERMITIDAS);
|
|
54
|
+
if (!permitidas.length) throw erroConfig('defina TS_HUB_TELAS_PERMITIDAS (ex.: agentes:claude,agentes:codex); sem lista nada é exportado');
|
|
55
|
+
let historico = P.LIMITES.HISTORICO_MAX;
|
|
56
|
+
if (e.TS_HUB_TELAS_HISTORICO !== undefined && e.TS_HUB_TELAS_HISTORICO !== '') {
|
|
57
|
+
historico = Number(e.TS_HUB_TELAS_HISTORICO);
|
|
58
|
+
if (!Number.isSafeInteger(historico) || historico < 0 || historico > P.LIMITES.HISTORICO_MAX) {
|
|
59
|
+
throw erroConfig('TS_HUB_TELAS_HISTORICO deve ser um inteiro de 0 a ' + P.LIMITES.HISTORICO_MAX);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
let intervaloMs = 1000;
|
|
63
|
+
if (e.TS_HUB_TELAS_INTERVALO_MS) {
|
|
64
|
+
intervaloMs = Number(e.TS_HUB_TELAS_INTERVALO_MS);
|
|
65
|
+
if (!Number.isSafeInteger(intervaloMs) || intervaloMs < 250 || intervaloMs > 60000) throw erroConfig('TS_HUB_TELAS_INTERVALO_MS deve ser de 250 a 60000');
|
|
66
|
+
}
|
|
67
|
+
return { dir: path.resolve(dir), permitidas, historico, intervaloMs };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function garantirDir(dir) {
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o750 });
|
|
72
|
+
const st = fs.lstatSync(dir);
|
|
73
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw erroConfig('TS_HUB_TELAS_DIR não pode ser link simbólico');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Grava atômico: temporário exclusivo no mesmo diretório + rename (nunca segue link). */
|
|
77
|
+
function gravarAtomico(dir, nome, conteudo) {
|
|
78
|
+
const tmp = path.join(dir, '.tmp-' + crypto.randomBytes(8).toString('hex'));
|
|
79
|
+
try {
|
|
80
|
+
fs.writeFileSync(tmp, conteudo, { mode: 0o640, flag: 'wx' });
|
|
81
|
+
fs.renameSync(tmp, path.join(dir, nome));
|
|
82
|
+
} catch (e) {
|
|
83
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Tela pronta para sair da máquina: só SGR, segredos mascarados, tamanho limitado. */
|
|
89
|
+
function prepararTela(bruta) {
|
|
90
|
+
const limpa = mascararSegredos(T.sanitizarTela(bruta));
|
|
91
|
+
return T.cortarTela(limpa, t => Buffer.byteLength(t, 'utf8') <= TELA_BYTES_MAX);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Uma passada. opcoes: { dir, permitidas, historico, executar?, agora? }.
|
|
96
|
+
* Devolve { exportados: [terminalId], removidos: n }.
|
|
97
|
+
*/
|
|
98
|
+
async function exportarUmaVez(opcoes) {
|
|
99
|
+
const o = opcoes;
|
|
100
|
+
const executar = o.executar || T.executorPadrao;
|
|
101
|
+
const agora = o.agora || Date.now;
|
|
102
|
+
const historico = Math.max(0, Math.min(P.LIMITES.HISTORICO_MAX, o.historico === undefined ? P.LIMITES.HISTORICO_MAX : o.historico));
|
|
103
|
+
const tmux = args => executar('tmux', ['-u', '-L', T.SOCKET_TMUX].concat(args));
|
|
104
|
+
garantirDir(o.dir);
|
|
105
|
+
|
|
106
|
+
let lista = [];
|
|
107
|
+
try { lista = T.analisarLista(await tmux(['list-panes', '-a', '-F', T.FORMATO])); } catch (_) { lista = []; }
|
|
108
|
+
const escolhidos = lista.filter(t => RX_ID.test(t.terminalId) && T.RX_TERMINAL_TMUX.test(t.terminalId) && permitida(o.permitidas, t)).slice(0, 100);
|
|
109
|
+
|
|
110
|
+
const indice = [];
|
|
111
|
+
for (const t of escolhidos) {
|
|
112
|
+
let bruta;
|
|
113
|
+
try {
|
|
114
|
+
bruta = await tmux(['capture-pane', '-p', '-e', '-J', '-t', '%' + t.terminalId.slice(1), '-S', '-' + historico]);
|
|
115
|
+
} catch (_) { continue; }
|
|
116
|
+
const registro = {
|
|
117
|
+
v: 1, terminalId: t.terminalId, colunas: t.colunas, linhas: t.linhas, historico, geradoEm: agora(), tela: prepararTela(bruta),
|
|
118
|
+
};
|
|
119
|
+
gravarAtomico(o.dir, arquivoTela(t.terminalId), JSON.stringify(registro) + '\n');
|
|
120
|
+
indice.push({ terminalId: t.terminalId, titulo: t.titulo, ativo: t.ativo, colunas: t.colunas, linhas: t.linhas });
|
|
121
|
+
}
|
|
122
|
+
gravarAtomico(o.dir, INDICE, JSON.stringify({ v: 1, geradoEm: agora(), terminais: indice }) + '\n');
|
|
123
|
+
|
|
124
|
+
// Telas de terminais que sumiram (ou saíram da lista permitida) não ficam para trás.
|
|
125
|
+
const atuais = new Set(indice.map(t => arquivoTela(t.terminalId)));
|
|
126
|
+
let removidos = 0;
|
|
127
|
+
for (const n of fs.readdirSync(o.dir)) {
|
|
128
|
+
if (/^tela-[A-Za-z0-9_-]{1,64}\.json$/.test(n) && !atuais.has(n)) {
|
|
129
|
+
try { fs.unlinkSync(path.join(o.dir, n)); removidos++; } catch (_) {}
|
|
130
|
+
} else if (/^\.tmp-[0-9a-f]{16}$/.test(n)) {
|
|
131
|
+
try { if (agora() - fs.lstatSync(path.join(o.dir, n)).mtimeMs > 60000) fs.unlinkSync(path.join(o.dir, n)); } catch (_) {}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { exportados: indice.map(t => t.terminalId), removidos };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Laço contínuo até `sinal.parar` (ou SIGINT/SIGTERM no processo de verdade). */
|
|
138
|
+
async function exportarTelas(opcoes) {
|
|
139
|
+
const o = opcoes;
|
|
140
|
+
const sinal = o.sinal || { parar: false };
|
|
141
|
+
for (;;) {
|
|
142
|
+
try { await exportarUmaVez(o); } catch (e) { if (o.log) o.log('falha ao exportar (' + (e.codigo || e.code || 'erro') + ')'); }
|
|
143
|
+
if (sinal.parar) return;
|
|
144
|
+
await new Promise(r => setTimeout(r, o.intervaloMs || 1000));
|
|
145
|
+
if (sinal.parar) return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { exportarUmaVez, exportarTelas, lerConfig, lerPermitidas, prepararTela };
|