@danieltmn/tamnora 0.7.1 → 0.9.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +9 -0
  3. package/SECURITY.md +11 -6
  4. package/harness/packages/cli/src/cli.js +1 -1
  5. package/harness/packages/cli/src/commands/bridge.js +180 -119
  6. package/harness/packages/cli/src/commands/run.js +1 -1
  7. package/harness/packages/cli/src/commands/server.js +26 -50
  8. package/harness/packages/cli/src/commands/tui.js +1 -1
  9. package/harness/packages/cli/src/lib/daemon.js +92 -40
  10. package/harness/packages/cli/test/release.test.js +9 -0
  11. package/harness/packages/core/src/agent/modes.js +1 -1
  12. package/harness/packages/core/src/agent/runAgent.js +3 -0
  13. package/harness/packages/core/src/git/index.js +177 -0
  14. package/harness/packages/core/src/index.js +16 -1
  15. package/harness/packages/core/src/open.js +43 -0
  16. package/harness/packages/core/src/pid.js +35 -0
  17. package/harness/packages/core/src/procs/kill.js +95 -0
  18. package/harness/packages/core/src/tools/executors.js +16 -0
  19. package/harness/packages/core/src/tools/labels.js +2 -0
  20. package/harness/packages/core/src/tools/permissions.js +1 -0
  21. package/harness/packages/core/src/tools/schema.js +17 -0
  22. package/harness/packages/core/src/tts/edge.js +232 -0
  23. package/harness/packages/core/src/tunnel.js +180 -19
  24. package/harness/packages/core/test/open.test.js +19 -0
  25. package/harness/packages/core/test/procs.test.js +50 -0
  26. package/harness/packages/core/test/tools.test.js +12 -0
  27. package/harness/packages/core/test/tts.test.js +41 -0
  28. package/harness/packages/server/public/app.js +4715 -3669
  29. package/harness/packages/server/public/index.html +94 -5
  30. package/harness/packages/server/public/landing/index.html +5 -5
  31. package/harness/packages/server/public/reconcile.js +121 -0
  32. package/harness/packages/server/public/relayClient.js +170 -0
  33. package/harness/packages/server/public/runStream.js +207 -0
  34. package/harness/packages/server/public/styles.css +196 -23
  35. package/harness/packages/server/src/auth.js +29 -5
  36. package/harness/packages/server/src/lib/agentRun.js +77 -0
  37. package/harness/packages/server/src/lib/announcer.js +92 -0
  38. package/harness/packages/server/src/lib/approvals.js +25 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  40. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  41. package/harness/packages/server/src/lib/procs.js +3 -18
  42. package/harness/packages/server/src/lib/runManager.js +24 -2
  43. package/harness/packages/server/src/lib/sessionView.js +43 -0
  44. package/harness/packages/server/src/lib/tasks.js +4 -9
  45. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  46. package/harness/packages/server/src/lib/workspace.js +37 -1
  47. package/harness/packages/server/src/routes/chat.js +51 -47
  48. package/harness/packages/server/src/routes/events.js +35 -0
  49. package/harness/packages/server/src/routes/files.js +22 -26
  50. package/harness/packages/server/src/routes/folders.js +9 -17
  51. package/harness/packages/server/src/routes/git.js +35 -0
  52. package/harness/packages/server/src/routes/procs.js +25 -15
  53. package/harness/packages/server/src/routes/sessions.js +12 -19
  54. package/harness/packages/server/src/routes/shell.js +19 -0
  55. package/harness/packages/server/src/routes/state.js +32 -0
  56. package/harness/packages/server/src/routes/tts.js +82 -0
  57. package/harness/packages/server/src/server.js +25 -1
  58. package/harness/packages/server/test/announcer.test.js +51 -0
  59. package/harness/packages/server/test/reconcile.test.js +146 -0
  60. package/harness/packages/server/test/relay.test.js +219 -0
  61. package/harness/packages/server/test/runStream.test.js +245 -0
  62. package/harness/packages/server/test/security.test.js +252 -0
  63. package/harness/packages/server/test/server.test.js +617 -1
  64. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  65. package/harness/packages/server/test/tts.test.js +133 -0
  66. package/package.json +1 -1
@@ -0,0 +1,207 @@
1
+ // Streaming SSE resumible para la consola.
2
+ //
3
+ // El tunel (o un proxy) puede cortar la conexion sin que el `fetch` se entere:
4
+ // `reader.read()` queda esperando para siempre y el turno se queda "pensando..."
5
+ // sin fin. Estos helpers agregan un watchdog de silencio, recuerdan el cursor
6
+ // (`id:`) de cada frame para reanudar donde se quedo, y reintentan con backoff.
7
+
8
+ // El server manda un comentario `: ping` cada 15 s (lib/http.js). Tres pings
9
+ // perdidos ya no son transitorios: algo se murio.
10
+ export const SILENCE_MS = 45000;
11
+
12
+ const DEFAULT_BACKOFF = [1000, 2000, 4000, 8000, 10000];
13
+
14
+ function abortError() {
15
+ return new DOMException('Aborted', 'AbortError');
16
+ }
17
+
18
+ /** Espera `ms` abortable: si el caller cancela, no hay que esperar al timer. */
19
+ export function delay(ms, signal) {
20
+ return new Promise((resolve, reject) => {
21
+ if (signal?.aborted) return reject(abortError());
22
+ const timer = setTimeout(() => {
23
+ signal?.removeEventListener('abort', onAbort);
24
+ resolve();
25
+ }, ms);
26
+ function onAbort() {
27
+ clearTimeout(timer);
28
+ reject(abortError());
29
+ }
30
+ signal?.addEventListener('abort', onAbort, { once: true });
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Combina varias senales en una. No usamos `AbortSignal.any` porque en Android
36
+ * Chrome viejo no existe y la consola se abre mucho desde el celular.
37
+ */
38
+ export function linkSignals(signals) {
39
+ const list = signals.filter(Boolean);
40
+ if (!list.length) return { signal: undefined, abort: () => {} };
41
+ if (list.length === 1) return { signal: list[0], abort: () => list[0].abort() };
42
+ const controller = new AbortController();
43
+ const onAbort = (event) => controller.abort(event.target.reason);
44
+ for (const signal of list) {
45
+ if (signal.aborted) {
46
+ controller.abort(signal.reason);
47
+ break;
48
+ }
49
+ signal.addEventListener('abort', onAbort, { once: true });
50
+ }
51
+ return { signal: controller.signal, abort: () => controller.abort() };
52
+ }
53
+
54
+ async function httpError(res) {
55
+ let message = res.statusText;
56
+ try {
57
+ message = (await res.json())?.error ?? message;
58
+ } catch {
59
+ // nos quedamos con statusText
60
+ }
61
+ const error = new Error(message || `HTTP ${res.status}`);
62
+ error.status = res.status;
63
+ return error;
64
+ }
65
+
66
+ /** El stream quedo mudo: cortamos por nuestra cuenta, no el servidor. */
67
+ function silentError(from) {
68
+ const error = new Error('La conexion quedo muda (se corto el stream).');
69
+ error.name = 'SilentStream';
70
+ error.resumable = true;
71
+ error.from = from;
72
+ return error;
73
+ }
74
+
75
+ /**
76
+ * Lee un stream SSE y entrega `(event, data, id)` por frame.
77
+ *
78
+ * Corta si pasan `silenceMs` sin bytes. Al hacerlo tira un error `SilentStream`
79
+ * con `from` = ultimo cursor recibido, para que el llamador pueda reanudar
80
+ * desde ahi en vez de desde cero.
81
+ */
82
+ export async function streamSse(
83
+ url,
84
+ { method = 'GET', body, csrf, signal, silenceMs = SILENCE_MS, fetchImpl = fetch } = {},
85
+ onEvent
86
+ ) {
87
+ const headers = {};
88
+ if (body) headers['Content-Type'] = 'application/json';
89
+ if (method !== 'GET' && csrf) headers['x-csrf'] = csrf;
90
+
91
+ const watchdog = new AbortController();
92
+ let lastId = null;
93
+ let timer = null;
94
+ let silent = false;
95
+ const linked = linkSignals([signal, watchdog.signal]);
96
+ const arm = () => {
97
+ if (timer) clearTimeout(timer);
98
+ timer = setTimeout(() => {
99
+ silent = true;
100
+ watchdog.abort();
101
+ }, silenceMs);
102
+ };
103
+
104
+ try {
105
+ const res = await fetchImpl(url, {
106
+ method,
107
+ headers,
108
+ body: body ? JSON.stringify(body) : undefined,
109
+ signal: linked.signal,
110
+ });
111
+ if (!res.ok) throw await httpError(res);
112
+ const reader = res.body.getReader();
113
+ const decoder = new TextDecoder();
114
+ let buffer = '';
115
+ arm();
116
+ for (;;) {
117
+ const { value, done } = await reader.read();
118
+ // Cualquier byte cuenta como vida: el heartbeat es un comentario SSE y
119
+ // nunca llega a `onEvent`.
120
+ arm();
121
+ if (done) break;
122
+ buffer += decoder.decode(value, { stream: true });
123
+ let index;
124
+ while ((index = buffer.indexOf('\n\n')) !== -1) {
125
+ const frame = buffer.slice(0, index);
126
+ buffer = buffer.slice(index + 2);
127
+ let event = null;
128
+ let data = '';
129
+ let id = null;
130
+ for (const line of frame.split('\n')) {
131
+ if (line.startsWith('event:')) event = line.slice(6).trim();
132
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
133
+ else if (line.startsWith('id:')) id = line.slice(3).trim();
134
+ }
135
+ if (id !== null) lastId = id;
136
+ // Frame de comentario (`: ping`): el watchdog ya lo conto como vida,
137
+ // pero no hay nada que entregar.
138
+ if (!event && !data) continue;
139
+ let parsed = {};
140
+ if (data) {
141
+ try {
142
+ parsed = JSON.parse(data);
143
+ } catch {
144
+ parsed = { raw: data };
145
+ }
146
+ }
147
+ try {
148
+ onEvent(event || 'message', parsed, id);
149
+ } catch (error) {
150
+ // Un error del handler NO es una caida del transporte: si lo dejamos
151
+ // pasar, `followRun` reintenta en bucle y el sintoma es un
152
+ // "reconectando..." eterno que no dice nada. Se propaga con su tipo
153
+ // para que el llamador pueda distinguirlo y no reintentar.
154
+ error.eventHandler = true;
155
+ throw error;
156
+ }
157
+ }
158
+ }
159
+ if (silent) throw silentError(lastId);
160
+ } catch (error) {
161
+ // Si el watchdog fue el que corto, el AbortError obscurece la causa.
162
+ if (silent && error?.name === 'AbortError') throw silentError(lastId);
163
+ throw error;
164
+ } finally {
165
+ if (timer) clearTimeout(timer);
166
+ }
167
+ }
168
+
169
+ export function backoffDelay(index, backoff = DEFAULT_BACKOFF) {
170
+ const step = backoff[Math.min(Math.max(index, 0), backoff.length - 1)];
171
+ return Number(step) || 1000;
172
+ }
173
+
174
+ /**
175
+ * Reintenta `open()` con backoff hasta que el run termine o se agoten los
176
+ * intentos.
177
+ *
178
+ * `open(cursor)` hace una conexion y devuelve `{ finished, cursor }` cuando el
179
+ * turno acaba, o tira error si el transporte se cayo. Los `AbortError` nunca se
180
+ * reintentan: son el usuario cancelando o el boton `parar`. Tampoco los errores
181
+ * marcados con `eventHandler`: esos son un bug de la consola, y reintentarlos
182
+ * solo tapa el sintoma y quema el backoff.
183
+ *
184
+ * `from` es el cursor inicial: se pasa el ultimo `id:` conocido cuando ya
185
+ * estabamos escuchando este turno y lo que se perdio esta despues.
186
+ */
187
+ export async function followRun({ open, onRetry = null, backoff = DEFAULT_BACKOFF, attempts = Infinity, signal, from = 0, sleep = delay }) {
188
+ let cursor = Number(from) || 0;
189
+ let attempt = 0;
190
+ for (;;) {
191
+ if (signal?.aborted) throw abortError();
192
+ try {
193
+ const result = await open(cursor, signal);
194
+ if (result?.cursor !== undefined) cursor = result.cursor;
195
+ return { finished: true, cursor, attempts: attempt };
196
+ } catch (error) {
197
+ if (error?.name === 'AbortError' || error?.eventHandler) throw error;
198
+ // El error sabe hasta donde llegamos: preferimos su cursor.
199
+ if (error?.from != null) cursor = Number(error.from) || cursor;
200
+ attempt += 1;
201
+ if (attempt > attempts) throw error;
202
+ const wait = backoffDelay(attempt - 1, backoff);
203
+ onRetry?.({ attempt, delay: wait, error, cursor });
204
+ await sleep(wait, signal);
205
+ }
206
+ }
207
+ }
@@ -177,6 +177,23 @@ html[data-theme='dark'] #theme-toggle .ico-moon { display: block; }
177
177
  .menu-pop button.active { background: var(--theirs); color: var(--accent); }
178
178
  .settings { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: 8px; }
179
179
  .settings select { max-width: 220px; }
180
+
181
+ /* Nueva sesión en el topbar: texto en desktop, solo el icono en móvil, donde
182
+ el ancho es el recurso escaso. */
183
+ .topbar-new {
184
+ display: inline-flex;
185
+ align-items: center;
186
+ justify-content: center;
187
+ gap: 6px;
188
+ height: 34px;
189
+ padding: 0 13px;
190
+ flex: 0 0 auto;
191
+ }
192
+ .topbar-new svg { width: 18px; height: 18px; display: block; flex: 0 0 auto; }
193
+ .topbar-new-label { display: none; }
194
+ @media (min-width: 821px) {
195
+ .topbar-new-label { display: inline; }
196
+ }
180
197
  .check { color: var(--muted); display: flex; align-items: center; gap: 5px; cursor: pointer; }
181
198
 
182
199
  select, input, textarea {
@@ -204,6 +221,8 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
204
221
  cursor: pointer;
205
222
  font: 600 12.5px var(--sans);
206
223
  transition: 0.15s ease;
224
+ /* Sin esto, en una fila apretada "denegar" se parte en "den egar". */
225
+ white-space: nowrap;
207
226
  }
208
227
  .btn:hover { border-color: var(--accent); color: var(--text); }
209
228
  .btn.primary { background: var(--accent); border-color: var(--accent); color: var(--primary-fg); font-weight: 700; }
@@ -266,6 +285,29 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
266
285
  .session-item.active { background: var(--theirs); color: var(--text); border-color: var(--border); }
267
286
  .session-item .sub { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; }
268
287
  .session-item .session-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
288
+ .session-live {
289
+ flex: none;
290
+ width: 6px;
291
+ height: 6px;
292
+ border-radius: 50%;
293
+ background: var(--accent);
294
+ animation: live-pulse 1.4s ease-in-out infinite;
295
+ }
296
+ .session-await {
297
+ flex: none;
298
+ padding: 0 5px;
299
+ height: 14px;
300
+ line-height: 14px;
301
+ border-radius: 7px;
302
+ font-size: 10px;
303
+ font-weight: 700;
304
+ color: var(--bg);
305
+ background: var(--warn);
306
+ }
307
+ @keyframes live-pulse {
308
+ 0%, 100% { opacity: 1; }
309
+ 50% { opacity: 0.25; }
310
+ }
269
311
  .session-del,
270
312
  .group-del {
271
313
  flex: 0 0 auto;
@@ -445,53 +487,61 @@ body.resizing * { cursor: col-resize !important; }
445
487
  .msg-menu-btn {
446
488
  display: inline-grid;
447
489
  place-items: center;
448
- width: auto;
449
- min-height: 24px;
450
- padding: 2px 9px;
490
+ width: 26px;
491
+ height: 24px;
492
+ padding: 0;
451
493
  background: transparent;
452
- border: 1px solid var(--border);
494
+ border: 1px solid transparent;
453
495
  border-radius: 7px;
454
496
  color: var(--muted);
455
497
  cursor: pointer;
456
- font: 500 11px var(--mono);
457
- opacity: 0.45;
458
- transition: opacity 0.12s ease;
498
+ opacity: 0.4;
499
+ transition: opacity 0.12s ease, background 0.12s ease, color 0.12s ease;
459
500
  }
460
501
  .msg:hover .msg-menu-btn,
461
502
  .msg:focus-within .msg-menu-btn,
462
503
  .msg-menu-btn[aria-expanded='true'] { opacity: 1; }
463
504
  .msg-menu-btn:hover,
464
- .msg-menu-btn[aria-expanded='true'] { color: var(--text); border-color: var(--accent); }
505
+ .msg-menu-btn[aria-expanded='true'] { color: var(--text); background: var(--theirs); }
465
506
  .msg-menu {
466
507
  position: fixed;
467
508
  top: 0;
468
509
  left: 0;
469
510
  z-index: 60;
470
- min-width: 170px;
511
+ min-width: 178px;
471
512
  display: flex;
472
513
  flex-direction: column;
473
- gap: 2px;
514
+ gap: 1px;
474
515
  padding: 5px;
475
516
  border: 1px solid var(--border);
476
- border-radius: 10px;
477
- background: var(--panel);
478
- box-shadow: 0 12px 30px rgb(0 0 0 / 0.22);
517
+ border-radius: 11px;
518
+ background: var(--solid, var(--panel));
519
+ box-shadow: 0 14px 34px rgb(0 0 0 / 0.24);
520
+ backdrop-filter: blur(18px);
479
521
  }
480
522
  .msg-menu[hidden] { display: none; }
523
+ .msg-menu-sep { height: 1px; margin: 4px 6px; background: var(--border); }
481
524
  .msg-actions button.msg-act {
482
525
  width: 100%;
526
+ display: flex;
527
+ align-items: center;
528
+ gap: 8px;
483
529
  text-align: left;
484
530
  background: transparent;
485
- border: 1px solid transparent;
531
+ border: 0;
486
532
  border-radius: 7px;
487
- color: var(--muted);
488
- font: 500 11px var(--mono);
489
- padding: 5px 8px;
533
+ color: var(--text);
534
+ font: 500 12.5px var(--sans);
535
+ padding: 6px 8px;
490
536
  cursor: pointer;
491
537
  }
492
- .msg-actions button.msg-act:hover { color: var(--text); background: var(--theirs); }
538
+ .msg-act .mi { flex: 0 0 auto; color: var(--muted); }
539
+ .msg-act .mlabel { min-width: 0; }
540
+ .msg-actions button.msg-act:hover { background: var(--theirs); }
541
+ .msg-actions button.msg-act:hover .mi { color: var(--accent); }
493
542
  .msg-actions button.msg-act.danger { color: var(--danger); }
494
543
  .msg-actions button.msg-act.danger:hover { background: var(--theirs); }
544
+ .msg-actions button.msg-act.danger:hover .mi { color: var(--danger); }
495
545
  .bubble .tools { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; font-weight: 700; }
496
546
 
497
547
  .tools-actions { display: flex; align-items: center; gap: 8px; }
@@ -550,6 +600,12 @@ body.resizing * { cursor: col-resize !important; }
550
600
  overflow: auto;
551
601
  }
552
602
  .tool-card > .tool-out:empty { display: none; }
603
+ .tool-card > .tool-audio {
604
+ display: block;
605
+ width: calc(100% - 28px);
606
+ height: 32px;
607
+ margin: 4px 0 2px 18px;
608
+ }
553
609
  .tool-more {
554
610
  display: inline-block;
555
611
  margin: 2px 0 2px 18px;
@@ -624,7 +680,7 @@ body.resizing * { cursor: col-resize !important; }
624
680
  background: rgba(210, 153, 34, 0.06);
625
681
  }
626
682
  .perm-card .perm-head { display: flex; gap: 7px; align-items: center; color: var(--warn); font-weight: 700; }
627
- .perm-card .perm-args { color: var(--muted); margin: 5px 0 9px; white-space: pre-wrap; word-break: break-word; }
683
+ .perm-card .perm-args { color: var(--muted); margin: 5px 0 9px; white-space: pre-wrap; word-break: break-word; max-height: 7.5em; overflow: auto; }
628
684
  .perm-card .perm-actions { display: flex; align-items: center; gap: 8px; }
629
685
  .perm-card .spacer { flex: 1; }
630
686
  .perm-card.approved { border-color: var(--ok); background: rgba(63, 185, 80, 0.06); }
@@ -710,7 +766,80 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
710
766
  .btn.icon svg { width: 18px; height: 18px; display: block; }
711
767
  .btn.icon:hover { color: var(--accent); }
712
768
 
769
+ .chat-queue { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 9px; background: var(--theirs); }
770
+ .chat-queue[hidden] { display: none; }
771
+ .queue-label { color: var(--muted); font: 600 10.5px var(--mono); text-transform: uppercase; letter-spacing: 0.05em; }
772
+ .queue-item { display: flex; align-items: center; gap: 8px; }
773
+ .queue-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 12.5px var(--sans); }
774
+ .queue-remove { flex: 0 0 auto; width: 20px; height: 20px; padding: 0; line-height: 1; border-radius: 50%; border: 0; background: transparent; color: var(--muted); cursor: pointer; }
775
+ .queue-remove:hover { color: var(--danger); background: var(--theirs); }
776
+ .shell-out { margin: 0; padding: 8px 10px; max-height: 320px; overflow: auto; background: var(--theirs); border: 1px solid var(--border); border-radius: 8px; font: 11.5px/1.5 var(--mono); white-space: pre-wrap; word-break: break-word; }
713
777
  .attach-preview { display: flex; gap: 8px; flex-wrap: wrap; padding-bottom: 2px; }
778
+ .sb-channel {
779
+ font-size: 11px;
780
+ color: var(--warn);
781
+ border: 1px solid currentColor;
782
+ border-radius: 9px;
783
+ padding: 0 6px;
784
+ cursor: help;
785
+ }
786
+ .sb-channel.retrying { animation: live-pulse 1.4s ease-in-out infinite; }
787
+ .toasts {
788
+ /* Encima del composer, no sobre el: al borde inferior tapaba el banner y los
789
+ botones, y cada toast (con `pointer-events: auto`) les robaba los clics. */
790
+ position: absolute;
791
+ bottom: calc(100% + 6px);
792
+ left: 10px;
793
+ right: 10px;
794
+ display: flex;
795
+ flex-direction: column;
796
+ gap: 5px;
797
+ pointer-events: none;
798
+ z-index: 5;
799
+ }
800
+ .toast {
801
+ pointer-events: auto;
802
+ cursor: pointer;
803
+ padding: 7px 11px;
804
+ border: 1px solid var(--border);
805
+ border-left: 3px solid var(--warn);
806
+ border-radius: 8px;
807
+ background: var(--panel);
808
+ color: var(--text);
809
+ font-size: 12.5px;
810
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
811
+ }
812
+
813
+ /* Banner de permisos: se decide desde cualquier pestana o sesion, asi que vive
814
+ en el composer y no dentro del mensaje de una sesion. */
815
+ .perm-banner { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
816
+ .perm-banner[hidden] { display: none; }
817
+ .perm-banner-row {
818
+ display: flex;
819
+ flex-direction: column;
820
+ gap: 5px;
821
+ padding: 9px 11px;
822
+ border: 1px solid var(--warn);
823
+ border-radius: 9px;
824
+ background: rgba(217, 112, 74, 0.08);
825
+ }
826
+ .perm-banner-label { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
827
+ .perm-banner-icon { color: var(--warn); font-weight: 700; }
828
+ .perm-banner-title { font-weight: 700; color: var(--text); }
829
+ .perm-banner-session { font-size: 11.5px; color: var(--muted); border: 1px solid var(--border); border-radius: 8px; padding: 0 6px; }
830
+ .perm-banner-args {
831
+ color: var(--muted);
832
+ font-family: var(--mono, monospace);
833
+ font-size: 11.5px;
834
+ /* Recorte limpio: `overflow` a secas cortaba la linea por la mitad y se
835
+ leia como texto roto. */
836
+ display: -webkit-box;
837
+ -webkit-line-clamp: 2;
838
+ -webkit-box-orient: vertical;
839
+ overflow: hidden;
840
+ word-break: break-word;
841
+ }
842
+ .perm-banner-row > .btn { align-self: flex-start; }
714
843
  .attach-thumb { position: relative; }
715
844
  .attach-thumb img { height: 64px; border-radius: 6px; border: 1px solid var(--border); display: block; }
716
845
  .attach-remove {
@@ -846,6 +975,9 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
846
975
 
847
976
  .card { border: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); border-radius: 14px; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
848
977
 
978
+ /* ajustes: voz (TTS) -------------------------------------------------------- */
979
+ .voz-announce { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); padding: 8px 0; }
980
+
849
981
  /* ajustes: pestañas internas ---------------------------------------------- */
850
982
  #view-ajustes.active { display: block; overflow-y: auto; }
851
983
  .ajustes-wrap { width: 100%; max-width: 780px; margin: 0 auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; min-height: 100%; }
@@ -1049,6 +1181,26 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1049
1181
  #xp-preview.preview-maximized .preview-stage { border-radius: 10px; }
1050
1182
  #procs-list { flex: 1; min-height: 0; overflow-y: auto; }
1051
1183
  .procs-log { margin: 0; padding: 8px 12px; height: 150px; overflow: auto; background: var(--theirs); border-top: 1px solid var(--border); font: 500 11px var(--mono); white-space: pre-wrap; word-break: break-word; }
1184
+ .git-branch { font: 700 12px var(--mono); color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1185
+ .git-list { list-style: none; margin: 0; padding: 0 8px 10px; overflow-y: auto; flex: 1; min-height: 0; }
1186
+ .git-group { padding: 8px 6px 3px; color: var(--muted); font: 600 11px var(--sans); text-transform: uppercase; letter-spacing: 0.04em; }
1187
+ .git-item { border-radius: 7px; }
1188
+ .git-row { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 7px; cursor: pointer; }
1189
+ .git-row:hover { background: var(--theirs); }
1190
+ .git-badge { flex: 0 0 auto; width: 16px; text-align: center; font: 700 11px var(--mono); }
1191
+ .git-badge.mod, .git-badge.ren { color: var(--accent); }
1192
+ .git-badge.add, .git-badge.new { color: var(--ok); }
1193
+ .git-badge.del, .git-badge.conf { color: var(--danger); }
1194
+ .git-badge.ign { color: var(--muted); }
1195
+ .git-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; font: 500 12px var(--sans); }
1196
+ .git-diff { margin: 2px 0 8px; padding: 8px 10px; max-height: 320px; overflow: auto; background: var(--theirs); border-radius: 8px; font: 11px/1.5 var(--mono); white-space: pre; }
1197
+ .git-diff.err { color: var(--danger); }
1198
+ .git-diff .diff-line { display: block; }
1199
+ .git-diff .diff-add { color: var(--ok); }
1200
+ .git-diff .diff-del { color: var(--danger); }
1201
+ .git-diff .diff-eq { color: var(--muted); }
1202
+ .git-diff .diff-sep { color: var(--accent); opacity: 0.8; }
1203
+ .git-diff .diff-meta { color: var(--muted); opacity: 0.7; }
1052
1204
  .import-bar { display: flex; gap: 6px; margin: 4px 0 10px; }
1053
1205
  .import-bar input { flex: 1; min-width: 0; }
1054
1206
 
@@ -1166,10 +1318,13 @@ dialog.file-modal.preview-maximized {
1166
1318
  .topbar {
1167
1319
  position: relative;
1168
1320
  display: flex;
1169
- flex-wrap: wrap;
1321
+ /* Una sola fila: con `wrap` los botones de accion caian a una segunda
1322
+ linea y empujaban el chat hacia abajo. */
1323
+ flex-wrap: nowrap;
1170
1324
  gap: 8px 10px;
1171
1325
  padding: 8px 12px;
1172
1326
  }
1327
+ .brand { flex: 0 0 auto; }
1173
1328
  .brand .ver {
1174
1329
  display: none;
1175
1330
  }
@@ -1196,13 +1351,20 @@ dialog.file-modal.preview-maximized {
1196
1351
  .settings {
1197
1352
  grid-column: auto;
1198
1353
  margin-left: 0;
1199
- flex: 1 1 100%;
1200
- flex-wrap: wrap;
1201
- gap: 8px;
1354
+ /* Antes `1 1 100%`: eso ocupaba toda la fila y mandaba los botones a una
1355
+ segunda linea. Ahora se quedan en la misma linea que TH. */
1356
+ flex: 0 0 auto;
1357
+ flex-wrap: nowrap;
1358
+ gap: 6px;
1202
1359
  }
1203
1360
  .settings .btn.icon {
1204
1361
  flex: 0 0 auto;
1205
1362
  }
1363
+ /* Cuadrado como los demas iconos: el texto no entra en un movil angosto. */
1364
+ .topbar-new {
1365
+ width: 34px;
1366
+ padding: 0;
1367
+ }
1206
1368
 
1207
1369
  /* sidebar como drawer deslizante */
1208
1370
  .sidebar {
@@ -1260,6 +1422,17 @@ dialog.file-modal.preview-maximized {
1260
1422
  }
1261
1423
 
1262
1424
  @media (max-width: 480px) {
1425
+ /* Cinco elementos en una fila no entran en un movil angosto: el nombre del
1426
+ tab activo es lo unico que sobra, y el menu de puntos ya lo deja claro. */
1427
+ .topbar {
1428
+ gap: 8px 8px;
1429
+ }
1430
+ #more-tab-label {
1431
+ display: none;
1432
+ }
1433
+ .settings {
1434
+ gap: 5px;
1435
+ }
1263
1436
  .settings select {
1264
1437
  flex: 1 1 100%;
1265
1438
  }
@@ -1,5 +1,7 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { sendError } from './lib/http.js';
3
+ import { tunnelState } from './lib/core.js';
4
+ import { isLoopback } from './lib/workspace.js';
3
5
 
4
6
  /**
5
7
  * Autenticacion web con cookie de sesion firmada (stateless), CSRF y bloqueo
@@ -37,14 +39,25 @@ export function parseCookies(header) {
37
39
  }
38
40
 
39
41
  // Detras del tunel el server recibe desde loopback: solo ahi confiamos en
40
- // X-Forwarded-Proto para marcar la cookie Secure.
42
+ // X-Forwarded-Proto para marcar la cookie Secure. Como el cliente del tunel
43
+ // corre en la misma maquina, un request por el tunel es indistinguible de uno
44
+ // local por direccion: usamos el Host (localhost/127.0.0.1 = local) y el
45
+ // estado del bridge para no mandar la cookie sin Secure por el tunel.
46
+ function isLocalHost(req) {
47
+ const host = String(req.headers.host || '').split(':')[0].toLowerCase();
48
+ return !host || host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
49
+ }
50
+
41
51
  function isSecure(req) {
42
52
  if (req.socket && req.socket.encrypted) return true;
43
53
  const remote = req.socket && req.socket.remoteAddress;
44
- const loopback = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
54
+ const loopback = !remote || isLoopback(remote);
45
55
  if (!loopback) return false;
46
56
  const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
47
- return proto === 'https';
57
+ if (proto === 'https') return true;
58
+ // Peticion por el tunel (host publico + bridge activo): la URL publica es
59
+ // HTTPS, la cookie no tiene que poder viajar por la variante http.
60
+ return Boolean(tunnelState()) && !isLocalHost(req);
48
61
  }
49
62
 
50
63
  function serializeCookie(value, req, maxAge) {
@@ -112,17 +125,27 @@ export function requireCsrf(app, req, res) {
112
125
  // ---------------------------------------------------------------------------
113
126
  // Bloqueo por intentos fallidos (en memoria: el server es un proceso).
114
127
  // ---------------------------------------------------------------------------
128
+ // La clave usa remoteAddress: X-Forwarded-For lo controla el cliente y con un
129
+ // header falsificado se rotaba de IP y se esquivaba el bloqueo.
115
130
  const MAX_ATTEMPTS = 5;
116
131
  const WINDOW_MS = 15 * 60 * 1000;
117
132
  const LOCK_MS = 15 * 60 * 1000;
133
+ const MAX_KEYS = 256;
118
134
  const attempts = new Map();
119
135
 
120
136
  function clientKey(req, user) {
121
- const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
122
- const ip = xff || (req.socket && req.socket.remoteAddress) || 'desconocido';
137
+ const ip = (req.socket && req.socket.remoteAddress) || 'desconocido';
123
138
  return `${ip}|${String(user || '').trim().toLowerCase()}`;
124
139
  }
125
140
 
141
+ function sweepStale(now) {
142
+ if (attempts.size < MAX_KEYS) return;
143
+ for (const [key, st] of attempts) {
144
+ const expired = (!st.lockUntil || st.lockUntil < now) && now - st.first > WINDOW_MS;
145
+ if (expired) attempts.delete(key);
146
+ }
147
+ }
148
+
126
149
  export function loginLockRemaining(req, user) {
127
150
  const now = Date.now();
128
151
  const st = attempts.get(clientKey(req, user));
@@ -133,6 +156,7 @@ export function loginLockRemaining(req, user) {
133
156
 
134
157
  export function loginRecordFailure(req, user) {
135
158
  const now = Date.now();
159
+ sweepStale(now);
136
160
  const key = clientKey(req, user);
137
161
  let st = attempts.get(key);
138
162
  if (!st || (!st.lockUntil && now - st.first > WINDOW_MS)) st = { count: 0, first: now, lockUntil: 0 };
@@ -0,0 +1,77 @@
1
+ // Pedazos compartidos del scaffolding "correr un agente" (chat HTTP, Telegram,
2
+ // subagentes). La creacion de tools y el onEvent de cada canal siguen siendo
3
+ // propios (los canales de salida son distintos); lo combinable vive aca.
4
+
5
+ import { summarizeToolCall, previewToolResult } from './core.js';
6
+
7
+ const FAILED_RE = /^Error|^Permiso denegado/i;
8
+
9
+ /** Payload estandar de un tool_result para eventos SSE: preview + failed. */
10
+ export function toolResultPayload(event) {
11
+ const { preview, truncated } = previewToolResult(event.name, event.result);
12
+ return { preview, truncated, failed: FAILED_RE.test(String(event.result)) };
13
+ }
14
+
15
+ export function toolCallPayload(event) {
16
+ return { id: event.id, name: event.name, args: event.args, summary: summarizeToolCall(event.name, event.args) };
17
+ }
18
+
19
+ /**
20
+ * Combina tools locales + admin + subagentes en un toolset unificado.
21
+ * `approve` es el callback de aprobacion de las tools de administraación.
22
+ */
23
+ export function combineToolset({ local, admin = null, tasks = null, exposeAdmin = false, approve = null }) {
24
+ const definitions = [
25
+ ...local.definitions,
26
+ ...(exposeAdmin && admin ? admin.definitions : []),
27
+ ...(tasks ? tasks.definitions : []),
28
+ ];
29
+ const execute = (name, args, context) => {
30
+ if (tasks?.handles(name)) return tasks.execute(name, args);
31
+ if (exposeAdmin && admin?.handles(name)) {
32
+ return admin.execute(name, args, approve ? { approve } : undefined);
33
+ }
34
+ return local.execute(name, args, context);
35
+ };
36
+ return { definitions, execute };
37
+ }
38
+
39
+ /** Resuelve proveedor + config con la API key almacenada. */
40
+ export function resolveProviderFor(ctx, providerId, extra = {}) {
41
+ return ctx.resolveProvider({
42
+ ...ctx.providerSettings(providerId),
43
+ apiKey: ctx.apiKeyFor(providerId),
44
+ ...extra,
45
+ });
46
+ }
47
+
48
+ /** Mensaje del usuario, multinodal si hay imagenes (data URLs). */
49
+ export function buildUserMessage(input, images = []) {
50
+ if (images.length) {
51
+ return {
52
+ role: 'user',
53
+ content: [
54
+ { type: 'text', text: input },
55
+ ...images.map((url) => ({ type: 'image_url', image_url: { url } })),
56
+ ],
57
+ };
58
+ }
59
+ return { role: 'user', content: input };
60
+ }
61
+
62
+ /**
63
+ * Arma el array de mensajes del turno: system prompt (con el del modo) +
64
+ * objetivo/resumen de la sesion + historial + mensaje del usuario.
65
+ */
66
+ export async function composeAgentMessages({ ctx, cwd, mode, history = [], userMessage, sessionRecord = null }) {
67
+ const basePrompt = await ctx.systemPromptFor(cwd);
68
+ const goal = sessionRecord?.meta?.goal;
69
+ const summary = sessionRecord?.meta?.summary;
70
+ return [
71
+ { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
72
+ ...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
73
+ ...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
74
+ ...history,
75
+ userMessage,
76
+ ];
77
+ }