@danieltmn/openbridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/bin/openbridge.js +18 -0
- package/docs/ARCHITECTURE.md +77 -0
- package/package.json +54 -0
- package/src/auth.js +204 -0
- package/src/bridge/bridge.js +2280 -0
- package/src/cli.js +791 -0
- package/src/config.js +108 -0
- package/src/log.js +22 -0
- package/src/paths.js +152 -0
- package/src/push.js +85 -0
- package/src/store/index.js +930 -0
- package/src/store/jsonfile.js +54 -0
- package/src/tunnel/index.js +141 -0
- package/src/web/assets/app.js +3939 -0
- package/src/web/assets/icons/icon-128x128.png +0 -0
- package/src/web/assets/icons/icon-144x144.png +0 -0
- package/src/web/assets/icons/icon-152x152.png +0 -0
- package/src/web/assets/icons/icon-192x192.png +0 -0
- package/src/web/assets/icons/icon-384x384.png +0 -0
- package/src/web/assets/icons/icon-512x512.png +0 -0
- package/src/web/assets/icons/icon-72x72.png +0 -0
- package/src/web/assets/icons/icon-96x96.png +0 -0
- package/src/web/assets/manifest.webmanifest +24 -0
- package/src/web/assets/sw.js +85 -0
- package/src/web/assets/themes/dark-plus.css +19 -0
- package/src/web/assets/themes/dark-red.css +18 -0
- package/src/web/assets/themes/default.css +18 -0
- package/src/web/assets/themes/github-light.css +19 -0
- package/src/web/assets/themes/high-contrast.css +18 -0
- package/src/web/assets/themes/index.json +15 -0
- package/src/web/assets/themes/light-plus.css +19 -0
- package/src/web/assets/themes/light.css +18 -0
- package/src/web/assets/themes/monokai.css +19 -0
- package/src/web/assets/themes/one-dark.css +19 -0
- package/src/web/assets/themes/solarized.css +18 -0
- package/src/web/assets/themes/terminal.css +28 -0
- package/src/web/assets/themes/themes.css +2 -0
- package/src/web/routes.js +871 -0
- package/src/web/server.js +130 -0
- package/src/web/templates/chat.html +1296 -0
- package/src/web/templates/login.html +144 -0
|
@@ -0,0 +1,2280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Puente entre tu hosting (PHP) y opencode en tu PC (v2).
|
|
3
|
+
*
|
|
4
|
+
* - Lee bridge/folders.json y `opencode models`, y los sincroniza al hosting.
|
|
5
|
+
* - Consulta el hosting por mensajes nuevos (poll) y ejecuta:
|
|
6
|
+
* opencode run --model <m> [--session <id> | --title bridge-<session-app>] "<mensaje>"
|
|
7
|
+
* en la carpeta del chat. Luego publica la respuesta y enlaza la sesión real
|
|
8
|
+
* de opencode con el chat para continuarla en los siguientes mensajes.
|
|
9
|
+
*
|
|
10
|
+
* Uso:
|
|
11
|
+
* node bridge.js (usa bridge/config.json)
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const { spawn } = require('node:child_process');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
// Casa portable: config.json, folders.json, logs y estado viven en
|
|
21
|
+
// `<base>/.openbridge`. La CLI setea OPENBRIDGE_HOME a la base (cwd/--dir); si
|
|
22
|
+
// no hay env, soportamos la instalacion clasica (config al lado del script) o
|
|
23
|
+
// una base deducida del cwd.
|
|
24
|
+
const BASE = process.env.OPENBRIDGE_HOME || process.env.OPENCONEX_HOME || '';
|
|
25
|
+
const HOME = BASE
|
|
26
|
+
? path.join(BASE, '.openbridge')
|
|
27
|
+
: (function () {
|
|
28
|
+
if (fs.existsSync(path.join(__dirname, 'config.json'))) return __dirname;
|
|
29
|
+
if (fs.existsSync(path.join(__dirname, '.openbridge', 'config.json'))) return path.join(__dirname, '.openbridge');
|
|
30
|
+
return path.join(process.cwd(), '.openbridge');
|
|
31
|
+
})();
|
|
32
|
+
|
|
33
|
+
const CONFIG_PATH = path.join(HOME, 'config.json');
|
|
34
|
+
const MODE_PATH = path.join(HOME, 'mode.txt');
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
|
|
37
|
+
if (!config.apiUrl) {
|
|
38
|
+
console.error('[bridge] Falta "apiUrl" en ' + CONFIG_PATH);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Identidad de este puente (una computadora). El hosting puede atender varias
|
|
43
|
+
// PCs a la vez: cada una sincroniza su catálogo y ejecuta SUS sesiones. Sin
|
|
44
|
+
// config.bridgeId, se usa el hostname de la máquina.
|
|
45
|
+
const BRIDGE_ID = (function () {
|
|
46
|
+
let raw = String(config.bridgeId || os.hostname() || '');
|
|
47
|
+
let id = raw.replace(/[^A-Za-z0-9._\-]/g, '');
|
|
48
|
+
id = id.replace(/^[^A-Za-z0-9]+/, '');
|
|
49
|
+
id = id.slice(0, 40);
|
|
50
|
+
return /^[A-Za-z0-9][A-Za-z0-9._\-]{0,39}$/.test(id) ? id : 'puente';
|
|
51
|
+
})();
|
|
52
|
+
const BRIDGE_NAME = (String(config.bridgeName || config.bridgeId || os.hostname() || BRIDGE_ID))
|
|
53
|
+
.replace(/[^\p{L}\p{N} ._\-]/gu, '').slice(0, 40) || BRIDGE_ID;
|
|
54
|
+
|
|
55
|
+
// Lock anti-instancias-múltiples: dos puentes compiten por el poll, corren
|
|
56
|
+
// barridos en paralelo y se pisan sync-state.json. Si el pid del lock vive,
|
|
57
|
+
// este proceso se va; si el lock quedó huérfano, se adopta.
|
|
58
|
+
const LOCK_PATH = path.join(HOME, '.bridge.pid');
|
|
59
|
+
try {
|
|
60
|
+
const prev = parseInt(fs.readFileSync(LOCK_PATH, 'utf8').trim(), 10);
|
|
61
|
+
if (prev && prev !== process.pid) {
|
|
62
|
+
let alive = false;
|
|
63
|
+
try { process.kill(prev, 0); alive = true; } catch (e) { /* muerto */ }
|
|
64
|
+
if (alive) {
|
|
65
|
+
console.error('[bridge] Ya hay un puente corriendo (pid ' + prev + '). Cerrálo primero.');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
console.error('[bridge] lock huérfano (pid ' + prev + ' ya no existe); lo adopto.');
|
|
69
|
+
}
|
|
70
|
+
} catch (e) { /* sin lock previo */ }
|
|
71
|
+
try { fs.writeFileSync(LOCK_PATH, String(process.pid)); } catch (e) {}
|
|
72
|
+
process.on('exit', function () {
|
|
73
|
+
try {
|
|
74
|
+
if (fs.readFileSync(LOCK_PATH, 'utf8').trim() === String(process.pid)) fs.unlinkSync(LOCK_PATH);
|
|
75
|
+
} catch (e) { /* nada */ }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Registro de procesos lanzados (dev servers). Si el puente muere de golpe
|
|
79
|
+
// (taskkill /F de la ventana de control, apagón), sus hijos sobreviven y
|
|
80
|
+
// quedan agarrando puertos: al arrancar, este puente los detecta y los mata.
|
|
81
|
+
const PROCS_STATE_PATH = path.join(HOME, '.procs.json');
|
|
82
|
+
function saveProcsState() {
|
|
83
|
+
const list = [];
|
|
84
|
+
for (const p of procs.values()) {
|
|
85
|
+
if (!p.running || !p.proc || !p.proc.pid) continue;
|
|
86
|
+
list.push({ pid: p.proc.pid, cmd: p.cmd, folder: p.folder, port: p.port || null, startedAt: p.startedAt });
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
if (list.length) fs.writeFileSync(PROCS_STATE_PATH, JSON.stringify(list, null, 2));
|
|
90
|
+
else if (fs.existsSync(PROCS_STATE_PATH)) fs.unlinkSync(PROCS_STATE_PATH);
|
|
91
|
+
} catch (e) { /* sin registro no cortamos nada */ }
|
|
92
|
+
}
|
|
93
|
+
function killOrphansFromState() {
|
|
94
|
+
let list = [];
|
|
95
|
+
try {
|
|
96
|
+
list = JSON.parse(fs.readFileSync(PROCS_STATE_PATH, 'utf8'));
|
|
97
|
+
} catch (e) { return; }
|
|
98
|
+
if (!Array.isArray(list) || !list.length) return;
|
|
99
|
+
const killed = new Set();
|
|
100
|
+
const killPid = (pid, why) => {
|
|
101
|
+
pid = parseInt(pid, 10);
|
|
102
|
+
if (!pid || pid === process.pid || killed.has(pid)) return;
|
|
103
|
+
killed.add(pid);
|
|
104
|
+
try {
|
|
105
|
+
if (process.platform === 'win32') {
|
|
106
|
+
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
|
|
107
|
+
} else {
|
|
108
|
+
process.kill(pid, 'SIGKILL');
|
|
109
|
+
}
|
|
110
|
+
log('limpieza: eliminé el proceso huérfano del puente anterior (pid ' + pid + (why ? ', ' + why : '') + ')');
|
|
111
|
+
} catch (e) {
|
|
112
|
+
log('aviso: no pude matar el huérfano pid ' + pid + ': ' + (e && e.message));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
for (const it of list) {
|
|
116
|
+
if (!it || typeof it !== 'object') continue;
|
|
117
|
+
// Si el registro es muy viejo, el pid pudo ser reciclado: no arriesgar.
|
|
118
|
+
if (it.startedAt && (Date.now() - it.startedAt) > 24 * 60 * 60 * 1000) continue;
|
|
119
|
+
let alive = false;
|
|
120
|
+
try { process.kill(parseInt(it.pid, 10), 0); alive = true; } catch (e) { /* muerto */ }
|
|
121
|
+
if (alive) killPid(it.pid, it.cmd ? it.cmd : '');
|
|
122
|
+
// El wrapper puede haber muerto y el hijo (el server) sobrevivió
|
|
123
|
+
// agarrando el puerto: si conocemos el puerto, matamos a quien
|
|
124
|
+
// escuche ahí. Es el caso clásico "npm start" → next/vite.
|
|
125
|
+
if (it.port && tunnelPortOf([String(it.port)])) {
|
|
126
|
+
killPidByPort(it.port);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
try { fs.unlinkSync(PROCS_STATE_PATH); } catch (e) { /* nada */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Pid(s) escuchando en un puerto TCP (netstat en Windows; lsof/fuser en el resto).
|
|
133
|
+
function portListeners(port) {
|
|
134
|
+
return runCmd(process.platform === 'win32' ? 'netstat' : 'lsof',
|
|
135
|
+
process.platform === 'win32' ? ['-ano', '-p', 'tcp'] : ['-t', '-i', 'tcp:' + port, '-s', 'tcp:listen'],
|
|
136
|
+
{ timeout: 15000 }).then((r) => {
|
|
137
|
+
if (process.platform !== 'win32') {
|
|
138
|
+
return String(r.text || '').split(/\s+/).map(Number).filter((n) => n > 0);
|
|
139
|
+
}
|
|
140
|
+
const re = new RegExp(':' + port + '\\s');
|
|
141
|
+
const pids = new Set();
|
|
142
|
+
for (const line of String(r.text || '').split(/\r?\n/)) {
|
|
143
|
+
if (!re.test(line) || !/LISTENING/i.test(line)) continue;
|
|
144
|
+
const parts = line.trim().split(/\s+/);
|
|
145
|
+
const pid = parseInt(parts[parts.length - 1], 10);
|
|
146
|
+
if (pid > 0) pids.add(pid);
|
|
147
|
+
}
|
|
148
|
+
return [...pids];
|
|
149
|
+
}).catch(() => []);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function killPidByPort(port) {
|
|
153
|
+
try {
|
|
154
|
+
const pids = await portListeners(port);
|
|
155
|
+
for (const pid of pids) {
|
|
156
|
+
try {
|
|
157
|
+
if (process.platform === 'win32') {
|
|
158
|
+
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
|
|
159
|
+
} else {
|
|
160
|
+
process.kill(pid, 'SIGKILL');
|
|
161
|
+
}
|
|
162
|
+
log('limpieza: eliminé al que escuchaba el puerto ' + port + ' (pid ' + pid + ')');
|
|
163
|
+
} catch (e) { /* sigue */ }
|
|
164
|
+
}
|
|
165
|
+
} catch (e) { /* nada */ }
|
|
166
|
+
}
|
|
167
|
+
killOrphansFromState();
|
|
168
|
+
|
|
169
|
+
// Modo activo: 'remoto' (hosting) o 'local' (php -S en la PC).
|
|
170
|
+
// Se alterna escribiendo "local"/"remoto" en bridge/mode.txt (lo hace la
|
|
171
|
+
// ventana OpenConex); el puente lo toma en el próximo ciclo sin reiniciar.
|
|
172
|
+
let activeMode = null;
|
|
173
|
+
|
|
174
|
+
const LOG_PATH = config.logFile
|
|
175
|
+
? path.resolve(HOME, config.logFile)
|
|
176
|
+
: path.join(HOME, 'logs', 'bridge.log');
|
|
177
|
+
try { fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true }); } catch (e) { /* nada */ }
|
|
178
|
+
|
|
179
|
+
let busy = false;
|
|
180
|
+
const STARTED_AT = Date.now();
|
|
181
|
+
// Tras una respuesta del hosting volvemos a pollear rápido: con long-poll el
|
|
182
|
+
// hosting retiene la respuesta cuando no hay nada, así que el ritmo real de
|
|
183
|
+
// requests queda bajo incluso pidiendo seguido.
|
|
184
|
+
const POLL_QUICK_MS = 300;
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Logging
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
function ts() {
|
|
190
|
+
return new Date().toISOString();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function log(...parts) {
|
|
194
|
+
const line = '[' + ts() + '] ' + parts.join(' ');
|
|
195
|
+
try {
|
|
196
|
+
fs.appendFileSync(LOG_PATH, line + '\n');
|
|
197
|
+
} catch (e) { /* sin log no detenemos el puente */ }
|
|
198
|
+
console.log(line);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Config
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
function loadConfig() {
|
|
205
|
+
let raw;
|
|
206
|
+
try {
|
|
207
|
+
raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.error('No se pudo leer ' + CONFIG_PATH + ': ' + e.message);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const cfg = JSON.parse(raw);
|
|
214
|
+
const merged = Object.assign(
|
|
215
|
+
{
|
|
216
|
+
apiToken: '',
|
|
217
|
+
apiUrlLocal: '',
|
|
218
|
+
mode: 'remoto',
|
|
219
|
+
pollIntervalMs: 1500,
|
|
220
|
+
command: 'opencode',
|
|
221
|
+
opencodeTimeoutMs: 15 * 60 * 1000,
|
|
222
|
+
logFile: 'bridge.log',
|
|
223
|
+
foldersFile: 'folders.json',
|
|
224
|
+
workspace: '',
|
|
225
|
+
allowCreateFolders: false,
|
|
226
|
+
models: [],
|
|
227
|
+
agents: ['build', 'plan'],
|
|
228
|
+
bridgeId: '',
|
|
229
|
+
bridgeName: '',
|
|
230
|
+
// Procesos de desarrollo lanzados desde la web (proc_start...).
|
|
231
|
+
processes: { enabled: true, allow: ['npm', 'node', 'npx'], maxGlobal: 3 },
|
|
232
|
+
},
|
|
233
|
+
cfg
|
|
234
|
+
);
|
|
235
|
+
// Overrides por entorno (los setea `openbridge bridge --api --token ...`).
|
|
236
|
+
if (process.env.OPENBRIDGE_API_URL) merged.apiUrl = process.env.OPENBRIDGE_API_URL;
|
|
237
|
+
if (process.env.OPENBRIDGE_API_TOKEN) merged.apiToken = process.env.OPENBRIDGE_API_TOKEN;
|
|
238
|
+
if (process.env.OPENBRIDGE_BRIDGE_ID) merged.bridgeId = process.env.OPENBRIDGE_BRIDGE_ID;
|
|
239
|
+
if (process.env.OPENBRIDGE_BRIDGE_NAME) merged.bridgeName = process.env.OPENBRIDGE_BRIDGE_NAME;
|
|
240
|
+
return merged;
|
|
241
|
+
} catch (e) {
|
|
242
|
+
console.error('config.json no es un JSON valido: ' + e.message);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Carpetas habilitadas (bridge/folders.json)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
function foldersFilePath() {
|
|
251
|
+
return path.resolve(HOME, config.foldersFile);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readFolders() {
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(fs.readFileSync(foldersFilePath(), 'utf8'));
|
|
257
|
+
return Array.isArray(parsed.folders) ? parsed.folders : [];
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function writeFolders(list) {
|
|
264
|
+
fs.writeFileSync(foldersFilePath(), JSON.stringify({ folders: list }, null, 2));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// API del hosting
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
function normalizeMode(v) {
|
|
271
|
+
const m = String(v || '').trim().toLowerCase();
|
|
272
|
+
if (m === 'local') return 'local';
|
|
273
|
+
if (m === 'dual') return 'dual';
|
|
274
|
+
return 'remoto';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// El modo pedido: mode.txt (escrito por la ventana OpenConex) tiene prioridad
|
|
278
|
+
// sobre config.json; el puente lo consulta en cada ciclo.
|
|
279
|
+
function requestedMode() {
|
|
280
|
+
try {
|
|
281
|
+
if (fs.existsSync(MODE_PATH)) {
|
|
282
|
+
return normalizeMode(fs.readFileSync(MODE_PATH, 'utf8'));
|
|
283
|
+
}
|
|
284
|
+
} catch (e) { /* si no se puede leer, usamos config */ }
|
|
285
|
+
return normalizeMode(config.mode);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Destinos atendidos según el modo. En dual el puente sirve a los dos
|
|
289
|
+
// hostings a la vez: hace poll a cada uno y responde donde corresponde.
|
|
290
|
+
function activeTargets() {
|
|
291
|
+
const mode = activeMode || requestedMode();
|
|
292
|
+
return mode === 'dual' ? ['remoto', 'local'] : [mode];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function targetApiUrl(target) {
|
|
296
|
+
if (target === 'local') {
|
|
297
|
+
if (config.apiUrlLocal) return config.apiUrlLocal;
|
|
298
|
+
log('aviso: destino "local" sin "apiUrlLocal" en config.json; uso el hosting remoto.');
|
|
299
|
+
return config.apiUrl;
|
|
300
|
+
}
|
|
301
|
+
return config.apiUrl;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function activeApiUrl(target) {
|
|
305
|
+
return targetApiUrl(target || (activeMode || requestedMode()));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Detecta cambios de modo (local ↔ remoto ↔ dual) y resincroniza el catálogo.
|
|
309
|
+
function checkModeChange() {
|
|
310
|
+
const want = requestedMode();
|
|
311
|
+
if (activeMode === null) {
|
|
312
|
+
activeMode = want;
|
|
313
|
+
if (want === 'dual') {
|
|
314
|
+
log('modo dual: atiendo remoto (' + config.apiUrl + ') y local (' + (config.apiUrlLocal || '(no definido)') + ')');
|
|
315
|
+
} else {
|
|
316
|
+
log('modo: ' + want + ' -> ' + targetApiUrl(want));
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (want !== activeMode) {
|
|
321
|
+
const prev = activeMode;
|
|
322
|
+
activeMode = want;
|
|
323
|
+
log('cambio de modo: ' + prev + ' -> ' + want);
|
|
324
|
+
syncCatalog({ silent: true });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function api(action, body, target, opts) {
|
|
329
|
+
let t = target;
|
|
330
|
+
if (!t) {
|
|
331
|
+
const targets = activeTargets();
|
|
332
|
+
t = targets[0];
|
|
333
|
+
if (targets.length > 1) {
|
|
334
|
+
log('aviso: "' + action + '" sin destino en modo dual; uso ' + t + ' (revisar el ruteo)');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const url = new URL(targetApiUrl(t));
|
|
338
|
+
url.searchParams.set('action', action);
|
|
339
|
+
|
|
340
|
+
const headers = { Accept: 'application/json' };
|
|
341
|
+
let payload = null;
|
|
342
|
+
if (body !== undefined) {
|
|
343
|
+
headers['Content-Type'] = 'application/json';
|
|
344
|
+
payload = JSON.stringify(body);
|
|
345
|
+
}
|
|
346
|
+
if (config.apiToken) {
|
|
347
|
+
headers['X-Bridge-Token'] = config.apiToken;
|
|
348
|
+
}
|
|
349
|
+
// Identidad del puente: el hosting separa catálogo, sesiones y colas por PC.
|
|
350
|
+
headers['X-Bridge-Id'] = BRIDGE_ID;
|
|
351
|
+
headers['X-Bridge-Name'] = BRIDGE_NAME;
|
|
352
|
+
|
|
353
|
+
const res = await fetch(url, {
|
|
354
|
+
method: body !== undefined ? 'POST' : 'GET',
|
|
355
|
+
headers,
|
|
356
|
+
body: payload,
|
|
357
|
+
signal: opts ? opts.signal : undefined,
|
|
358
|
+
});
|
|
359
|
+
const data = await res.json().catch(() => ({}));
|
|
360
|
+
if (!res.ok || data.ok === false) {
|
|
361
|
+
throw new Error('HTTP ' + res.status + ' ' + (data.error || JSON.stringify(data)));
|
|
362
|
+
}
|
|
363
|
+
return data;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Localizar opencode (Windows: resolver el .exe real del shim de pnpm/npm)
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
function resolveCommand() {
|
|
370
|
+
const cmd = config.command;
|
|
371
|
+
if (!cmd.includes(path.sep) && !cmd.includes('/')) {
|
|
372
|
+
const found = resolveFromPath(cmd);
|
|
373
|
+
if (found) return found;
|
|
374
|
+
}
|
|
375
|
+
return path.normalize(cmd.replace(/\//g, path.sep));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function resolveFromPath(name) {
|
|
379
|
+
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
380
|
+
if (process.platform !== 'win32') {
|
|
381
|
+
for (const dir of dirs) {
|
|
382
|
+
const p = path.join(dir, name);
|
|
383
|
+
try {
|
|
384
|
+
if (fs.existsSync(p)) return p;
|
|
385
|
+
} catch (e) {}
|
|
386
|
+
}
|
|
387
|
+
return name;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const extras = ['.exe', '.cmd', '.bat'];
|
|
391
|
+
for (const dir of dirs) {
|
|
392
|
+
for (const ext of extras) {
|
|
393
|
+
const p = path.join(dir, name + ext);
|
|
394
|
+
try {
|
|
395
|
+
if (!fs.existsSync(p)) continue;
|
|
396
|
+
if (ext.toLowerCase() === '.exe') return p;
|
|
397
|
+
const resolved = parseShimExe(p, dir);
|
|
398
|
+
if (resolved) return resolved;
|
|
399
|
+
return p;
|
|
400
|
+
} catch (e) {}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return name;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function parseShimExe(shimPath, shimDir) {
|
|
407
|
+
const content = fs.readFileSync(shimPath, 'utf8');
|
|
408
|
+
const m = content.match(/"([^"]+\.exe)"/i);
|
|
409
|
+
if (!m) return null;
|
|
410
|
+
let exe = m[1].replace(/%~dp0/gi, shimDir.endsWith(path.sep) ? shimDir : shimDir + path.sep);
|
|
411
|
+
exe = exe.replace(/\//g, path.sep);
|
|
412
|
+
return exe;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
// Ejecutar comandos auxiliares de opencode
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
function stripAnsi(s) {
|
|
419
|
+
return String(s || '')
|
|
420
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
|
421
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
|
|
422
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Spawn genérico con captura de salida y timeout (para helpers como port_free;
|
|
426
|
+
// runCli queda reservado al CLI de opencode).
|
|
427
|
+
function runCmd(bin, args, opts) {
|
|
428
|
+
return new Promise((resolve) => {
|
|
429
|
+
const timeoutMs = Math.max((opts && opts.timeout) || 0, 0);
|
|
430
|
+
let child;
|
|
431
|
+
try {
|
|
432
|
+
child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
433
|
+
} catch (e) {
|
|
434
|
+
resolve({ ok: false, code: null, text: '', spawnError: e.message });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
let out = '';
|
|
438
|
+
let timer = null;
|
|
439
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
440
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
441
|
+
if (timeoutMs) {
|
|
442
|
+
timer = setTimeout(() => {
|
|
443
|
+
try { child.kill(); } catch (e) { /* nada */ }
|
|
444
|
+
resolve({ ok: false, code: null, text: out, killed: true });
|
|
445
|
+
}, timeoutMs);
|
|
446
|
+
}
|
|
447
|
+
child.on('close', (code) => {
|
|
448
|
+
if (timer) clearTimeout(timer);
|
|
449
|
+
resolve({ ok: code === 0, code: code, text: out });
|
|
450
|
+
});
|
|
451
|
+
child.on('error', (e) => {
|
|
452
|
+
if (timer) clearTimeout(timer);
|
|
453
|
+
resolve({ ok: false, code: null, text: out, spawnError: e.message });
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function runCli(args, opts) {
|
|
459
|
+
return new Promise((resolve) => {
|
|
460
|
+
const cmd = resolveCommand();
|
|
461
|
+
const timeoutMs = Math.max((opts && opts.timeout) || 0, 0);
|
|
462
|
+
const child = spawn(cmd, args, {
|
|
463
|
+
cwd: (opts && opts.cwd) || undefined,
|
|
464
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
465
|
+
env: process.env,
|
|
466
|
+
windowsHide: true,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
let stdout = '';
|
|
470
|
+
let stderr = '';
|
|
471
|
+
let settled = false;
|
|
472
|
+
let timer = null;
|
|
473
|
+
|
|
474
|
+
if (timeoutMs > 0) {
|
|
475
|
+
timer = setTimeout(() => {
|
|
476
|
+
if (settled) return;
|
|
477
|
+
settled = true;
|
|
478
|
+
try { child.kill(); } catch (e) {}
|
|
479
|
+
resolve({ ok: false, code: null, text: (stripAnsi(stdout || stderr || '')).replace(/\s+$/g, ''), killed: true });
|
|
480
|
+
}, timeoutMs);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
484
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
485
|
+
// opencode lee stdin hasta EOF; si no cerramos, el pipe queda abierto y se cuelga.
|
|
486
|
+
child.stdin.on('error', () => {});
|
|
487
|
+
child.stdin.end();
|
|
488
|
+
|
|
489
|
+
child.on('close', (code) => {
|
|
490
|
+
if (settled) return;
|
|
491
|
+
settled = true;
|
|
492
|
+
if (timer) clearTimeout(timer);
|
|
493
|
+
const text = (stripAnsi(stdout || stderr || '')).replace(/\s+$/g, '');
|
|
494
|
+
resolve({ ok: code === 0, code: code, text: text, killed: false });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Sin esto, un spawn que falla (ENOENT puntual de Windows) cuelga la
|
|
498
|
+
// promesa para siempre y congela el barrido.
|
|
499
|
+
child.on('error', (err) => {
|
|
500
|
+
if (settled) return;
|
|
501
|
+
settled = true;
|
|
502
|
+
if (timer) clearTimeout(timer);
|
|
503
|
+
resolve({ ok: false, code: null, text: '', killed: false, spawnError: err.message });
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Ejecuta opencode y emite {text, reasoning} acumulados por tramos vía onPartial.
|
|
509
|
+
// Los modelos razonadores emiten además eventos "reasoning" que se muestran
|
|
510
|
+
// en vivo (💭) y se guardan con la respuesta final.
|
|
511
|
+
// Resolución: { ok, code, text, reasoning, killed, canceled, sessionID, errorText }.
|
|
512
|
+
function streamCli(args, opts, onPartial) {
|
|
513
|
+
return new Promise((resolve) => {
|
|
514
|
+
const cmd = resolveCommand();
|
|
515
|
+
const timeoutMs = Math.max((opts && opts.timeout) || 0, 0);
|
|
516
|
+
const child = spawn(cmd, args, {
|
|
517
|
+
cwd: (opts && opts.cwd) || undefined,
|
|
518
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
519
|
+
env: process.env,
|
|
520
|
+
windowsHide: true,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
let stdout = '';
|
|
524
|
+
let stderr = '';
|
|
525
|
+
let settled = false;
|
|
526
|
+
let timer = null;
|
|
527
|
+
let sessionID = null;
|
|
528
|
+
let errorText = '';
|
|
529
|
+
const texts = [];
|
|
530
|
+
const reasons = [];
|
|
531
|
+
let flushedText = null;
|
|
532
|
+
let flushedReasoning = null;
|
|
533
|
+
let lineBuf = '';
|
|
534
|
+
|
|
535
|
+
function flush(v) {
|
|
536
|
+
try { onPartial(v); } catch (e) {}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function drain() {
|
|
540
|
+
const acc = texts.join('\n').trim();
|
|
541
|
+
const rac = reasons.join('\n').trim();
|
|
542
|
+
if (acc === flushedText && rac === flushedReasoning) return;
|
|
543
|
+
if (!acc && !rac) return;
|
|
544
|
+
flushedText = acc;
|
|
545
|
+
flushedReasoning = rac;
|
|
546
|
+
flush({ text: acc, reasoning: rac });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Resultado común de las tres salidas (timeout, cancelación, cierre).
|
|
550
|
+
function result(extra) {
|
|
551
|
+
const parsed = texts.join('\n').trim();
|
|
552
|
+
let fallback = (stripAnsi(stdout || stderr || '')).replace(/\s+$/g, '');
|
|
553
|
+
// Con --format json la salida cruda son eventos, no respuesta: si
|
|
554
|
+
// no se parseó ningún texto (corte a mitad de tool calls), no
|
|
555
|
+
// publicar el JSON crudo como si fuera la respuesta.
|
|
556
|
+
if (!parsed && /^\s*\{"type"/m.test(stdout)) fallback = '';
|
|
557
|
+
return Object.assign({
|
|
558
|
+
ok: false,
|
|
559
|
+
code: null,
|
|
560
|
+
text: parsed || fallback,
|
|
561
|
+
reasoning: reasons.join('\n').trim(),
|
|
562
|
+
killed: false,
|
|
563
|
+
canceled: false,
|
|
564
|
+
sessionID: sessionID,
|
|
565
|
+
errorText: errorText,
|
|
566
|
+
}, extra || {});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Cancelación remota: la web marca el mensaje y el puente corta el
|
|
570
|
+
// proceso en el próximo chequeo (~2 s).
|
|
571
|
+
let cancelTimer = null;
|
|
572
|
+
|
|
573
|
+
if (timeoutMs > 0) {
|
|
574
|
+
timer = setTimeout(() => {
|
|
575
|
+
if (settled) return;
|
|
576
|
+
settled = true;
|
|
577
|
+
if (cancelTimer) clearInterval(cancelTimer);
|
|
578
|
+
clearInterval(flusher);
|
|
579
|
+
try { child.kill(); } catch (e) {}
|
|
580
|
+
resolve(result({ killed: true }));
|
|
581
|
+
}, timeoutMs);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (typeof opts.cancelCheck === 'function') {
|
|
585
|
+
cancelTimer = setInterval(async () => {
|
|
586
|
+
if (settled) { clearInterval(cancelTimer); return; }
|
|
587
|
+
let want = false;
|
|
588
|
+
try { want = await opts.cancelCheck(); } catch (e) { /* reintenta */ }
|
|
589
|
+
if (want && !settled) {
|
|
590
|
+
settled = true;
|
|
591
|
+
if (timer) clearTimeout(timer);
|
|
592
|
+
clearInterval(flusher);
|
|
593
|
+
clearInterval(cancelTimer);
|
|
594
|
+
log('cancelación remota: cortando opencode (sesión ' + sessionID + ')');
|
|
595
|
+
try { child.kill(); } catch (e2) {}
|
|
596
|
+
resolve(result({ killed: true, canceled: true }));
|
|
597
|
+
}
|
|
598
|
+
}, 2000);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function errorMessage(ev) {
|
|
602
|
+
if (typeof ev.error === 'string' && ev.error) return ev.error;
|
|
603
|
+
const e = ev.error;
|
|
604
|
+
if (e && typeof e === 'object') {
|
|
605
|
+
if (typeof e.message === 'string' && e.message) return e.message;
|
|
606
|
+
const d = e.data;
|
|
607
|
+
if (d && typeof d.message === 'string' && d.message) return d.message;
|
|
608
|
+
if (d && typeof d === 'object') { try { return JSON.stringify(d); } catch (e2) {} }
|
|
609
|
+
}
|
|
610
|
+
if (typeof ev.message === 'string' && ev.message) return ev.message;
|
|
611
|
+
return 'error';
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function handleLine(l) {
|
|
615
|
+
let ev;
|
|
616
|
+
try { ev = JSON.parse(l); } catch (e) {
|
|
617
|
+
return; // no era JSON completo (ruido/log): se descarta
|
|
618
|
+
}
|
|
619
|
+
if (!ev || typeof ev !== 'object') return;
|
|
620
|
+
if (ev.sessionID && !sessionID) sessionID = ev.sessionID;
|
|
621
|
+
if (ev.part && typeof ev.part.text === 'string') {
|
|
622
|
+
if (ev.type === 'text') texts.push(ev.part.text);
|
|
623
|
+
else if (ev.type === 'reasoning') reasons.push(ev.part.text);
|
|
624
|
+
}
|
|
625
|
+
if (ev.type === 'error') {
|
|
626
|
+
errorText = errorMessage(ev);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function addLines(raw) {
|
|
631
|
+
// Acumula y procesa solo líneas completas; lo que quede sin \n
|
|
632
|
+
// espera el próximo chunk (antes se perdían eventos cortados).
|
|
633
|
+
lineBuf += String(raw || '');
|
|
634
|
+
let idx;
|
|
635
|
+
while ((idx = lineBuf.indexOf('\n')) >= 0) {
|
|
636
|
+
const line = lineBuf.slice(0, idx).trim();
|
|
637
|
+
lineBuf = lineBuf.slice(idx + 1);
|
|
638
|
+
if (line) handleLine(line);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
child.stdout.on('data', (d) => {
|
|
643
|
+
stdout += d;
|
|
644
|
+
addLines(d);
|
|
645
|
+
});
|
|
646
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
647
|
+
child.stdin.on('error', () => {});
|
|
648
|
+
child.stdin.end();
|
|
649
|
+
|
|
650
|
+
const flusher = setInterval(drain, 750);
|
|
651
|
+
child.on('close', (code) => {
|
|
652
|
+
if (settled) return;
|
|
653
|
+
settled = true;
|
|
654
|
+
if (timer) clearTimeout(timer);
|
|
655
|
+
if (cancelTimer) clearInterval(cancelTimer);
|
|
656
|
+
clearInterval(flusher);
|
|
657
|
+
if (lineBuf.trim()) handleLine(lineBuf.trim());
|
|
658
|
+
drain();
|
|
659
|
+
resolve(result({ ok: code === 0 }));
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// Igual que runCli: un spawn fallido no puede colgar la ejecución.
|
|
663
|
+
child.on('error', (err) => {
|
|
664
|
+
if (settled) return;
|
|
665
|
+
settled = true;
|
|
666
|
+
if (timer) clearTimeout(timer);
|
|
667
|
+
if (cancelTimer) clearInterval(cancelTimer);
|
|
668
|
+
clearInterval(flusher);
|
|
669
|
+
resolve(result({ errorText: 'no se pudo ejecutar opencode: ' + err.message }));
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function listModels() {
|
|
675
|
+
const r = await runCli(['models'], { timeout: 60000 });
|
|
676
|
+
if (!r.ok) {
|
|
677
|
+
log('aviso: no se pudo listar modelos: ' + (r.text || r.code));
|
|
678
|
+
return [];
|
|
679
|
+
}
|
|
680
|
+
return r.text.split(/\r?\n/)
|
|
681
|
+
.map((l) => l.trim())
|
|
682
|
+
.filter((l) => l.includes('/') && !l.startsWith('#') && !l.startsWith('─') && !l.startsWith('═'));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Catálogo completo de modelos agrupado por proveedor ("prov/modelo"),
|
|
686
|
+
// cacheado 30 min para no ejecutar el CLI en cada sincronización.
|
|
687
|
+
let modelsFullCache = null; // { groups: {proveedor: [ids]}, total, at }
|
|
688
|
+
const MODELS_FULL_TTL = 30 * 60 * 1000;
|
|
689
|
+
|
|
690
|
+
function flattenModels(groups) {
|
|
691
|
+
const out = [];
|
|
692
|
+
for (const k of Object.keys(groups || {})) out.push(...groups[k]);
|
|
693
|
+
return out;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function getModelsFull(force) {
|
|
697
|
+
if (!force && modelsFullCache && (Date.now() - modelsFullCache.at) < MODELS_FULL_TTL) {
|
|
698
|
+
return modelsFullCache;
|
|
699
|
+
}
|
|
700
|
+
const list = await listModels();
|
|
701
|
+
const groups = {};
|
|
702
|
+
for (const id of list) {
|
|
703
|
+
const slash = id.indexOf('/');
|
|
704
|
+
if (slash <= 0) continue;
|
|
705
|
+
const prov = id.slice(0, slash);
|
|
706
|
+
if (!groups[prov]) groups[prov] = [];
|
|
707
|
+
groups[prov].push(id);
|
|
708
|
+
}
|
|
709
|
+
modelsFullCache = { groups: groups, total: list.length, at: Date.now() };
|
|
710
|
+
return modelsFullCache;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Capacidades por modelo (visión, adjuntos, ventana de contexto) a partir de
|
|
714
|
+
// `opencode models --verbose`; misma caché de 30 min que el catálogo completo.
|
|
715
|
+
let modelsCapsCache = null; // { caps: {id: {vision, attachment, ctx}}, at }
|
|
716
|
+
|
|
717
|
+
function parseModelsVerbose(text) {
|
|
718
|
+
const caps = {};
|
|
719
|
+
// El id puede tener varias barras (openrouter/~anthropic/claude-...).
|
|
720
|
+
const headerRe = /^[\w.~\-]+(?:\/[\w.~\-:.+:]+)+$/;
|
|
721
|
+
let cur = null;
|
|
722
|
+
let buf = null;
|
|
723
|
+
let depth = 0;
|
|
724
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
725
|
+
const line = raw.trim();
|
|
726
|
+
if (buf === null) {
|
|
727
|
+
if (headerRe.test(line)) {
|
|
728
|
+
cur = line;
|
|
729
|
+
} else if (cur && line === '{') {
|
|
730
|
+
buf = '{';
|
|
731
|
+
depth = 1;
|
|
732
|
+
}
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
buf += '\n' + line;
|
|
736
|
+
for (const ch of line) {
|
|
737
|
+
if (ch === '{') depth++;
|
|
738
|
+
else if (ch === '}') depth--;
|
|
739
|
+
}
|
|
740
|
+
if (depth === 0) {
|
|
741
|
+
try {
|
|
742
|
+
const j = JSON.parse(buf);
|
|
743
|
+
// El encabezado ("prov/modelo") usa el mismo formato que la
|
|
744
|
+
// lista plana; j.id puede venir sin el proveedor.
|
|
745
|
+
const id = cur || (typeof j.id === 'string' && j.id.includes('/') ? j.id : null);
|
|
746
|
+
if (id) {
|
|
747
|
+
caps[id] = {
|
|
748
|
+
vision: !!(j.capabilities && j.capabilities.input && j.capabilities.input.image),
|
|
749
|
+
attachment: !!(j.capabilities && j.capabilities.attachment),
|
|
750
|
+
ctx: (j.limit && j.limit.context) || 0,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
} catch (e) { /* bloque no parseable: se ignora */ }
|
|
754
|
+
buf = null;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return caps;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function getModelCaps(force) {
|
|
761
|
+
if (!force && modelsCapsCache && (Date.now() - modelsCapsCache.at) < MODELS_FULL_TTL) {
|
|
762
|
+
return modelsCapsCache.caps;
|
|
763
|
+
}
|
|
764
|
+
const r = await runCli(['models', '--verbose'], { timeout: 120000 });
|
|
765
|
+
if (!r.ok) {
|
|
766
|
+
log('aviso: no se pudieron leer las capacidades de modelos: ' + (r.text || r.code).slice(0, 100));
|
|
767
|
+
return modelsCapsCache ? modelsCapsCache.caps : {};
|
|
768
|
+
}
|
|
769
|
+
modelsCapsCache = { caps: parseModelsVerbose(r.text), at: Date.now() };
|
|
770
|
+
const vis = Object.keys(modelsCapsCache.caps).filter((k) => modelsCapsCache.caps[k].vision);
|
|
771
|
+
log('capacidades: ' + Object.keys(modelsCapsCache.caps).length + ' modelos, ' + vis.length + ' con visión');
|
|
772
|
+
return modelsCapsCache.caps;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Favoritos de config.json (o todos si no hay configurados), validados contra
|
|
776
|
+
// la lista completa si ya está cacheada (sin volver a ejecutar el CLI).
|
|
777
|
+
async function resolveModels() {
|
|
778
|
+
const curated = Array.isArray(config.models)
|
|
779
|
+
? config.models.filter((m) => typeof m === 'string' && m.includes('/')).map((m) => m.trim())
|
|
780
|
+
: [];
|
|
781
|
+
const available = modelsFullCache ? flattenModels(modelsFullCache.groups) : null;
|
|
782
|
+
if (!curated.length) {
|
|
783
|
+
if (available && available.length) return available;
|
|
784
|
+
const full = await getModelsFull();
|
|
785
|
+
return flattenModels(full.groups);
|
|
786
|
+
}
|
|
787
|
+
if (available && available.length) {
|
|
788
|
+
const missing = curated.filter((m) => !available.includes(m));
|
|
789
|
+
if (missing.length) {
|
|
790
|
+
log('aviso: no aparecen en "opencode models": ' + missing.join(', '));
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return curated;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function resolveAgents() {
|
|
797
|
+
const list = Array.isArray(config.agents)
|
|
798
|
+
? config.agents.filter((a) => typeof a === 'string' && a.trim() !== '').map((a) => a.trim())
|
|
799
|
+
: [];
|
|
800
|
+
return list.length ? list : ['build', 'plan'];
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Consulta al hosting si el usuario pidió cancelar ese mensaje.
|
|
804
|
+
async function checkCancel(sessionId, target) {
|
|
805
|
+
try {
|
|
806
|
+
const r = await api('cancel_status', { session_id: sessionId }, target);
|
|
807
|
+
return !!(r && r.cancel);
|
|
808
|
+
} catch (e) { return false; }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
// Ejecutar opencode con un mensaje de un chat (streaming opcional)
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
async function runOpencode(msg, onPartial) {
|
|
815
|
+
const session = msg.session || {};
|
|
816
|
+
const folder = session.folder || '';
|
|
817
|
+
const model = session.model || '';
|
|
818
|
+
const agent = session.agent || '';
|
|
819
|
+
|
|
820
|
+
// --thinking: sin esto el CLI no emite los eventos "reasoning" (💭).
|
|
821
|
+
const args = ['run', '--format', 'json', '--thinking'];
|
|
822
|
+
if (model) args.push('--model', model);
|
|
823
|
+
if (agent && resolveAgents().includes(agent)) args.push('--agent', agent);
|
|
824
|
+
const cwd = folder ? path.resolve(folder) : undefined;
|
|
825
|
+
if (msg.opencode_session) {
|
|
826
|
+
args.push('--session', msg.opencode_session);
|
|
827
|
+
} else {
|
|
828
|
+
args.push('--title', 'bridge-' + msg.session_id);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Imagen adjunta desde la web (dataURL) → archivo temporal → --file.
|
|
832
|
+
let imgPath = null;
|
|
833
|
+
if (msg.img && /^data:image\/(png|jpe?g|webp|gif);base64,/.test(msg.img)) {
|
|
834
|
+
try {
|
|
835
|
+
const mime = msg.img.slice(5, msg.img.indexOf(';'));
|
|
836
|
+
const ext = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }[mime] || '.png';
|
|
837
|
+
imgPath = path.join(os.tmpdir(), 'ocx-img-' + msg.session_id + '-' + msg.id + ext);
|
|
838
|
+
fs.writeFileSync(imgPath, Buffer.from(msg.img.slice(msg.img.indexOf(',') + 1), 'base64'));
|
|
839
|
+
log('imagen adjunta: ' + Math.round(fs.statSync(imgPath).size / 1024) + ' KB para el mensaje #' + msg.id);
|
|
840
|
+
} catch (e) {
|
|
841
|
+
log('aviso: no se pudo guardar la imagen adjunta: ' + e.message);
|
|
842
|
+
imgPath = null;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Solo imagen, sin texto: opencode exige un mensaje.
|
|
847
|
+
// El mensaje va antes de --file: yargs consume los valores siguientes
|
|
848
|
+
// a una opción array, y se comería el texto si va después.
|
|
849
|
+
args.push(msg.text || 'Analizá la imagen adjunta.');
|
|
850
|
+
if (imgPath) {
|
|
851
|
+
args.push('--file', imgPath);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
log('ejecutando (sesión ' + (msg.opencode_session || 'nueva') + ', agente ' + (agent || 'default') + '): ' +
|
|
855
|
+
'\n cmd: ' + resolveCommand() + ' ' + args.slice(0, -1).join(' ') + ' "<mensaje>"' +
|
|
856
|
+
(cwd ? '\n cwd: ' + cwd : ''));
|
|
857
|
+
|
|
858
|
+
const r = await streamCli(args, {
|
|
859
|
+
cwd: cwd,
|
|
860
|
+
timeout: Math.max(config.opencodeTimeoutMs || 0, 0),
|
|
861
|
+
cancelCheck: () => checkCancel(msg.session_id, msg._t),
|
|
862
|
+
}, onPartial);
|
|
863
|
+
if (imgPath) {
|
|
864
|
+
try { fs.unlinkSync(imgPath); } catch (e) { /* ya no está */ }
|
|
865
|
+
}
|
|
866
|
+
let out = (r.text || '').trim();
|
|
867
|
+
if (r.canceled) {
|
|
868
|
+
// Cancelada: conserva lo generado y avisa; nunca "Error".
|
|
869
|
+
out = (out ? out + '\n\n' : '') + '⏹ Cancelado antes de terminar.';
|
|
870
|
+
} else {
|
|
871
|
+
if (!out && r.errorText) out = r.errorText;
|
|
872
|
+
if (!out && !r.ok && !r.killed) out = 'Error: ' + r.code + ' (revisa el puente)';
|
|
873
|
+
if (r.killed) out = (out ? out + '\n\n' : '') + '[La ejecución se cortó por tiempo límite]';
|
|
874
|
+
}
|
|
875
|
+
if (!out) out = 'ok (sin texto)';
|
|
876
|
+
|
|
877
|
+
const reached = r.sessionID || msg.opencode_session || null;
|
|
878
|
+
if (r.sessionID && r.sessionID !== msg.opencode_session) {
|
|
879
|
+
log('sesión de opencode: ' + r.sessionID);
|
|
880
|
+
} else if (!reached) {
|
|
881
|
+
log('aviso: no se pudo detectar la sesión en el stream.');
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return { text: out, reasoning: r.reasoning || '', opencodeSession: reached, errorText: r.errorText || '', canceled: !!r.canceled };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// ---------------------------------------------------------------------------
|
|
888
|
+
// Comandos "/xxx" enviados desde el celular
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
function parseSlash(text) {
|
|
891
|
+
const t = String(text || '').trim();
|
|
892
|
+
if (!t.startsWith('/')) return null;
|
|
893
|
+
const sp = t.indexOf(' ');
|
|
894
|
+
const name = (sp >= 0 ? t.slice(1, sp) : t.slice(1)).trim().toLowerCase();
|
|
895
|
+
const arg = sp >= 0 ? t.slice(sp + 1).trim() : '';
|
|
896
|
+
return { name: name, arg: arg, full: t };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Respuesta inmediata de un comando local (no pasa por el modelo).
|
|
900
|
+
async function respondSimple(msg, text, clearSession) {
|
|
901
|
+
const payload = {
|
|
902
|
+
session_id: msg.session_id,
|
|
903
|
+
user_id: msg.id,
|
|
904
|
+
text: text,
|
|
905
|
+
opencode_session: msg.opencode_session || '',
|
|
906
|
+
clear_session: !!clearSession,
|
|
907
|
+
};
|
|
908
|
+
await api('respond', payload, msg._t);
|
|
909
|
+
log('respuesta #' + msg.id + ' publicada (' + text.length + ' car., comando local)');
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Publica tramos parciales (texto y, si hay, razonamiento) en la API.
|
|
913
|
+
// Los parciales son best-effort: si fallan, se sigue.
|
|
914
|
+
function partialPoster(msg) {
|
|
915
|
+
return async (partial) => {
|
|
916
|
+
const text = String((partial && partial.text) || '').trim();
|
|
917
|
+
const reasoning = String((partial && partial.reasoning) || '').trim();
|
|
918
|
+
if (!text && !reasoning) return;
|
|
919
|
+
try {
|
|
920
|
+
await api('respond_partial', { session_id: msg.session_id, user_id: msg.id, text: text, reasoning: reasoning }, msg._t);
|
|
921
|
+
} catch (e) { /* parciales son best-effort */ }
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function runMessage(msg) {
|
|
926
|
+
const slash = parseSlash(msg.text);
|
|
927
|
+
if (!slash) {
|
|
928
|
+
const r = await runOpencode(msg, partialPoster(msg));
|
|
929
|
+
return r;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const name = slash.name;
|
|
933
|
+
|
|
934
|
+
if (name === 'new' || name === 'clear' || name === 'nuevo') {
|
|
935
|
+
log('comando /' + name + ' -> nueva sesión');
|
|
936
|
+
await respondSimple(msg, '🧹 Nueva sesión iniciada. Escribí tu primer mensaje y arranco desde cero.', true);
|
|
937
|
+
return { text: '', opencodeSession: null, done: true };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (name === 'compact' || name === 'resumen' || name === 'summarize') {
|
|
941
|
+
log('comando /' + name + ' -> liberar contexto');
|
|
942
|
+
await respondSimple(msg, '🧹 Contexto liberado. La próxima respuesta usa una sesión nueva (compactación no soportada por CLI).', true);
|
|
943
|
+
return { text: '', opencodeSession: null, done: true };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (name === 'models') {
|
|
947
|
+
log('comando /models' + (slash.arg ? ' (filtro: ' + slash.arg + ')' : ''));
|
|
948
|
+
const full = await getModelsFull();
|
|
949
|
+
const flat = flattenModels(full.groups);
|
|
950
|
+
const filtro = (slash.arg || '').trim().toLowerCase();
|
|
951
|
+
let text;
|
|
952
|
+
if (!flat.length) {
|
|
953
|
+
text = 'No hay modelos disponibles (¿está instalado opencode?).';
|
|
954
|
+
} else if (filtro) {
|
|
955
|
+
const hits = flat.filter((m) => m.toLowerCase().includes(filtro));
|
|
956
|
+
const shown = hits.slice(0, 60);
|
|
957
|
+
text = 'Modelos que coinciden con "' + slash.arg + '" (' + hits.length + '):\n\n'
|
|
958
|
+
+ (shown.length ? shown.map((m) => '• ' + m).join('\n') : '(sin coincidencias)')
|
|
959
|
+
+ (hits.length > shown.length ? '\n\n… y ' + (hits.length - shown.length) + ' más. Refiná el filtro.' : '');
|
|
960
|
+
} else {
|
|
961
|
+
const provs = Object.keys(full.groups).sort((a, b) => a.localeCompare(b));
|
|
962
|
+
text = 'Proveedores disponibles (' + provs.length + ' proveedores, ' + flat.length + ' modelos):\n\n'
|
|
963
|
+
+ provs.map((p) => '• ' + p + ' (' + full.groups[p].length + ')').join('\n')
|
|
964
|
+
+ '\n\nBuscá con "/models <texto>" (ej: /models glm) o con el buscador del modal en la web.';
|
|
965
|
+
}
|
|
966
|
+
await respondSimple(msg, text, false);
|
|
967
|
+
return { text: '', opencodeSession: null, done: true };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (name === 'agents') {
|
|
971
|
+
log('comando /agents');
|
|
972
|
+
const agents = resolveAgents();
|
|
973
|
+
const text = 'Agentes disponibles (' + agents.length + '):\n\n' + agents.map((a) => '• ' + a).join('\n');
|
|
974
|
+
await respondSimple(msg, text, false);
|
|
975
|
+
return { text: '', opencodeSession: null, done: true };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (name === 'workspace') {
|
|
979
|
+
log('comando /workspace');
|
|
980
|
+
const ws = config.workspace ? path.resolve(config.workspace) : '';
|
|
981
|
+
const text = ws
|
|
982
|
+
? 'Espacio de trabajo:\n\n' + ws
|
|
983
|
+
: 'El workspace no está definido en config.json.';
|
|
984
|
+
await respondSimple(msg, text, false);
|
|
985
|
+
return { text: '', opencodeSession: null, done: true };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (name === 'status' || name === 'estado') {
|
|
989
|
+
log('comando /' + name);
|
|
990
|
+
const up = Math.floor((Date.now() - STARTED_AT) / 1000);
|
|
991
|
+
const upStr = up >= 60 ? Math.floor(up / 60) + 'min ' + (up % 60) + 's' : up + 's';
|
|
992
|
+
const ws = config.workspace ? path.resolve(config.workspace) : '(sin definir)';
|
|
993
|
+
const text = 'Estado del puente:\n\n'
|
|
994
|
+
+ '• PID: ' + process.pid + '\n'
|
|
995
|
+
+ '• Puente: ' + BRIDGE_ID + (BRIDGE_NAME && BRIDGE_NAME !== BRIDGE_ID ? ' (' + BRIDGE_NAME + ')' : '') + '\n'
|
|
996
|
+
+ '• Encendido hace: ' + upStr + '\n'
|
|
997
|
+
+ '• Ocupado: ' + (busy ? 'sí' : 'no') + '\n'
|
|
998
|
+
+ '• Modo: ' + (activeMode || requestedMode()) + '\n'
|
|
999
|
+
+ '• API activa: ' + activeApiUrl() + '\n'
|
|
1000
|
+
+ '• Hosting remoto: ' + config.apiUrl + '\n'
|
|
1001
|
+
+ '• API local: ' + (config.apiUrlLocal || '(no definida)') + '\n'
|
|
1002
|
+
+ '• Modelos: ' + (config.models || []).length + ' favoritos'
|
|
1003
|
+
+ (modelsFullCache ? ', ' + modelsFullCache.total + ' disponibles (' + Object.keys(modelsFullCache.groups).length + ' proveedores)' : '') + '\n'
|
|
1004
|
+
+ '• Agentes: ' + (resolveAgents().length) + '\n'
|
|
1005
|
+
+ '• Workspace: ' + ws;
|
|
1006
|
+
await respondSimple(msg, text, false);
|
|
1007
|
+
return { text: '', opencodeSession: null, done: true };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (name === 'help' || name === 'ayuda' || name === 'comandos') {
|
|
1011
|
+
log('comando /' + name);
|
|
1012
|
+
const text = 'Comandos disponibles:\n\n'
|
|
1013
|
+
+ '• /new, /clear, /nuevo — empezar una conversación nueva\n'
|
|
1014
|
+
+ '• /compact, /resumen, /summarize — liberar contexto\n'
|
|
1015
|
+
+ '• /models [filtro] — proveedores disponibles o búsqueda de modelos\n'
|
|
1016
|
+
+ '• /agents — listar agentes disponibles\n'
|
|
1017
|
+
+ '• /folders, /carpetas, /dirs — listar carpetas del workspace\n'
|
|
1018
|
+
+ '• /workspace — mostrar el espacio de trabajo\n'
|
|
1019
|
+
+ '• /status, /estado — estado del puente\n'
|
|
1020
|
+
+ '• /help, /ayuda — esta ayuda\n'
|
|
1021
|
+
+ '• /<cualquier-cosa> — comando custom de opencode (si existe); si no, se trata como un mensaje normal.';
|
|
1022
|
+
await respondSimple(msg, text, false);
|
|
1023
|
+
return { text: '', opencodeSession: null, done: true };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (name === 'folders' || name === 'carpetas' || name === 'dirs') {
|
|
1027
|
+
log('comando /' + name);
|
|
1028
|
+
const workspace = config.workspace ? path.resolve(config.workspace) : '';
|
|
1029
|
+
const list = listWorkspaceFolders(workspace);
|
|
1030
|
+
const text = list.length
|
|
1031
|
+
? 'Carpetas del workspace:\n\n' + list.join('\n')
|
|
1032
|
+
: 'No se encontraron carpetas' + (workspace ? '' : ' (workspace sin definir)') + '.';
|
|
1033
|
+
await respondSimple(msg, text, false);
|
|
1034
|
+
return { text: '', opencodeSession: null, done: true };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Comando custom de opencode (p. ej. /review) vía --command; si no existe, cae en prompt normal.
|
|
1038
|
+
log('comando /' + name + ' -> intentando custom command');
|
|
1039
|
+
const cmdArgs = ['run', '--format', 'json', '--thinking', '--command', name];
|
|
1040
|
+
if (slash.arg) cmdArgs.push(slash.arg);
|
|
1041
|
+
const session = msg.session || {};
|
|
1042
|
+
const cwd = session.folder ? path.resolve(session.folder) : undefined;
|
|
1043
|
+
const r = await streamCli(cmdArgs, {
|
|
1044
|
+
cwd: cwd,
|
|
1045
|
+
timeout: Math.max(config.opencodeTimeoutMs || 0, 0),
|
|
1046
|
+
cancelCheck: () => checkCancel(msg.session_id, msg._t),
|
|
1047
|
+
}, partialPoster(msg));
|
|
1048
|
+
if ((r.errorText || r.text).indexOf('Command not found') >= 0) {
|
|
1049
|
+
log('comando /' + name + ' no existe; lo trato como mensaje normal');
|
|
1050
|
+
const r2 = await runOpencode(msg, partialPoster(msg));
|
|
1051
|
+
return r2;
|
|
1052
|
+
}
|
|
1053
|
+
let out = (r.text || '').trim();
|
|
1054
|
+
if (!out && r.errorText) out = r.errorText;
|
|
1055
|
+
if (r.killed) out = (out ? out + '\n\n' : '') + '[La ejecución se cortó por tiempo límite]';
|
|
1056
|
+
if (!out) out = 'ok (sin texto)';
|
|
1057
|
+
const reached = r.sessionID || msg.opencode_session || null;
|
|
1058
|
+
if (r.sessionID && r.sessionID !== msg.opencode_session) log('sesión de opencode: ' + r.sessionID);
|
|
1059
|
+
return { text: out, reasoning: r.reasoning || '', opencodeSession: reached };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// ---------------------------------------------------------------------------
|
|
1063
|
+
// Crear una carpeta solicitada remotamente
|
|
1064
|
+
// ---------------------------------------------------------------------------
|
|
1065
|
+
// Lista las subcarpetas de primer nivel del workspace (solo directorios, no archivos).
|
|
1066
|
+
function listWorkspaceFolders(workspace) {
|
|
1067
|
+
return listWorkspaceFoldersRecursive(workspace, 1);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Recorre subcarpetas hasta `maxDepth` (default 3) excluyendo node_modules, .git y carpetas ocultas.
|
|
1071
|
+
function listWorkspaceFoldersRecursive(workspace, maxDepth) {
|
|
1072
|
+
if (!workspace) return [];
|
|
1073
|
+
maxDepth = maxDepth || 3;
|
|
1074
|
+
let root;
|
|
1075
|
+
try {
|
|
1076
|
+
root = path.resolve(workspace);
|
|
1077
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return [];
|
|
1078
|
+
} catch (e) { return []; }
|
|
1079
|
+
const SKIP = new Set(['node_modules', '.git', '.next', '.cache', 'dist', 'build', '.venv', '__pycache__']);
|
|
1080
|
+
const out = [];
|
|
1081
|
+
function walk(dir, depth, prefix) {
|
|
1082
|
+
let names = [];
|
|
1083
|
+
try {
|
|
1084
|
+
names = fs.readdirSync(dir).filter((n) => {
|
|
1085
|
+
if (n.startsWith('.')) return false;
|
|
1086
|
+
if (SKIP.has(n)) return false;
|
|
1087
|
+
try { return fs.statSync(path.join(dir, n)).isDirectory(); } catch (e) { return false; }
|
|
1088
|
+
});
|
|
1089
|
+
} catch (e) { return; }
|
|
1090
|
+
names.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
|
1091
|
+
for (const n of names) {
|
|
1092
|
+
const full = path.join(dir, n);
|
|
1093
|
+
const rel = prefix ? prefix + '/' + n : n;
|
|
1094
|
+
out.push({ name: n, path: full, rel: rel, depth: depth });
|
|
1095
|
+
if (depth < maxDepth) walk(full, depth + 1, rel);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
walk(root, 1, '');
|
|
1099
|
+
return out;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function createRemoteFolder(name) {
|
|
1103
|
+
const clean = String(name || '').trim();
|
|
1104
|
+
if (!config.allowCreateFolders) throw new Error('creación remota desactivada en config.json');
|
|
1105
|
+
const workspace = config.workspace ? path.resolve(config.workspace) : '';
|
|
1106
|
+
if (!workspace) throw new Error('config.json no define "workspace"');
|
|
1107
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 _\-\.\(\)]{1,49}$/.test(clean)) {
|
|
1108
|
+
throw new Error('nombre de carpeta inválido');
|
|
1109
|
+
}
|
|
1110
|
+
if (!fs.existsSync(workspace)) throw new Error('el espacio de trabajo no existe: ' + workspace);
|
|
1111
|
+
const dir = path.join(workspace, clean);
|
|
1112
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
|
|
1113
|
+
let list = readFolders();
|
|
1114
|
+
if (!list.some((f) => f && f.path === dir)) {
|
|
1115
|
+
list.push({ name: clean, path: dir });
|
|
1116
|
+
writeFolders(list);
|
|
1117
|
+
}
|
|
1118
|
+
return { name: clean, path: dir };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async function handleFolderRequest(req) {
|
|
1122
|
+
const name = String(req.name || '').trim();
|
|
1123
|
+
log('solicitud de carpeta: "' + name + '" (id ' + req.id + ')');
|
|
1124
|
+
try {
|
|
1125
|
+
const folder = createRemoteFolder(name);
|
|
1126
|
+
await api('folder_done', { id: req.id, ok: true, folder: folder }, req._t);
|
|
1127
|
+
log('carpeta creada: ' + folder.path);
|
|
1128
|
+
} catch (e) {
|
|
1129
|
+
log('error al crear carpeta: ' + e.message);
|
|
1130
|
+
try { await api('folder_done', { id: req.id, ok: false, error: e.message }, req._t); } catch (e2) { /* noop */ }
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
// Sincronizar catálogo (carpetas + modelos + workspace + flags + agentes)
|
|
1136
|
+
// ---------------------------------------------------------------------------
|
|
1137
|
+
async function syncCatalog(opts) {
|
|
1138
|
+
try {
|
|
1139
|
+
// Solo carpetas principales del workspace (nivel 1): el dropdown de
|
|
1140
|
+
// "nueva sesión" queda limpio y el catálogo liviano. Los chats viejos
|
|
1141
|
+
// conservan su carpeta aunque ya no esté en la lista.
|
|
1142
|
+
let folders = readFolders();
|
|
1143
|
+
if (config.workspace) {
|
|
1144
|
+
const root = path.resolve(config.workspace);
|
|
1145
|
+
folders = folders.filter((f) => {
|
|
1146
|
+
const p = typeof f === 'string' ? f : f.path;
|
|
1147
|
+
if (!p) return false;
|
|
1148
|
+
try {
|
|
1149
|
+
const rel = path.relative(root, path.resolve(p));
|
|
1150
|
+
if (!rel || rel.split(path.sep).includes('..')) return false;
|
|
1151
|
+
return !rel.includes(path.sep); // solo nivel 1
|
|
1152
|
+
} catch (e) { return false; }
|
|
1153
|
+
});
|
|
1154
|
+
try {
|
|
1155
|
+
const discovered = listWorkspaceFolders(config.workspace); // depth 1
|
|
1156
|
+
const known = new Set(folders.map((f) => (typeof f === 'string' ? f : f.path)));
|
|
1157
|
+
for (const d of discovered) {
|
|
1158
|
+
if (!known.has(d.path)) folders.push({ name: d.name, path: d.path });
|
|
1159
|
+
}
|
|
1160
|
+
} catch (e) {
|
|
1161
|
+
log('aviso: no pude escanear el workspace: ' + e.message);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const full = await getModelsFull();
|
|
1165
|
+
const models = await resolveModels();
|
|
1166
|
+
const agents = resolveAgents();
|
|
1167
|
+
const caps = await getModelCaps();
|
|
1168
|
+
const vision = Object.keys(caps).filter((k) => caps[k].vision);
|
|
1169
|
+
const modelsCtx = {};
|
|
1170
|
+
for (const k of Object.keys(caps)) {
|
|
1171
|
+
const c = caps[k] && caps[k].ctx;
|
|
1172
|
+
if (typeof c === 'number' && c > 0) modelsCtx[k] = c;
|
|
1173
|
+
}
|
|
1174
|
+
const payload = {
|
|
1175
|
+
folders: folders,
|
|
1176
|
+
models: models,
|
|
1177
|
+
models_full: full.groups,
|
|
1178
|
+
models_ctx: modelsCtx,
|
|
1179
|
+
vision: vision,
|
|
1180
|
+
workspace: config.workspace || '',
|
|
1181
|
+
allowCreateFolders: !!config.allowCreateFolders,
|
|
1182
|
+
agents: agents,
|
|
1183
|
+
};
|
|
1184
|
+
let res = null;
|
|
1185
|
+
for (const t of activeTargets()) {
|
|
1186
|
+
try {
|
|
1187
|
+
const r1 = await api('sync_catalog', payload, t);
|
|
1188
|
+
if (!res) res = r1;
|
|
1189
|
+
} catch (e2) {
|
|
1190
|
+
log('aviso: no se pudo sincronizar el catálogo con ' + t + ': ' + e2.message);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (!res) res = { folders: 0, models: 0, agents: 0 };
|
|
1194
|
+
if (!opts || !opts.silent) {
|
|
1195
|
+
log('catálogo sincronizado: ' + res.folders + ' carpetas, ' + (full.total || 0) + ' modelos en '
|
|
1196
|
+
+ Object.keys(full.groups).length + ' proveedores (' + res.models + ' favoritos), ' + res.agents + ' agentes' +
|
|
1197
|
+
(config.allowCreateFolders ? ' · creacion remota: ON' : ' · creacion remota: OFF'));
|
|
1198
|
+
log('workspace: ' + (config.workspace || '(sin definir)'));
|
|
1199
|
+
log('favoritos (' + models.length + '): ' + models.join(', '));
|
|
1200
|
+
log('agentes (' + agents.length + '): ' + agents.join(', '));
|
|
1201
|
+
}
|
|
1202
|
+
} catch (e) {
|
|
1203
|
+
log('aviso: no se pudo sincronizar el catálogo: ' + e.message);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// ---------------------------------------------------------------------------
|
|
1208
|
+
// Historial único: importa al hosting las sesiones de opencode hechas en el
|
|
1209
|
+
// TUI local. opencode en la PC es la fuente; la web las espeja y puede
|
|
1210
|
+
// continuarlas (el puente ya usa --session al responder).
|
|
1211
|
+
// ---------------------------------------------------------------------------
|
|
1212
|
+
const SYNC_STATE_PATH = path.join(HOME, 'sync-state.json');
|
|
1213
|
+
// v5: los espejos importados ahora se marcan (`importada`) y salen de
|
|
1214
|
+
// known_oc; re-importa todo una vez para marcarlos y corregir su carpeta.
|
|
1215
|
+
let syncState = { version: 5, targets: {} };
|
|
1216
|
+
try {
|
|
1217
|
+
const parsed = JSON.parse(fs.readFileSync(SYNC_STATE_PATH, 'utf8'));
|
|
1218
|
+
if (parsed && parsed.version === 5 && parsed.targets) syncState = parsed;
|
|
1219
|
+
} catch (e) { /* estado nuevo */ }
|
|
1220
|
+
// Estado por destino (remoto/local): cada hosting lleva su propia marca de
|
|
1221
|
+
// qué sesiones ya importó (el merge es idempotente igual).
|
|
1222
|
+
function targetState(t) {
|
|
1223
|
+
const mode = t || activeMode || requestedMode();
|
|
1224
|
+
const s = syncState.targets[mode] || (syncState.targets[mode] = { folders: {}, fullScanTs: 0 });
|
|
1225
|
+
return s;
|
|
1226
|
+
}
|
|
1227
|
+
function saveSyncState() {
|
|
1228
|
+
try { fs.writeFileSync(SYNC_STATE_PATH, JSON.stringify(syncState)); } catch (e) {}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
let sweepRunning = false;
|
|
1232
|
+
let lastKnownOc = { remoto: [], local: [] }; // chats web ya vinculados, por destino
|
|
1233
|
+
let activeRunFolder = null; // carpeta con un mensaje procesándose (evita carreras)
|
|
1234
|
+
let activeMsgSession = null; // id de sesión (del hosting) cuyo mensaje se está ejecutando
|
|
1235
|
+
let activeMsgTarget = null; // destino donde vive esa sesión
|
|
1236
|
+
const importBackoffUntil = { remoto: 0, local: 0 }; // backoff si el hosting no soporta session_import
|
|
1237
|
+
|
|
1238
|
+
// Avisa al hosting qué sesión está ejecutando opencode (o que ya no hay
|
|
1239
|
+
// ninguna). Con sessionId null limpia el estado. Fire-and-forget.
|
|
1240
|
+
function notifyBusy(target, sessionId) {
|
|
1241
|
+
if (!target) return;
|
|
1242
|
+
api('heartbeat', { busy: sessionId != null, busy_session: sessionId || null }, target).catch(() => {});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Un destino caído (p. ej. el servidor local apagado en modo DUAL) no debe
|
|
1246
|
+
// inundar el log: se sigue reintentando, pero el mensaje de error sale como
|
|
1247
|
+
// mucho una vez por minuto por destino.
|
|
1248
|
+
const downLogAt = {}; // destino -> próximo instante en el que se vuelve a loguear
|
|
1249
|
+
function shouldLogDown(t) {
|
|
1250
|
+
const now = Date.now();
|
|
1251
|
+
if (now < (downLogAt[t] || 0)) return false;
|
|
1252
|
+
downLogAt[t] = now + 60000;
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function normFolder(f) {
|
|
1257
|
+
return String(f || '').replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase();
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
1261
|
+
|
|
1262
|
+
function topWorkspaceFolders() {
|
|
1263
|
+
if (!config.workspace) return [];
|
|
1264
|
+
let root;
|
|
1265
|
+
try { root = path.resolve(config.workspace); } catch (e) { return []; }
|
|
1266
|
+
let names = [];
|
|
1267
|
+
try { names = fs.readdirSync(root); } catch (e) { return []; }
|
|
1268
|
+
const out = [];
|
|
1269
|
+
for (const name of names) {
|
|
1270
|
+
if (fsSkipName(name)) continue;
|
|
1271
|
+
try {
|
|
1272
|
+
if (fs.statSync(path.join(root, name)).isDirectory()) out.push(path.join(root, name));
|
|
1273
|
+
} catch (e) { /* sigue */ }
|
|
1274
|
+
}
|
|
1275
|
+
return out;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Parsea la tabla de `opencode session list` (columnas: ID, Title, Updated).
|
|
1279
|
+
async function listSessionsInFolder(folder) {
|
|
1280
|
+
const r = await runCli(['session', 'list'], { cwd: folder, timeout: 30000 });
|
|
1281
|
+
if (!r.ok) return [];
|
|
1282
|
+
const out = [];
|
|
1283
|
+
for (const line of r.text.split(/\r?\n/)) {
|
|
1284
|
+
const idm = line.match(/^(ses_[A-Za-z0-9]+)\s{2,}/);
|
|
1285
|
+
if (!idm) continue;
|
|
1286
|
+
const rest = line.slice(idm[1].length).trim();
|
|
1287
|
+
const tm = rest.match(/\s(\d{1,2}:\d{2}(?: · .+)?)$/);
|
|
1288
|
+
out.push({
|
|
1289
|
+
id: idm[1],
|
|
1290
|
+
title: (tm ? rest.slice(0, tm.index) : rest).trim(),
|
|
1291
|
+
updated: tm ? tm[1] : '',
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
return out;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Exporta una sesión solo para leer tokens/costo acumulados (chats web ya
|
|
1298
|
+
// vinculados: no se importan mensajes, solo se actualizan esos dos números).
|
|
1299
|
+
// De paso manda la carpeta real (info.directory): corrige sesiones que una
|
|
1300
|
+
// pasada vieja clasificó con la carpeta del barrido en vez del proyecto.
|
|
1301
|
+
async function refreshTokens(folder, sess, target) {
|
|
1302
|
+
const r = await runCli(['export', sess.id], { cwd: folder, timeout: 90000 });
|
|
1303
|
+
if (!r.ok || !r.text) throw new Error('export falló');
|
|
1304
|
+
const data = JSON.parse(r.text);
|
|
1305
|
+
const info = data.info || {};
|
|
1306
|
+
const tk = info.tokens || {};
|
|
1307
|
+
const tokens = (tk.input || 0) + (tk.output || 0) + (tk.reasoning || 0);
|
|
1308
|
+
const realDir = typeof info.directory === 'string' && info.directory ? info.directory : folder;
|
|
1309
|
+
await api('session_tokens', {
|
|
1310
|
+
opencode_session: sess.id,
|
|
1311
|
+
tokens: tokens,
|
|
1312
|
+
cost: typeof info.cost === 'number' ? info.cost : 0,
|
|
1313
|
+
folder: realDir,
|
|
1314
|
+
}, target);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Exporta una sesión de opencode (JSON) y la manda al hosting indicado. El
|
|
1318
|
+
// merge del hosting es idempotente (clave rol|fecha|texto): reimportar no duplica.
|
|
1319
|
+
async function exportAndImport(folder, sess, target) {
|
|
1320
|
+
const r = await runCli(['export', sess.id], { cwd: folder, timeout: 90000 });
|
|
1321
|
+
if (!r.ok || !r.text) throw new Error('export falló' + (r.killed ? ' (timeout)' : ''));
|
|
1322
|
+
const data = JSON.parse(r.text);
|
|
1323
|
+
const msgs = [];
|
|
1324
|
+
let lastAgent = '';
|
|
1325
|
+
for (const m of (data.messages || [])) {
|
|
1326
|
+
const info = m.info || {};
|
|
1327
|
+
const role = info.role;
|
|
1328
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
1329
|
+
const parts = m.parts || [];
|
|
1330
|
+
const text = parts
|
|
1331
|
+
.filter((p) => p && p.type === 'text' && typeof p.text === 'string')
|
|
1332
|
+
.map((p) => p.text).join('\n\n').trim();
|
|
1333
|
+
const reasoning = role === 'assistant'
|
|
1334
|
+
? parts.filter((p) => p && p.type === 'reasoning' && typeof p.text === 'string')
|
|
1335
|
+
.map((p) => p.text).join('\n\n').trim().slice(0, 50000)
|
|
1336
|
+
: '';
|
|
1337
|
+
if (!text && !reasoning) continue;
|
|
1338
|
+
const ts = info.time && info.time.created ? new Date(Number(info.time.created)).toISOString() : '';
|
|
1339
|
+
if (role === 'user' && info.agent) lastAgent = info.agent;
|
|
1340
|
+
const out = {
|
|
1341
|
+
role: role,
|
|
1342
|
+
text: text.slice(0, 50000),
|
|
1343
|
+
ts: ts,
|
|
1344
|
+
agent: role === 'assistant' ? lastAgent : (info.agent || ''),
|
|
1345
|
+
};
|
|
1346
|
+
if (reasoning) out.reasoning = reasoning;
|
|
1347
|
+
msgs.push(out);
|
|
1348
|
+
}
|
|
1349
|
+
const info = data.info || {};
|
|
1350
|
+
const updatedMs = info.time && info.time.updated ? Number(info.time.updated) : 0;
|
|
1351
|
+
// Carpeta real del proyecto donde vive la sesión: `session list` desde
|
|
1352
|
+
// carpetas sin git propio devuelve la lista del proyecto compartido, así
|
|
1353
|
+
// que la carpeta del barrido puede mentir. info.directory no.
|
|
1354
|
+
const realDir = typeof info.directory === 'string' && info.directory ? info.directory : folder;
|
|
1355
|
+
// Tokens de la sesión (sin cache read: es contexto releído, no consumo nuevo).
|
|
1356
|
+
const tk = info.tokens || {};
|
|
1357
|
+
const tokens = (tk.input || 0) + (tk.output || 0) + (tk.reasoning || 0);
|
|
1358
|
+
await api('session_import', {
|
|
1359
|
+
opencode_session: sess.id,
|
|
1360
|
+
folder: realDir,
|
|
1361
|
+
name: String(info.title || sess.title || '').slice(0, 60),
|
|
1362
|
+
updated: updatedMs ? new Date(updatedMs).toISOString() : '',
|
|
1363
|
+
model: (Array.isArray(config.models) && config.models[0]) || '',
|
|
1364
|
+
agent: 'build',
|
|
1365
|
+
tokens: tokens,
|
|
1366
|
+
cost: typeof info.cost === 'number' ? info.cost : 0,
|
|
1367
|
+
messages: msgs.slice(-400),
|
|
1368
|
+
}, target);
|
|
1369
|
+
return realDir;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// Barrido: recorre carpetas (o una lista puntual), compara contra el estado
|
|
1373
|
+
// local y exporta/importa las sesiones nuevas o actualizadas. Escaneo completo
|
|
1374
|
+
// al arrancar y cada 6 h; los intermedios solo tocan carpetas con actividad.
|
|
1375
|
+
const FULL_SCAN_MS = 6 * 60 * 60 * 1000;
|
|
1376
|
+
|
|
1377
|
+
async function syncSessions(opts) {
|
|
1378
|
+
if (sweepRunning) return;
|
|
1379
|
+
sweepRunning = true;
|
|
1380
|
+
try {
|
|
1381
|
+
for (const t of activeTargets()) {
|
|
1382
|
+
await sweepTarget(t, opts);
|
|
1383
|
+
}
|
|
1384
|
+
} finally {
|
|
1385
|
+
sweepRunning = false;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Barrido de un destino: importa al hosting t las sesiones de opencode que
|
|
1390
|
+
// ese hosting no conoce. Incluye los chats web creados en el otro destino
|
|
1391
|
+
// (todos viven en opencode): así ambos lados terminan viendo lo mismo.
|
|
1392
|
+
async function sweepTarget(t, opts) {
|
|
1393
|
+
// Si el hosting de t no soporta session_import (código viejo), no seguir
|
|
1394
|
+
// exportando al vacío: reintentamos dentro de un rato.
|
|
1395
|
+
if (Date.now() < (importBackoffUntil[t] || 0)) return 0;
|
|
1396
|
+
let imported = 0;
|
|
1397
|
+
const target = targetState(t);
|
|
1398
|
+
const full = !target.fullScanTs || (Date.now() - target.fullScanTs > FULL_SCAN_MS);
|
|
1399
|
+
let folders;
|
|
1400
|
+
if (opts && opts.folders && opts.folders.length) {
|
|
1401
|
+
folders = opts.folders;
|
|
1402
|
+
} else if (full) {
|
|
1403
|
+
folders = topWorkspaceFolders();
|
|
1404
|
+
} else {
|
|
1405
|
+
folders = Object.keys(target.folders);
|
|
1406
|
+
}
|
|
1407
|
+
const knownOc = lastKnownOc[t] || [];
|
|
1408
|
+
// Primer barrido del destino (nada importado todavía): sin tope, para que
|
|
1409
|
+
// el lado nuevo se llene de una en vez de esperar varias pasadas.
|
|
1410
|
+
const sinTope = !Object.keys(target.folders).length;
|
|
1411
|
+
// Varias carpetas pueden listar la misma sesión (proyectos compartidos sin
|
|
1412
|
+
// git propio): solo se atiende una vez por pasada.
|
|
1413
|
+
const seen = new Set();
|
|
1414
|
+
for (const folder of folders) {
|
|
1415
|
+
if (busy) { log('barrido de sesiones pausado: hay un mensaje procesándose'); return imported; }
|
|
1416
|
+
if (activeRunFolder && normFolder(folder) === normFolder(activeRunFolder)) continue;
|
|
1417
|
+
let sessions = [];
|
|
1418
|
+
try {
|
|
1419
|
+
sessions = await listSessionsInFolder(folder);
|
|
1420
|
+
} catch (e) { continue; }
|
|
1421
|
+
if (!sessions.length) continue;
|
|
1422
|
+
const fstate = target.folders[folder] || (target.folders[folder] = {});
|
|
1423
|
+
for (const s of sessions) {
|
|
1424
|
+
if (fstate[s.id] === s.updated) { seen.add(s.id); continue; } // sin cambios desde el último barrido
|
|
1425
|
+
if (seen.has(s.id)) { fstate[s.id] = s.updated; continue; } // ya atendida en esta pasada
|
|
1426
|
+
seen.add(s.id);
|
|
1427
|
+
if (knownOc.includes(s.id)) {
|
|
1428
|
+
// Chat web vinculado: no se importa (sería duplicar), solo se
|
|
1429
|
+
// refrescan tokens/costo desde el export de opencode.
|
|
1430
|
+
try {
|
|
1431
|
+
await refreshTokens(folder, s, t);
|
|
1432
|
+
fstate[s.id] = s.updated;
|
|
1433
|
+
saveSyncState();
|
|
1434
|
+
} catch (e) {
|
|
1435
|
+
log('aviso: no pude refrescar tokens de ' + s.id + ': ' + e.message);
|
|
1436
|
+
}
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
try {
|
|
1440
|
+
const realDir = await exportAndImport(folder, s, t);
|
|
1441
|
+
fstate[s.id] = s.updated;
|
|
1442
|
+
if (realDir && realDir !== folder) {
|
|
1443
|
+
// Carpeta canónica de la sesión: queda marcada también ahí
|
|
1444
|
+
// para los barridos intermedios y refreshTokens.
|
|
1445
|
+
const rf = target.folders[realDir] || (target.folders[realDir] = {});
|
|
1446
|
+
rf[s.id] = s.updated;
|
|
1447
|
+
}
|
|
1448
|
+
saveSyncState();
|
|
1449
|
+
imported++;
|
|
1450
|
+
log('sesión importada (' + t + '): ' + s.id + ' "' + (s.title || '(sin título)') + '"'
|
|
1451
|
+
+ (realDir && realDir !== folder ? ' → ' + realDir : ''));
|
|
1452
|
+
} catch (e) {
|
|
1453
|
+
log('aviso: no se pudo importar ' + s.id + ': ' + e.message);
|
|
1454
|
+
if (e.message.indexOf('Acción no válida') >= 0) {
|
|
1455
|
+
importBackoffUntil[t] = Date.now() + 10 * 60 * 1000;
|
|
1456
|
+
log('barrido (' + t + '): el hosting no soporta session_import; reintento en 10 min (¿falta actualizar api.php?)');
|
|
1457
|
+
return imported;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (!sinTope && imported >= 80) { log('barrido (' + t + '): tope de importaciones por pasada (sigue en la próxima)'); return imported; }
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
if (full) {
|
|
1464
|
+
target.fullScanTs = Date.now();
|
|
1465
|
+
saveSyncState();
|
|
1466
|
+
}
|
|
1467
|
+
if (imported || !(opts && opts.silent)) {
|
|
1468
|
+
log('barrido de sesiones (' + t + '): ' + imported + ' importada(s)');
|
|
1469
|
+
}
|
|
1470
|
+
return imported;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// ---------------------------------------------------------------------------
|
|
1474
|
+
// Archivos: listado y lectura (read-only) resueltos por el puente,
|
|
1475
|
+
// siempre dentro del workspace y sin recorrer basura (node_modules, .git...).
|
|
1476
|
+
// ---------------------------------------------------------------------------
|
|
1477
|
+
function fsSkipName(name) {
|
|
1478
|
+
if (!name || name.startsWith('.')) return true;
|
|
1479
|
+
const SKIP = new Set(['node_modules', 'dist', 'build', 'vendor', 'target', '__pycache__', '.next', '.cache', '.venv', '.gradle', '.idea', '.vscode']);
|
|
1480
|
+
return SKIP.has(name);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function resolveInWorkspace(rel) {
|
|
1484
|
+
const root = config.workspace ? path.resolve(config.workspace) : '';
|
|
1485
|
+
if (!root) throw new Error('config.json no define "workspace"');
|
|
1486
|
+
const parts = String(rel || '').replace(/\\/g, '/').split('/').filter((p) => p && p !== '.' && p !== '..');
|
|
1487
|
+
let abs = root;
|
|
1488
|
+
for (const p of parts) abs = path.join(abs, p);
|
|
1489
|
+
let real, rootReal;
|
|
1490
|
+
try {
|
|
1491
|
+
real = fs.realpathSync(abs);
|
|
1492
|
+
rootReal = fs.realpathSync(root);
|
|
1493
|
+
} catch (e) {
|
|
1494
|
+
throw new Error('ruta inexistente');
|
|
1495
|
+
}
|
|
1496
|
+
const rootNorm = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
1497
|
+
if (real !== rootReal && !real.startsWith(rootNorm)) throw new Error('fuera del workspace');
|
|
1498
|
+
return { abs: real, rel: parts.join('/') };
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function fsList(rel) {
|
|
1502
|
+
const r = resolveInWorkspace(rel);
|
|
1503
|
+
const items = fs.readdirSync(r.abs, { withFileTypes: true });
|
|
1504
|
+
const dirs = [];
|
|
1505
|
+
const files = [];
|
|
1506
|
+
let skipped = false;
|
|
1507
|
+
for (const it of items) {
|
|
1508
|
+
if (fsSkipName(it.name)) { skipped = true; continue; }
|
|
1509
|
+
const full = path.join(r.abs, it.name);
|
|
1510
|
+
let st = null;
|
|
1511
|
+
try { st = fs.statSync(full); } catch (e) { continue; }
|
|
1512
|
+
if (st.isDirectory()) {
|
|
1513
|
+
dirs.push({ name: it.name, type: 'dir', size: null, mtime: Math.floor(st.mtimeMs / 1000) });
|
|
1514
|
+
} else if (st.isFile()) {
|
|
1515
|
+
files.push({ name: it.name, type: 'file', size: st.size, mtime: Math.floor(st.mtimeMs / 1000) });
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const cmp = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
|
1519
|
+
dirs.sort(cmp);
|
|
1520
|
+
files.sort(cmp);
|
|
1521
|
+
const entries = dirs.concat(files);
|
|
1522
|
+
return { path: r.rel, entries: entries.slice(0, 500), truncated: entries.length > 500 || skipped };
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function fsRead(rel) {
|
|
1526
|
+
const r = resolveInWorkspace(rel);
|
|
1527
|
+
const st = fs.statSync(r.abs);
|
|
1528
|
+
if (!st.isFile()) throw new Error('no es un archivo');
|
|
1529
|
+
if (st.size > 512 * 1024) throw new Error('demasiado grande (>512 KB)');
|
|
1530
|
+
const buf = fs.readFileSync(r.abs);
|
|
1531
|
+
const sample = buf.subarray(0, Math.min(buf.length, 4096));
|
|
1532
|
+
let nonPrint = 0;
|
|
1533
|
+
for (const b of sample) {
|
|
1534
|
+
if (b === 0 || (b < 32 && b !== 9 && b !== 10 && b !== 13)) nonPrint++;
|
|
1535
|
+
}
|
|
1536
|
+
if (sample.length && nonPrint / sample.length > 0.3) throw new Error('archivo binario');
|
|
1537
|
+
return { path: r.rel, size: st.size, content: buf.toString('utf8') };
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
async function handleFsCommand(cmd) {
|
|
1541
|
+
const rel = (cmd.args && cmd.args.length ? String(cmd.args[0]) : '');
|
|
1542
|
+
try {
|
|
1543
|
+
const r = cmd.name === 'fs_list' ? fsList(rel) : fsRead(rel);
|
|
1544
|
+
await api('fs_result', { id: cmd.id, ok: true, text: JSON.stringify(r), error: '' }, cmd._t);
|
|
1545
|
+
log('fs ' + cmd.name + ' "' + (rel || '/') + '" ok (' + JSON.stringify(r).length + ' car.)');
|
|
1546
|
+
} catch (e) {
|
|
1547
|
+
await api('fs_result', { id: cmd.id, ok: false, text: '', error: e.message }, cmd._t);
|
|
1548
|
+
log('fs ' + cmd.name + ' "' + (rel || '/') + '" error: ' + e.message);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// ---------------------------------------------------------------------------
|
|
1553
|
+
// Procesa comandos read-only encolados por la web.
|
|
1554
|
+
// ---------------------------------------------------------------------------
|
|
1555
|
+
|
|
1556
|
+
// Túneles TunnelMole: procesos vivos en esta PC, en memoria mientras corre el
|
|
1557
|
+
// puente. La web los abre/cierra/lista con tunnel_start/tunnel_stop/tunnel_list.
|
|
1558
|
+
const tunnels = new Map();
|
|
1559
|
+
// Hostings que no soportan el poll liviano (api.php viejo): si en modo lite
|
|
1560
|
+
// devuelven mensajes, los reclamaríamos y los ignoraríamos (quedarían
|
|
1561
|
+
// colgados hasta el STALE). Se desactiva el lite para ese destino.
|
|
1562
|
+
const liteUnsupported = new Set();
|
|
1563
|
+
|
|
1564
|
+
function tunnelPortOf(args) {
|
|
1565
|
+
const s = String((args && args.length ? args[0] : '') || '').trim();
|
|
1566
|
+
if (!/^\d{1,5}$/.test(s)) return null;
|
|
1567
|
+
const n = parseInt(s, 10);
|
|
1568
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
async function tunnelDone(cmd, ok, payload, error) {
|
|
1572
|
+
await api('command_done', {
|
|
1573
|
+
id: cmd.id,
|
|
1574
|
+
ok,
|
|
1575
|
+
text: ok ? JSON.stringify(payload) : '',
|
|
1576
|
+
error: error || '',
|
|
1577
|
+
}, cmd._t);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function parseTunnelUrls(text) {
|
|
1581
|
+
const urls = [...new Set(String(text).match(/https?:\/\/[a-z0-9-]+\.tunnelmole\.net/g) || [])];
|
|
1582
|
+
return {
|
|
1583
|
+
https: urls.find((u) => u.startsWith('https')) || '',
|
|
1584
|
+
http: urls.find((u) => u.startsWith('http:')) || '',
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// Abre el túnel para un puerto. Si `tmole` está instalado lo usa; si no,
|
|
1589
|
+
// recurre a `npx --yes tunnelmole` (lo descarga la primera vez). Los binarios
|
|
1590
|
+
// se resuelven con el mismo resolver de los procesos: en Windows los shims
|
|
1591
|
+
// .cmd no se pueden spawnear directo (EINVAL) y sin esto el túnel nunca abre.
|
|
1592
|
+
function tunnelSpawn(port, onLine, onFail, onUrls, triedNpx = false) {
|
|
1593
|
+
let spec;
|
|
1594
|
+
try {
|
|
1595
|
+
spec = procResolveBin(triedNpx ? 'npx' : 'tmole', null);
|
|
1596
|
+
} catch (e) {
|
|
1597
|
+
if (!triedNpx) {
|
|
1598
|
+
log('túnel: tmole no está en PATH, probando con npx…');
|
|
1599
|
+
tunnelSpawn(port, onLine, onFail, onUrls, true);
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
onFail('no se encontró npx para abrir el túnel (' + e.message + ')');
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const args = (spec.pre || []).concat(triedNpx ? ['--yes', 'tunnelmole', String(port)] : [String(port)]);
|
|
1606
|
+
let proc;
|
|
1607
|
+
try {
|
|
1608
|
+
proc = spawn(spec.bin, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
1609
|
+
} catch (e) {
|
|
1610
|
+
onFail('no se pudo iniciar tunnelmole (' + e.message + '). Instalalo con: npm i -g tunnelmole');
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
let buf = '';
|
|
1614
|
+
const feed = (d) => {
|
|
1615
|
+
buf += String(d);
|
|
1616
|
+
const urls = parseTunnelUrls(buf);
|
|
1617
|
+
if (urls.https || urls.http) onUrls(urls);
|
|
1618
|
+
};
|
|
1619
|
+
proc.stdout.on('data', feed);
|
|
1620
|
+
proc.stderr.on('data', feed);
|
|
1621
|
+
proc.on('error', (e) => {
|
|
1622
|
+
onFail('no se pudo iniciar tunnelmole (' + e.message + '). Instalalo con: npm i -g tunnelmole');
|
|
1623
|
+
});
|
|
1624
|
+
proc.on('exit', (code) => {
|
|
1625
|
+
onFail('tmole terminó (código ' + code + ')' + (buf ? ': ' + buf.slice(0, 160).trim() : ''));
|
|
1626
|
+
});
|
|
1627
|
+
onLine(proc);
|
|
1628
|
+
return proc;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function tunnelStart(cmd) {
|
|
1632
|
+
const port = tunnelPortOf(cmd.args);
|
|
1633
|
+
if (!port) {
|
|
1634
|
+
await tunnelDone(cmd, false, null, 'puerto inválido (usá 1–65535)');
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
const existing = tunnels.get(port);
|
|
1638
|
+
if (existing && existing.proc && !existing.proc.killed) {
|
|
1639
|
+
await tunnelDone(cmd, true, { port, ...existing.urls });
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
log('túnel: abriendo para el puerto ' + port + '…');
|
|
1643
|
+
let settled = false;
|
|
1644
|
+
let entry = null;
|
|
1645
|
+
let timer = null;
|
|
1646
|
+
const finish = async (ok, error) => {
|
|
1647
|
+
if (settled) return;
|
|
1648
|
+
settled = true;
|
|
1649
|
+
clearTimeout(timer);
|
|
1650
|
+
if (ok) {
|
|
1651
|
+
await tunnelDone(cmd, true, { port, ...entry.urls });
|
|
1652
|
+
log('túnel: puerto ' + port + ' → ' + (entry.urls.https || entry.urls.http));
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
tunnels.delete(port);
|
|
1656
|
+
if (entry && entry.proc) { try { entry.proc.kill(); } catch (e) { /* ya murió */ } }
|
|
1657
|
+
await tunnelDone(cmd, false, null, error || 'tunnelmole no devolvió URLs');
|
|
1658
|
+
log('túnel: error en puerto ' + port + ': ' + (error || 'sin URLs'));
|
|
1659
|
+
};
|
|
1660
|
+
entry = { port, proc: null, urls: { https: '', http: '' }, startedAt: Date.now() };
|
|
1661
|
+
tunnels.set(port, entry);
|
|
1662
|
+
// 60 s: con el fallback de npx, la primera vez descarga tunnelmole.
|
|
1663
|
+
timer = setTimeout(() => {
|
|
1664
|
+
finish(false, 'tunnelmole no devolvió URLs (60 s). ¿Está instalado? npm i -g tunnelmole');
|
|
1665
|
+
}, 60000);
|
|
1666
|
+
tunnelSpawn(
|
|
1667
|
+
port,
|
|
1668
|
+
(proc) => { entry.proc = proc; },
|
|
1669
|
+
(error) => finish(false, error),
|
|
1670
|
+
(urls) => {
|
|
1671
|
+
entry.urls = urls;
|
|
1672
|
+
finish(true);
|
|
1673
|
+
}
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
async function tunnelStop(cmd) {
|
|
1678
|
+
const port = tunnelPortOf(cmd.args);
|
|
1679
|
+
if (!port) {
|
|
1680
|
+
await tunnelDone(cmd, false, null, 'puerto inválido (usá 1–65535)');
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
const entry = tunnels.get(port);
|
|
1684
|
+
if (!entry) {
|
|
1685
|
+
await tunnelDone(cmd, true, { port, stopped: true });
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
tunnels.delete(port);
|
|
1689
|
+
try {
|
|
1690
|
+
if (process.platform === 'win32' && entry.proc && entry.proc.pid) {
|
|
1691
|
+
spawn('taskkill', ['/PID', String(entry.proc.pid), '/T', '/F'], { windowsHide: true });
|
|
1692
|
+
} else if (entry.proc) {
|
|
1693
|
+
entry.proc.kill('SIGTERM');
|
|
1694
|
+
}
|
|
1695
|
+
} catch (e) { /* nada que cerrar */ }
|
|
1696
|
+
log('túnel: cerrado el puerto ' + port);
|
|
1697
|
+
await tunnelDone(cmd, true, { port, stopped: true });
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
async function tunnelList(cmd) {
|
|
1701
|
+
const list = [];
|
|
1702
|
+
for (const [port, e] of tunnels) {
|
|
1703
|
+
if (!e.proc || e.proc.killed) continue;
|
|
1704
|
+
list.push({
|
|
1705
|
+
port,
|
|
1706
|
+
https: e.urls.https,
|
|
1707
|
+
http: e.urls.http,
|
|
1708
|
+
uptime: Math.round((Date.now() - e.startedAt) / 1000),
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
await tunnelDone(cmd, true, { tunnels: list });
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// ---------------------------------------------------------------------------
|
|
1715
|
+
// Procesos de desarrollo (dev servers). Viven en memoria del puente (mueren
|
|
1716
|
+
// con él, como los túneles). Se lanzan SIEMPRE con cwd dentro del workspace y
|
|
1717
|
+
// su binario debe estar en la whitelist de bridge/config.json → "processes".
|
|
1718
|
+
// Sin shell: en Windows los shims .cmd/.bat se resuelven al binario real o se
|
|
1719
|
+
// ejecutan vía cmd.exe, nunca interpolando la línea de comando.
|
|
1720
|
+
// ---------------------------------------------------------------------------
|
|
1721
|
+
const procs = new Map(); // id -> entrada
|
|
1722
|
+
let nextProcId = 1;
|
|
1723
|
+
const PROC_LOG_TAIL = 256 * 1024; // anillo de log retenido por proceso
|
|
1724
|
+
const PROC_LOG_CHUNK = 32 * 1024; // máx. caracteres por respuesta de proc_log
|
|
1725
|
+
|
|
1726
|
+
function procConfig() {
|
|
1727
|
+
const p = (config && config.processes) || {};
|
|
1728
|
+
return {
|
|
1729
|
+
enabled: p.enabled !== false,
|
|
1730
|
+
allow: Array.isArray(p.allow)
|
|
1731
|
+
? p.allow.map((a) => String(a).toLowerCase().replace(/\.(exe|cmd|bat|com)$/, ''))
|
|
1732
|
+
: ['npm', 'node', 'npx'],
|
|
1733
|
+
maxGlobal: Math.max(1, parseInt(p.maxGlobal, 10) || 3),
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// La carpeta de trabajo debe ser real y quedar DENTRO del workspace. Misma
|
|
1738
|
+
// garantía que los fs_*: realpath + prefijo del root. Nunca un cwd arbitrario.
|
|
1739
|
+
function procResolveFolder(dir) {
|
|
1740
|
+
const root = config.workspace ? path.resolve(config.workspace) : '';
|
|
1741
|
+
if (!root) throw new Error('config.json no define "workspace"');
|
|
1742
|
+
if (!String(dir || '').trim()) throw new Error('carpeta vacía');
|
|
1743
|
+
let real, rootReal;
|
|
1744
|
+
try {
|
|
1745
|
+
real = fs.realpathSync(path.resolve(String(dir)));
|
|
1746
|
+
rootReal = fs.realpathSync(root);
|
|
1747
|
+
} catch (e) {
|
|
1748
|
+
throw new Error('carpeta inexistente');
|
|
1749
|
+
}
|
|
1750
|
+
const norm = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
1751
|
+
if (real !== rootReal && !real.startsWith(norm)) throw new Error('fuera del workspace');
|
|
1752
|
+
if (!fs.statSync(real).isDirectory()) throw new Error('no es una carpeta');
|
|
1753
|
+
return real;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function procTokenize(line) {
|
|
1757
|
+
// Trocea respetando comillas simples/dobles; no hay shell, así que nada más.
|
|
1758
|
+
const out = [];
|
|
1759
|
+
let cur = '';
|
|
1760
|
+
let q = '';
|
|
1761
|
+
for (const ch of String(line)) {
|
|
1762
|
+
if (q) {
|
|
1763
|
+
if (ch === q) q = '';
|
|
1764
|
+
else cur += ch;
|
|
1765
|
+
} else if (ch === '"' || ch === "'") {
|
|
1766
|
+
q = ch;
|
|
1767
|
+
} else if (ch === ' ' || ch === '\t') {
|
|
1768
|
+
if (cur) { out.push(cur); cur = ''; }
|
|
1769
|
+
} else {
|
|
1770
|
+
cur += ch;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
if (cur) out.push(cur);
|
|
1774
|
+
return out;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function procSafeToken(t) {
|
|
1778
|
+
// Refuerzo del lado puente aunque la web ya filtra: sin metacaracteres de
|
|
1779
|
+
// shell/cmd ni controles. Con esto armar una línea para cmd.exe es seguro.
|
|
1780
|
+
return /^[\p{L}\p{N} _\-.:@\/+=]+$/u.test(String(t));
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function procFindOnPath(name) {
|
|
1784
|
+
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
1785
|
+
for (const dir of dirs) {
|
|
1786
|
+
for (const ext of ['.exe', '.cmd', '.bat']) {
|
|
1787
|
+
const p = path.join(dir, name + ext);
|
|
1788
|
+
try { if (fs.existsSync(p)) return { p, ext }; } catch (e) {}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return null;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// Devuelve { bin, pre } listo para spawn(), sin shell. En Windows:
|
|
1795
|
+
// - node → el node.exe del PATH (o el del propio puente).
|
|
1796
|
+
// - npm/npx → node.exe + su cli (node_modules/npm/bin/*-cli.js), probado.
|
|
1797
|
+
// - otro permitido → .exe directo, o shim .cmd/.bat vía cmd.exe /c (spawn
|
|
1798
|
+
// directo de .cmd da EINVAL en Node/Windows).
|
|
1799
|
+
function procResolveBin(token, allowSet) {
|
|
1800
|
+
const bare = String(token).toLowerCase().replace(/\.(exe|cmd|bat|com)$/, '');
|
|
1801
|
+
if (bare.includes('/') || bare.includes('\\')) throw new Error('el programa debe ser un nombre en el PATH');
|
|
1802
|
+
// allowSet null = uso interno del puente (túneles); con lista, se exige.
|
|
1803
|
+
if (allowSet && !allowSet.has(bare)) throw new Error('programa no permitido (config.json → processes.allow)');
|
|
1804
|
+
if (process.platform !== 'win32') return { bin: token, pre: [] };
|
|
1805
|
+
if (bare === 'node') {
|
|
1806
|
+
const exe = procFindOnPath('node');
|
|
1807
|
+
return { bin: exe && exe.ext === '.exe' ? exe.p : process.execPath, pre: [] };
|
|
1808
|
+
}
|
|
1809
|
+
if (bare === 'npm' || bare === 'npx') {
|
|
1810
|
+
const nodeBin = process.execPath;
|
|
1811
|
+
const base = path.dirname(nodeBin);
|
|
1812
|
+
const script = path.join(base, 'node_modules', 'npm', 'bin', bare === 'npm' ? 'npm-cli.js' : 'npx-cli.js');
|
|
1813
|
+
if (fs.existsSync(script)) return { bin: nodeBin, pre: [script] };
|
|
1814
|
+
throw new Error('no encontré ' + bare + '-cli.js junto a node.exe');
|
|
1815
|
+
}
|
|
1816
|
+
const found = procFindOnPath(bare);
|
|
1817
|
+
if (!found) throw new Error('no se encontró "' + token + '" en el PATH');
|
|
1818
|
+
if (found.ext.toLowerCase() === '.exe') return { bin: found.p, pre: [] };
|
|
1819
|
+
return { bin: 'cmd.exe', pre: ['/d', '/s', '/c', '"' + found.p + '"'] };
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
async function procDone(cmd, ok, payload, error) {
|
|
1823
|
+
// Los trozos de log pueden superar el tope de command_done (8000 car.),
|
|
1824
|
+
// así que los resultados de proc_* viajan por proc_result (hasta 100 KB).
|
|
1825
|
+
await api('proc_result', {
|
|
1826
|
+
id: cmd.id,
|
|
1827
|
+
ok,
|
|
1828
|
+
text: ok ? JSON.stringify(payload) : '',
|
|
1829
|
+
error: error || '',
|
|
1830
|
+
}, cmd._t);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// Acumula salida en un anillo: el offset es un cursor de caracteres *limpios*
|
|
1834
|
+
// (sin ANSI, CRLF normalizado) que devuelve proc_log y la web va avanzando.
|
|
1835
|
+
function procFeed(entry, chunk) {
|
|
1836
|
+
const clean = stripAnsi(String(chunk)).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
1837
|
+
if (!clean) return;
|
|
1838
|
+
entry.totalChars += clean.length;
|
|
1839
|
+
let merged = entry.log + clean;
|
|
1840
|
+
if (merged.length > PROC_LOG_TAIL) {
|
|
1841
|
+
const over = merged.length - PROC_LOG_TAIL;
|
|
1842
|
+
entry.ringStart += over;
|
|
1843
|
+
merged = merged.slice(over);
|
|
1844
|
+
}
|
|
1845
|
+
entry.log = merged;
|
|
1846
|
+
// Puerto del server detectado en la salida (para la limpieza de huérfanos
|
|
1847
|
+
// y para la web): "Local: http://localhost:3000" etc.
|
|
1848
|
+
if (!entry.port) {
|
|
1849
|
+
const m = entry.log.match(/(?:localhost|127\.0\.0\.1):\s*(\d{1,5})/i);
|
|
1850
|
+
if (m) {
|
|
1851
|
+
entry.port = parseInt(m[1], 10);
|
|
1852
|
+
saveProcsState();
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function procKillTree(entry) {
|
|
1858
|
+
if (!entry || !entry.proc || entry.proc.killed) return;
|
|
1859
|
+
try {
|
|
1860
|
+
if (process.platform === 'win32' && entry.proc.pid) {
|
|
1861
|
+
spawn('taskkill', ['/PID', String(entry.proc.pid), '/T', '/F'], { windowsHide: true });
|
|
1862
|
+
} else {
|
|
1863
|
+
entry.proc.kill('SIGTERM');
|
|
1864
|
+
}
|
|
1865
|
+
} catch (e) { /* ya murió */ }
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
async function procStart(cmd) {
|
|
1869
|
+
const pc = procConfig();
|
|
1870
|
+
if (!pc.enabled) {
|
|
1871
|
+
await procDone(cmd, false, null, 'procesos deshabilitados en bridge/config.json');
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
let folder;
|
|
1875
|
+
try { folder = procResolveFolder(cmd.args && cmd.args[0]); }
|
|
1876
|
+
catch (e) { await procDone(cmd, false, null, e.message); return; }
|
|
1877
|
+
const cmdline = String(cmd.args && cmd.args[1] ? cmd.args[1] : '').trim();
|
|
1878
|
+
if (!cmdline) { await procDone(cmd, false, null, 'comando vacío'); return; }
|
|
1879
|
+
const tokens = procTokenize(cmdline);
|
|
1880
|
+
if (!tokens.length) { await procDone(cmd, false, null, 'comando vacío'); return; }
|
|
1881
|
+
for (const t of tokens) {
|
|
1882
|
+
if (!procSafeToken(t)) { await procDone(cmd, false, null, 'caracteres no permitidos en el comando'); return; }
|
|
1883
|
+
}
|
|
1884
|
+
// 1 proceso activo por carpeta (idempotente): si ya corre uno ahí, se devuelve.
|
|
1885
|
+
const live = [...procs.values()];
|
|
1886
|
+
const sameFolder = live.find((p) => p.running && p.folder === folder);
|
|
1887
|
+
if (sameFolder) {
|
|
1888
|
+
await procDone(cmd, true, { id: sameFolder.id, folder, running: true, already: true });
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
if (live.filter((p) => p.running).length >= pc.maxGlobal) {
|
|
1892
|
+
await procDone(cmd, false, null, 'máximo de procesos alcanzado (' + pc.maxGlobal + ')');
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
let spec;
|
|
1896
|
+
try { spec = procResolveBin(tokens[0], new Set(pc.allow)); }
|
|
1897
|
+
catch (e) { await procDone(cmd, false, null, e.message); return; }
|
|
1898
|
+
const procArgs = (spec.pre || []).concat(tokens.slice(1));
|
|
1899
|
+
let child;
|
|
1900
|
+
try {
|
|
1901
|
+
child = spawn(spec.bin, procArgs, {
|
|
1902
|
+
cwd: folder,
|
|
1903
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1904
|
+
env: process.env,
|
|
1905
|
+
windowsHide: true,
|
|
1906
|
+
});
|
|
1907
|
+
} catch (e) {
|
|
1908
|
+
await procDone(cmd, false, null, 'no se pudo iniciar: ' + e.message);
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
const id = nextProcId++;
|
|
1912
|
+
const entry = {
|
|
1913
|
+
id, folder, cmd: cmdline, running: true,
|
|
1914
|
+
proc: child, spawnError: '',
|
|
1915
|
+
startedAt: Date.now(), exitedAt: null, exitCode: null,
|
|
1916
|
+
log: '', ringStart: 0, totalChars: 0,
|
|
1917
|
+
};
|
|
1918
|
+
procs.set(id, entry);
|
|
1919
|
+
child.stdout.on('data', (d) => procFeed(entry, d));
|
|
1920
|
+
child.stderr.on('data', (d) => procFeed(entry, d));
|
|
1921
|
+
child.on('error', (e) => { entry.spawnError = e.message; });
|
|
1922
|
+
child.on('close', (code) => {
|
|
1923
|
+
entry.running = false;
|
|
1924
|
+
entry.exitCode = code;
|
|
1925
|
+
entry.exitedAt = Date.now();
|
|
1926
|
+
log('proceso #' + id + ' terminó (código ' + code + '): ' + entry.cmd);
|
|
1927
|
+
saveProcsState();
|
|
1928
|
+
procCleanup();
|
|
1929
|
+
});
|
|
1930
|
+
log('proceso #' + id + ' iniciado en ' + folder + ': ' + cmdline);
|
|
1931
|
+
saveProcsState();
|
|
1932
|
+
await procDone(cmd, true, { id, folder, running: true, startedAt: entry.startedAt });
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
async function procStop(cmd) {
|
|
1936
|
+
const id = parseInt(String(cmd.args && cmd.args[0]), 10);
|
|
1937
|
+
const entry = procs.get(id);
|
|
1938
|
+
if (entry && entry.running) procKillTree(entry);
|
|
1939
|
+
await procDone(cmd, true, { id: isNaN(id) ? null : id, stopped: true });
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// Libera un puerto TCP: encuentra los pid que escuchan y mata cada árbol.
|
|
1943
|
+
// Lo dispara el panel cuando un dev server falla con EADDRINUSE.
|
|
1944
|
+
function portFreePidsWin(text, port) {
|
|
1945
|
+
const re = new RegExp(':' + port + '\\s');
|
|
1946
|
+
const pids = new Set();
|
|
1947
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
1948
|
+
if (!re.test(line) || !/LISTENING/i.test(line)) continue;
|
|
1949
|
+
const parts = line.trim().split(/\s+/);
|
|
1950
|
+
const pid = parseInt(parts[parts.length - 1], 10);
|
|
1951
|
+
if (pid > 0) pids.add(pid);
|
|
1952
|
+
}
|
|
1953
|
+
return [...pids];
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
async function portFree(cmd) {
|
|
1957
|
+
const port = tunnelPortOf(cmd.args);
|
|
1958
|
+
if (!port) {
|
|
1959
|
+
await procDone(cmd, false, null, 'puerto inválido (usá 1–65535)');
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
let pids = [];
|
|
1963
|
+
try {
|
|
1964
|
+
if (process.platform === 'win32') {
|
|
1965
|
+
const r = await runCmd('netstat', ['-ano', '-p', 'tcp'], { timeout: 15000 });
|
|
1966
|
+
pids = portFreePidsWin(r.text, port);
|
|
1967
|
+
} else {
|
|
1968
|
+
let r = await runCmd('lsof', ['-t', '-i', 'tcp:' + port, '-s', 'tcp:listen'], { timeout: 15000 });
|
|
1969
|
+
if (!r.ok || !String(r.text || '').trim()) {
|
|
1970
|
+
r = await runCmd('fuser', [String(port) + '/tcp'], { timeout: 15000 });
|
|
1971
|
+
}
|
|
1972
|
+
pids = String(r.text || '').split(/\s+/).map(Number).filter((n) => n > 0);
|
|
1973
|
+
}
|
|
1974
|
+
} catch (e) { /* sigo con lo que haya */ }
|
|
1975
|
+
if (!pids.length) {
|
|
1976
|
+
await procDone(cmd, true, { port, freed: false, pids: [] });
|
|
1977
|
+
log('puerto ' + port + ': no encontré procesos escuchando');
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
const killed = [];
|
|
1981
|
+
for (const pid of pids) {
|
|
1982
|
+
try {
|
|
1983
|
+
if (process.platform === 'win32') {
|
|
1984
|
+
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
|
|
1985
|
+
} else {
|
|
1986
|
+
process.kill(pid, 'SIGKILL');
|
|
1987
|
+
}
|
|
1988
|
+
killed.push(pid);
|
|
1989
|
+
} catch (e) { /* sigo con los demás */ }
|
|
1990
|
+
}
|
|
1991
|
+
log('puerto ' + port + ' liberado (pids: ' + killed.join(', ') + ')');
|
|
1992
|
+
await procDone(cmd, true, { port, freed: killed.length > 0, pids: killed });
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function procCleanup() {
|
|
1996
|
+
// Suelta entradas viejas terminadas (topes en memoria del puente).
|
|
1997
|
+
const finished = [...procs.values()].filter((p) => !p.running);
|
|
1998
|
+
for (let i = 0; i < finished.length - 8; i++) procs.delete(finished[i].id);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
async function procList(cmd) {
|
|
2002
|
+
const list = [...procs.values()]
|
|
2003
|
+
.filter((p) => p.running || p.exitedAt)
|
|
2004
|
+
.map((p) => ({
|
|
2005
|
+
id: p.id,
|
|
2006
|
+
folder: p.folder,
|
|
2007
|
+
cmd: p.cmd,
|
|
2008
|
+
running: p.running,
|
|
2009
|
+
startedAt: p.startedAt,
|
|
2010
|
+
exitedAt: p.exitedAt,
|
|
2011
|
+
exitCode: p.exitCode,
|
|
2012
|
+
}))
|
|
2013
|
+
.sort((a, b) => (b.running - a.running) || (b.startedAt - a.startedAt));
|
|
2014
|
+
await procDone(cmd, true, { procs: list });
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
async function procLog(cmd) {
|
|
2018
|
+
const id = parseInt(String(cmd.args && cmd.args[0]), 10);
|
|
2019
|
+
let offset = parseInt(String(cmd.args && cmd.args[1]), 10);
|
|
2020
|
+
if (!(offset >= 0)) offset = 0;
|
|
2021
|
+
const entry = procs.get(id);
|
|
2022
|
+
if (!entry) {
|
|
2023
|
+
// Desapareció (puente reiniciado o entrada podada): fin del stream.
|
|
2024
|
+
await procDone(cmd, true, { text: '', offset: 0, running: false, done: true, exitCode: null });
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
let start = Math.min(Math.max(offset, entry.ringStart), entry.totalChars);
|
|
2028
|
+
const avail = entry.totalChars - start;
|
|
2029
|
+
const take = Math.min(avail, PROC_LOG_CHUNK);
|
|
2030
|
+
const seg = entry.log.slice(start - entry.ringStart, start - entry.ringStart + take);
|
|
2031
|
+
await procDone(cmd, true, {
|
|
2032
|
+
text: seg,
|
|
2033
|
+
offset: start + take,
|
|
2034
|
+
running: entry.running,
|
|
2035
|
+
done: !entry.running,
|
|
2036
|
+
exitCode: entry.exitCode,
|
|
2037
|
+
spawnError: entry.spawnError || '',
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
async function handleCommand(cmd) {
|
|
2042
|
+
// Comandos fs_* los resuelve el puente directamente (sin opencode CLI).
|
|
2043
|
+
if (cmd.name === 'fs_list' || cmd.name === 'fs_read') {
|
|
2044
|
+
await handleFsCommand(cmd);
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
// Túneles también son del puente (procesos de esta PC).
|
|
2048
|
+
if (cmd.name === 'tunnel_start') { await tunnelStart(cmd); return; }
|
|
2049
|
+
if (cmd.name === 'tunnel_stop') { await tunnelStop(cmd); return; }
|
|
2050
|
+
if (cmd.name === 'tunnel_list') { await tunnelList(cmd); return; }
|
|
2051
|
+
// Procesos de desarrollo (dev servers) en la carpeta de una sesión.
|
|
2052
|
+
if (cmd.name === 'proc_start') { await procStart(cmd); return; }
|
|
2053
|
+
if (cmd.name === 'proc_stop') { await procStop(cmd); return; }
|
|
2054
|
+
if (cmd.name === 'proc_list') { await procList(cmd); return; }
|
|
2055
|
+
if (cmd.name === 'proc_log') { await procLog(cmd); return; }
|
|
2056
|
+
if (cmd.name === 'port_free') { await portFree(cmd); return; }
|
|
2057
|
+
// Mapea el nombre "interno" al subcomando real de opencode.
|
|
2058
|
+
// Whitelist cerrada; cualquier nombre fuera de acá se rechaza.
|
|
2059
|
+
const MAP = {
|
|
2060
|
+
'models': ['models'],
|
|
2061
|
+
'session_list': ['session', 'list'],
|
|
2062
|
+
'session_info': ['session', 'info'],
|
|
2063
|
+
'opencode_version': ['--version'],
|
|
2064
|
+
};
|
|
2065
|
+
const argv = MAP[cmd.name];
|
|
2066
|
+
if (!argv) {
|
|
2067
|
+
await api('command_done', { id: cmd.id, ok: false, text: '', error: 'comando no soportado' }, cmd._t);
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
const args = argv.concat(cmd.args || []);
|
|
2071
|
+
log('ejecutando comando #' + cmd.id + ': ' + cmd.name + ' ' + (cmd.args || []).join(' '));
|
|
2072
|
+
const r = await runCli(args, { timeout: 30000 });
|
|
2073
|
+
await api('command_done', {
|
|
2074
|
+
id: cmd.id,
|
|
2075
|
+
ok: r.ok,
|
|
2076
|
+
text: r.text || '',
|
|
2077
|
+
error: r.ok ? '' : ('exit ' + r.code),
|
|
2078
|
+
}, cmd._t);
|
|
2079
|
+
log('comando #' + cmd.id + ' finalizado (ok=' + r.ok + ', ' + (r.text || '').length + ' car.)');
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// ---------------------------------------------------------------------------
|
|
2083
|
+
// Bucle principal
|
|
2084
|
+
// ---------------------------------------------------------------------------
|
|
2085
|
+
// Devuelve 'libre' si no había trabajo o 'trabajo' si procesó algo (para que
|
|
2086
|
+
// el planificador ajuste el próximo poll). Los comandos que llegan mientras
|
|
2087
|
+
// corre un mensaje los atiende el poll liviano en paralelo (ver tick abajo).
|
|
2088
|
+
async function tick(opts) {
|
|
2089
|
+
checkModeChange();
|
|
2090
|
+
const wait = !(opts && opts.noWait);
|
|
2091
|
+
// Poll a cada destino en paralelo con espera larga del lado del hosting
|
|
2092
|
+
// (long-poll): si no hay nada, el hosting retiene la respuesta hasta 20 s.
|
|
2093
|
+
// En cuanto un destino trae trabajo cortamos las esperas restantes; como
|
|
2094
|
+
// el hosting solo reclama mensajes cuando va a responder, abortar esas
|
|
2095
|
+
// conexiones no deja nada colgado.
|
|
2096
|
+
const ac = new AbortController();
|
|
2097
|
+
const guard = setTimeout(() => ac.abort(), 35000); // si el hosting se cuelga
|
|
2098
|
+
const msgs = [], folders = [], cmds = [];
|
|
2099
|
+
let cut = false;
|
|
2100
|
+
let resolveWork;
|
|
2101
|
+
const workPromise = new Promise((res) => { resolveWork = res; });
|
|
2102
|
+
const collect = (t, data) => {
|
|
2103
|
+
if (cut) return;
|
|
2104
|
+
if (Array.isArray(data.known_oc)) lastKnownOc[t] = data.known_oc;
|
|
2105
|
+
for (const m of (data.messages || [])) { m._t = t; msgs.push(m); }
|
|
2106
|
+
for (const f of (data.folders || [])) { f._t = t; folders.push(f); }
|
|
2107
|
+
for (const c of (data.commands || [])) { c._t = t; cmds.push(c); }
|
|
2108
|
+
if (msgs.length || folders.length || cmds.length) resolveWork();
|
|
2109
|
+
};
|
|
2110
|
+
const ps = activeTargets().map((t) =>
|
|
2111
|
+
// waitMax 5: el hosting retiene la respuesta hasta 5 s cuando no hay
|
|
2112
|
+
// nada (antes 20 s). Los mensajes siguen avisando al instente (el peek
|
|
2113
|
+
// revisa cada 500 ms); lo que gana es la cola: un comando recién
|
|
2114
|
+
// encolado no queda preso hasta 20 s detrás de un ciclo largo.
|
|
2115
|
+
api('poll', wait ? { wait: 1, waitMax: 5 } : {}, t, { signal: ac.signal })
|
|
2116
|
+
.then((data) => collect(t, data))
|
|
2117
|
+
.catch((e) => { if (!cut && shouldLogDown(t)) log('no se pudo consultar ' + t + ': ' + e.message); })
|
|
2118
|
+
);
|
|
2119
|
+
await Promise.race([Promise.all(ps), workPromise]);
|
|
2120
|
+
let work = msgs.length > 0 || folders.length > 0 || cmds.length > 0;
|
|
2121
|
+
if (work) {
|
|
2122
|
+
cut = true;
|
|
2123
|
+
ac.abort(); // el resto eran esperas largas sin nada que reclamar
|
|
2124
|
+
} else {
|
|
2125
|
+
await Promise.all(ps);
|
|
2126
|
+
}
|
|
2127
|
+
clearTimeout(guard);
|
|
2128
|
+
if (!work) return 'libre';
|
|
2129
|
+
|
|
2130
|
+
busy = true;
|
|
2131
|
+
const involvedTargets = new Set();
|
|
2132
|
+
try {
|
|
2133
|
+
// Mientras corre un mensaje, atender SOLO comandos (fs/procesos/
|
|
2134
|
+
// túneles) en paralelo: `tick()` está esperando a runMessage y no
|
|
2135
|
+
// volverá a correr hasta terminar, así que sin esto la web remota se
|
|
2136
|
+
// queda ciega durante ejecuciones largas (archivos, procs, túneles).
|
|
2137
|
+
let liteRunning = false;
|
|
2138
|
+
let liteTimer = null;
|
|
2139
|
+
const litePoll = async () => {
|
|
2140
|
+
if (liteRunning) return;
|
|
2141
|
+
liteRunning = true;
|
|
2142
|
+
try {
|
|
2143
|
+
for (const t of activeTargets()) {
|
|
2144
|
+
if (liteUnsupported.has(t)) continue;
|
|
2145
|
+
try {
|
|
2146
|
+
const data = await api('poll', { lite: 1 }, t);
|
|
2147
|
+
if ((data.messages || []).length) {
|
|
2148
|
+
// api.php sin soporte lite: reclamó mensajes. No
|
|
2149
|
+
// volver a pedirle lite en esta sesión del puente.
|
|
2150
|
+
liteUnsupported.add(t);
|
|
2151
|
+
log('aviso: ' + t + ' no soporta poll liviano (api.php viejo); los comandos esperan al mensaje en curso');
|
|
2152
|
+
continue;
|
|
2153
|
+
}
|
|
2154
|
+
for (const c of (data.commands || [])) {
|
|
2155
|
+
c._t = t;
|
|
2156
|
+
await handleCommand(c);
|
|
2157
|
+
}
|
|
2158
|
+
} catch (e) {
|
|
2159
|
+
if (shouldLogDown(t)) {
|
|
2160
|
+
log('aviso: poll liviano: ' + t + ' no responde (' + e.message + ') · reintentando en silencio hasta 60 s');
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
} finally {
|
|
2165
|
+
liteRunning = false;
|
|
2166
|
+
}
|
|
2167
|
+
};
|
|
2168
|
+
liteTimer = setInterval(litePoll, config.pollIntervalMs || 3000);
|
|
2169
|
+
litePoll();
|
|
2170
|
+
for (const m of msgs) {
|
|
2171
|
+
activeRunFolder = (m.session && m.session.folder) || activeRunFolder;
|
|
2172
|
+
if (m._t) involvedTargets.add(m._t);
|
|
2173
|
+
// Avisa al hosting cuál sesión está corriendo (para el indicador
|
|
2174
|
+
// "trabajando" en listas). Se notifica al cambiar de sesión.
|
|
2175
|
+
if (activeMsgSession !== m.session_id || activeMsgTarget !== m._t) {
|
|
2176
|
+
activeMsgSession = m.session_id;
|
|
2177
|
+
activeMsgTarget = m._t;
|
|
2178
|
+
notifyBusy(m._t, m.session_id);
|
|
2179
|
+
}
|
|
2180
|
+
log('mensaje nuevo #' + m.id + ' (' + (m._t || '?') + ', sesión ' + m.session_id + ', ' + m.text.length + ' car.)');
|
|
2181
|
+
const r = await runMessage(m);
|
|
2182
|
+
if (r.done) continue;
|
|
2183
|
+
const payload = {
|
|
2184
|
+
session_id: m.session_id,
|
|
2185
|
+
user_id: m.id,
|
|
2186
|
+
text: r.text,
|
|
2187
|
+
reasoning: r.reasoning || '',
|
|
2188
|
+
opencode_session: r.opencodeSession || '',
|
|
2189
|
+
canceled: !!r.canceled,
|
|
2190
|
+
};
|
|
2191
|
+
try {
|
|
2192
|
+
await api('respond', payload, m._t);
|
|
2193
|
+
log('respuesta #' + m.id + ' publicada en ' + (m._t || '?') + ' (' + r.text.length + ' car.)');
|
|
2194
|
+
} catch (e) {
|
|
2195
|
+
log('error al publicar la respuesta #' + m.id + ': ' + e.message);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
for (const f of folders) {
|
|
2199
|
+
await handleFolderRequest(f);
|
|
2200
|
+
}
|
|
2201
|
+
for (const c of cmds) {
|
|
2202
|
+
await handleCommand(c);
|
|
2203
|
+
}
|
|
2204
|
+
if (folders.length) {
|
|
2205
|
+
await syncCatalog({ silent: true });
|
|
2206
|
+
}
|
|
2207
|
+
} finally {
|
|
2208
|
+
if (liteTimer) { clearInterval(liteTimer); liteTimer = null; }
|
|
2209
|
+
activeRunFolder = null;
|
|
2210
|
+
activeMsgSession = null;
|
|
2211
|
+
activeMsgTarget = null;
|
|
2212
|
+
busy = false;
|
|
2213
|
+
for (const t of involvedTargets) notifyBusy(t, null);
|
|
2214
|
+
}
|
|
2215
|
+
return 'trabajo';
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// Bucle de poll auto-agendado: tras cada respuesta volvemos a pollear enseguida
|
|
2219
|
+
// (el hosting retiene la respuesta cuando no hay nada, así que el ritmo de
|
|
2220
|
+
// requests queda bajo). Si el poll falla, volvemos al intervalo clásico.
|
|
2221
|
+
function scheduleTick(delayMs) {
|
|
2222
|
+
setTimeout(async () => {
|
|
2223
|
+
let next = POLL_QUICK_MS;
|
|
2224
|
+
try {
|
|
2225
|
+
await tick();
|
|
2226
|
+
} catch (e) {
|
|
2227
|
+
handleError(e);
|
|
2228
|
+
next = config.pollIntervalMs;
|
|
2229
|
+
}
|
|
2230
|
+
scheduleTick(next);
|
|
2231
|
+
}, delayMs);
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
function handleError(err) {
|
|
2235
|
+
log('error inesperado: ' + ((err && err.stack) || err));
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
process.on('unhandledRejection', handleError);
|
|
2239
|
+
process.on('uncaughtException', handleError);
|
|
2240
|
+
process.on('SIGINT', () => {
|
|
2241
|
+
log('puente deteniendose (Ctrl+C)');
|
|
2242
|
+
process.exit(0);
|
|
2243
|
+
});
|
|
2244
|
+
|
|
2245
|
+
(async function main() {
|
|
2246
|
+
checkModeChange();
|
|
2247
|
+
log('puente iniciado (' + BRIDGE_ID + (BRIDGE_NAME && BRIDGE_NAME !== BRIDGE_ID ? ' / ' + BRIDGE_NAME : '') + '). Hosting: ' + config.apiUrl + (config.apiUrlLocal ? ' · Local: ' + config.apiUrlLocal : '') + ' (modo ' + (activeMode || '?') + ')');
|
|
2248
|
+
log('verificando conexion...');
|
|
2249
|
+
for (const t of activeTargets()) {
|
|
2250
|
+
try {
|
|
2251
|
+
const pong = await api('ping', undefined, t);
|
|
2252
|
+
log('conexion ok (' + t + '): ' + pong.now);
|
|
2253
|
+
} catch (e) {
|
|
2254
|
+
log('ATENCION: no se pudo contactar ' + t + ': ' + e.message);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
await syncCatalog();
|
|
2258
|
+
// Heartbeat: le dice a cada hosting que el puente está vivo aunque esté
|
|
2259
|
+
// ocupado procesando un mensaje (y cuál sesión está corriendo).
|
|
2260
|
+
const heartbeat = () => {
|
|
2261
|
+
for (const t of activeTargets()) {
|
|
2262
|
+
// La sesión en ejecución solo se reporta al hosting donde vive
|
|
2263
|
+
// (en DUAL el otro lado no debe marcarla como "working").
|
|
2264
|
+
const mine = activeMsgTarget === t;
|
|
2265
|
+
const payload = { busy: mine && activeMsgSession != null, busy_session: mine ? activeMsgSession : null };
|
|
2266
|
+
api('heartbeat', payload, t).catch(() => {});
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
heartbeat();
|
|
2270
|
+
setInterval(heartbeat, 15000);
|
|
2271
|
+
// Primer poll sin espera larga: carga known_oc y deja arrancar el barrido.
|
|
2272
|
+
await tick({ noWait: true });
|
|
2273
|
+
// Historial único: primer poll hecho (lastKnownOc cargado), importamos
|
|
2274
|
+
// en segundo plano y repetimos cada 15 minutos.
|
|
2275
|
+
syncSessions().catch(handleError);
|
|
2276
|
+
setInterval(() => {
|
|
2277
|
+
if (!busy && !sweepRunning) syncSessions({ silent: true }).catch(handleError);
|
|
2278
|
+
}, 15 * 60 * 1000);
|
|
2279
|
+
scheduleTick(POLL_QUICK_MS);
|
|
2280
|
+
})();
|