@danieltmn/tamnora 0.7.1 → 0.8.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.
@@ -450,8 +450,8 @@
450
450
  <h1>Tamnora<br /><span>Harness</span></h1>
451
451
 
452
452
  <p class="hero-copy">
453
- Un harness simple y centralizado para orquestar agentes de IA, gestionar procesos y mantener
454
- el control de tu operación desde cualquier dispositivo.
453
+ Un harness local para orquestar agentes de IA, ejecutar tools en tu workspace y mantener el
454
+ control desde tu máquina o el celular (túnel + QR o Telegram).
455
455
  </p>
456
456
 
457
457
  <div class="actions">
@@ -462,15 +462,15 @@
462
462
  <div class="features" id="caracteristicas">
463
463
  <div class="feature">
464
464
  <div class="feature-icon">◈</div>
465
- <div><b>Centralizado</b><span>Tus agentes y flujos en un solo lugar.</span></div>
465
+ <div><b>Local</b><span>Corre en tu máquina; el estado vive en tu casa.</span></div>
466
466
  </div>
467
467
  <div class="feature">
468
468
  <div class="feature-icon">↗</div>
469
- <div><b>Accesible</b><span>Disponible desde cualquier dispositivo.</span></div>
469
+ <div><b>Accesible</b><span>Consola web, TUI y Telegram; túnel con QR.</span></div>
470
470
  </div>
471
471
  <div class="feature">
472
472
  <div class="feature-icon">✓</div>
473
- <div><b>Control</b><span>Ejecuciones claras y trazables.</span></div>
473
+ <div><b>Control</b><span>Permisos allow/ask/deny y runs trazables.</span></div>
474
474
  </div>
475
475
  </div>
476
476
  </section>
@@ -445,53 +445,61 @@ body.resizing * { cursor: col-resize !important; }
445
445
  .msg-menu-btn {
446
446
  display: inline-grid;
447
447
  place-items: center;
448
- width: auto;
449
- min-height: 24px;
450
- padding: 2px 9px;
448
+ width: 26px;
449
+ height: 24px;
450
+ padding: 0;
451
451
  background: transparent;
452
- border: 1px solid var(--border);
452
+ border: 1px solid transparent;
453
453
  border-radius: 7px;
454
454
  color: var(--muted);
455
455
  cursor: pointer;
456
- font: 500 11px var(--mono);
457
- opacity: 0.45;
458
- transition: opacity 0.12s ease;
456
+ opacity: 0.4;
457
+ transition: opacity 0.12s ease, background 0.12s ease, color 0.12s ease;
459
458
  }
460
459
  .msg:hover .msg-menu-btn,
461
460
  .msg:focus-within .msg-menu-btn,
462
461
  .msg-menu-btn[aria-expanded='true'] { opacity: 1; }
463
462
  .msg-menu-btn:hover,
464
- .msg-menu-btn[aria-expanded='true'] { color: var(--text); border-color: var(--accent); }
463
+ .msg-menu-btn[aria-expanded='true'] { color: var(--text); background: var(--theirs); }
465
464
  .msg-menu {
466
465
  position: fixed;
467
466
  top: 0;
468
467
  left: 0;
469
468
  z-index: 60;
470
- min-width: 170px;
469
+ min-width: 178px;
471
470
  display: flex;
472
471
  flex-direction: column;
473
- gap: 2px;
472
+ gap: 1px;
474
473
  padding: 5px;
475
474
  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);
475
+ border-radius: 11px;
476
+ background: var(--solid, var(--panel));
477
+ box-shadow: 0 14px 34px rgb(0 0 0 / 0.24);
478
+ backdrop-filter: blur(18px);
479
479
  }
480
480
  .msg-menu[hidden] { display: none; }
481
+ .msg-menu-sep { height: 1px; margin: 4px 6px; background: var(--border); }
481
482
  .msg-actions button.msg-act {
482
483
  width: 100%;
484
+ display: flex;
485
+ align-items: center;
486
+ gap: 8px;
483
487
  text-align: left;
484
488
  background: transparent;
485
- border: 1px solid transparent;
489
+ border: 0;
486
490
  border-radius: 7px;
487
- color: var(--muted);
488
- font: 500 11px var(--mono);
489
- padding: 5px 8px;
491
+ color: var(--text);
492
+ font: 500 12.5px var(--sans);
493
+ padding: 6px 8px;
490
494
  cursor: pointer;
491
495
  }
492
- .msg-actions button.msg-act:hover { color: var(--text); background: var(--theirs); }
496
+ .msg-act .mi { flex: 0 0 auto; color: var(--muted); }
497
+ .msg-act .mlabel { min-width: 0; }
498
+ .msg-actions button.msg-act:hover { background: var(--theirs); }
499
+ .msg-actions button.msg-act:hover .mi { color: var(--accent); }
493
500
  .msg-actions button.msg-act.danger { color: var(--danger); }
494
501
  .msg-actions button.msg-act.danger:hover { background: var(--theirs); }
502
+ .msg-actions button.msg-act.danger:hover .mi { color: var(--danger); }
495
503
  .bubble .tools { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; font-weight: 700; }
496
504
 
497
505
  .tools-actions { display: flex; align-items: center; gap: 8px; }
@@ -710,6 +718,14 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
710
718
  .btn.icon svg { width: 18px; height: 18px; display: block; }
711
719
  .btn.icon:hover { color: var(--accent); }
712
720
 
721
+ .chat-queue { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 9px; background: var(--theirs); }
722
+ .chat-queue[hidden] { display: none; }
723
+ .queue-label { color: var(--muted); font: 600 10.5px var(--mono); text-transform: uppercase; letter-spacing: 0.05em; }
724
+ .queue-item { display: flex; align-items: center; gap: 8px; }
725
+ .queue-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 12.5px var(--sans); }
726
+ .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; }
727
+ .queue-remove:hover { color: var(--danger); background: var(--theirs); }
728
+ .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
729
  .attach-preview { display: flex; gap: 8px; flex-wrap: wrap; padding-bottom: 2px; }
714
730
  .attach-thumb { position: relative; }
715
731
  .attach-thumb img { height: 64px; border-radius: 6px; border: 1px solid var(--border); display: block; }
@@ -1049,6 +1065,26 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1049
1065
  #xp-preview.preview-maximized .preview-stage { border-radius: 10px; }
1050
1066
  #procs-list { flex: 1; min-height: 0; overflow-y: auto; }
1051
1067
  .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; }
1068
+ .git-branch { font: 700 12px var(--mono); color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1069
+ .git-list { list-style: none; margin: 0; padding: 0 8px 10px; overflow-y: auto; flex: 1; min-height: 0; }
1070
+ .git-group { padding: 8px 6px 3px; color: var(--muted); font: 600 11px var(--sans); text-transform: uppercase; letter-spacing: 0.04em; }
1071
+ .git-item { border-radius: 7px; }
1072
+ .git-row { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 7px; cursor: pointer; }
1073
+ .git-row:hover { background: var(--theirs); }
1074
+ .git-badge { flex: 0 0 auto; width: 16px; text-align: center; font: 700 11px var(--mono); }
1075
+ .git-badge.mod, .git-badge.ren { color: var(--accent); }
1076
+ .git-badge.add, .git-badge.new { color: var(--ok); }
1077
+ .git-badge.del, .git-badge.conf { color: var(--danger); }
1078
+ .git-badge.ign { color: var(--muted); }
1079
+ .git-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; font: 500 12px var(--sans); }
1080
+ .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; }
1081
+ .git-diff.err { color: var(--danger); }
1082
+ .git-diff .diff-line { display: block; }
1083
+ .git-diff .diff-add { color: var(--ok); }
1084
+ .git-diff .diff-del { color: var(--danger); }
1085
+ .git-diff .diff-eq { color: var(--muted); }
1086
+ .git-diff .diff-sep { color: var(--accent); opacity: 0.8; }
1087
+ .git-diff .diff-meta { color: var(--muted); opacity: 0.7; }
1052
1088
  .import-bar { display: flex; gap: 6px; margin: 4px 0 10px; }
1053
1089
  .import-bar input { flex: 1; min-width: 0; }
1054
1090
 
@@ -264,6 +264,9 @@ export function registerChat(route, ctx) {
264
264
  } else if (event.type === 'retry') {
265
265
  partials.flush();
266
266
  ctx.runs.emit(sessionId, 'retry', { attempt: event.attempt, delayMs: event.delayMs, message: event.message });
267
+ } else if (event.type === 'usage') {
268
+ partials.flush();
269
+ ctx.runs.emit(sessionId, 'usage', { tokens: event.usage });
267
270
  } else if (event.type === 'trim') {
268
271
  partials.flush();
269
272
  ctx.runs.emit(sessionId, 'trim', { trimmed: event.trimmed, dropped: event.dropped, tokens: event.tokens });
@@ -0,0 +1,43 @@
1
+ import path from 'node:path';
2
+ import { gitStatus, gitDiff } from '../lib/core.js';
3
+ import { sendJson, sendError } from '../lib/http.js';
4
+ import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
5
+
6
+ function isInside(root, target) {
7
+ const rel = path.relative(root, target);
8
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
9
+ }
10
+
11
+ export function registerGit(route, ctx) {
12
+ const resolveRoot = (req, res, raw) => {
13
+ if (!isLoopback(req.socket.localAddress)) {
14
+ sendError(res, 403, 'El estado de git solo está disponible en conexiones locales.');
15
+ return null;
16
+ }
17
+ try {
18
+ return resolveExistingDir(raw || ctx.cwd);
19
+ } catch (error) {
20
+ sendError(res, 400, error.message);
21
+ return null;
22
+ }
23
+ };
24
+
25
+ route('GET', '/api/git/status', async ({ req, res, query }) => {
26
+ const root = resolveRoot(req, res, query.get('cwd'));
27
+ if (!root) return;
28
+ const status = await gitStatus(root);
29
+ sendJson(res, 200, { root, ...status });
30
+ });
31
+
32
+ route('GET', '/api/git/diff', async ({ req, res, query }) => {
33
+ const root = resolveRoot(req, res, query.get('cwd'));
34
+ if (!root) return;
35
+ const rel = String(query.get('path') ?? '').replace(/^[/\\]+/, '');
36
+ if (rel && !isInside(root, path.resolve(root, rel))) {
37
+ return sendError(res, 400, 'La ruta queda fuera del workspace.');
38
+ }
39
+ const result = await gitDiff(root, { path: rel, staged: query.get('staged') === '1' });
40
+ if (!result.ok) return sendError(res, 400, result.error ?? 'No se pudo obtener el diff.');
41
+ sendJson(res, 200, { root, path: rel || null, ...result });
42
+ });
43
+ }
@@ -1,6 +1,6 @@
1
- import { detectProcs } from '../lib/core.js';
1
+ import { detectProcs, killPort } from '../lib/core.js';
2
2
  import { readJson, sendJson, sendError } from '../lib/http.js';
3
- import { resolveExistingDir } from '../lib/workspace.js';
3
+ import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
4
4
 
5
5
  export function registerProcs(route, ctx) {
6
6
  route('GET', '/api/procs', ({ res }) => {
@@ -44,4 +44,16 @@ export function registerProcs(route, ctx) {
44
44
  if (!ctx.procs.stop(params.id)) return sendError(res, 404, 'Proceso no encontrado.');
45
45
  sendJson(res, 200, { ok: true });
46
46
  });
47
+
48
+ route('POST', '/api/procs/kill', async ({ req, res }) => {
49
+ if (!isLoopback(req.socket.localAddress)) {
50
+ return sendError(res, 403, 'Matar procesos solo está disponible en conexiones locales.');
51
+ }
52
+ const body = await readJson(req);
53
+ try {
54
+ sendJson(res, 200, await killPort(body.port));
55
+ } catch (error) {
56
+ sendError(res, 400, error.message);
57
+ }
58
+ });
47
59
  }
@@ -0,0 +1,23 @@
1
+ import { createTools } from '../lib/core.js';
2
+ import { readJson, sendJson, sendError } from '../lib/http.js';
3
+ import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
4
+
5
+ export function registerShell(route, ctx) {
6
+ route('POST', '/api/shell', async ({ req, res }) => {
7
+ if (!isLoopback(req.socket.localAddress)) {
8
+ return sendError(res, 403, 'El shell solo está disponible en conexiones locales.');
9
+ }
10
+ const body = await readJson(req);
11
+ const command = String(body.command ?? '').trim();
12
+ if (!command) return sendError(res, 400, 'Falta "command".');
13
+ let root;
14
+ try {
15
+ root = resolveExistingDir(body.cwd || ctx.cwd);
16
+ } catch (error) {
17
+ return sendError(res, 400, error.message);
18
+ }
19
+ const tools = createTools({ root, enabled: ['bash'], yes: true });
20
+ const output = await tools.execute('bash', { command });
21
+ sendJson(res, 200, { command, cwd: root, output: String(output ?? '') });
22
+ });
23
+ }
@@ -28,12 +28,14 @@ import { registerAuth } from './routes/auth.js';
28
28
  import { registerSessions } from './routes/sessions.js';
29
29
  import { registerFolders } from './routes/folders.js';
30
30
  import { registerFiles } from './routes/files.js';
31
+ import { registerGit } from './routes/git.js';
31
32
  import { registerModels } from './routes/models.js';
32
33
  import { registerProviders } from './routes/providers.js';
33
34
  import { registerMcp } from './routes/mcp.js';
34
35
  import { registerSkills } from './routes/skills.js';
35
36
  import { registerChannels } from './routes/channels.js';
36
37
  import { registerProcs } from './routes/procs.js';
38
+ import { registerShell } from './routes/shell.js';
37
39
  import { registerPreview } from './routes/preview.js';
38
40
  import { registerChat } from './routes/chat.js';
39
41
  import { registerEval } from './routes/eval.js';
@@ -212,12 +214,14 @@ export async function createTamnoraServer({
212
214
  registerSessions(route, ctx);
213
215
  registerFolders(route, ctx);
214
216
  registerFiles(route, ctx);
217
+ registerGit(route, ctx);
215
218
  registerModels(route, ctx);
216
219
  registerProviders(route, ctx);
217
220
  registerMcp(route, ctx);
218
221
  registerSkills(route, ctx);
219
222
  registerChannels(route, ctx);
220
223
  registerProcs(route, ctx);
224
+ registerShell(route, ctx);
221
225
  const preview = registerPreview(route, ctx);
222
226
  registerChat(route, ctx);
223
227
  registerEval(route, ctx);
@@ -1,6 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import http from 'node:http';
4
+ import { execFileSync } from 'node:child_process';
4
5
  import { mkdtemp, mkdir, writeFile, readFile, stat } from 'node:fs/promises';
5
6
  import { tmpdir } from 'node:os';
6
7
  import path from 'node:path';
@@ -30,6 +31,21 @@ async function startServer(overrides = {}) {
30
31
  return { instance, base: info.url };
31
32
  }
32
33
 
34
+ const HAS_GIT = (() => {
35
+ try {
36
+ execFileSync('git', ['--version'], { stdio: 'ignore' });
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ })();
42
+
43
+ function gitInit(root) {
44
+ execFileSync('git', ['init', '-q'], { cwd: root });
45
+ execFileSync('git', ['config', 'user.email', 'test@tamnora.dev'], { cwd: root });
46
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root });
47
+ }
48
+
33
49
  test('serves health, providers and the console', async () => {
34
50
  const { instance, base } = await startServer();
35
51
  try {
@@ -570,6 +586,46 @@ test('detects, runs and stops dev processes', async () => {
570
586
  }
571
587
  });
572
588
 
589
+ test('runs a console shell command and kills a port', async () => {
590
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-shell-'));
591
+ const { instance, base } = await startServer({ cwd });
592
+ const json = { 'Content-Type': 'application/json' };
593
+ try {
594
+ const shell = await fetch(`${base}/api/shell`, {
595
+ method: 'POST',
596
+ headers: json,
597
+ body: JSON.stringify({ command: 'echo hola-shell' }),
598
+ });
599
+ assert.equal(shell.status, 200);
600
+ const out = await shell.json();
601
+ assert.match(out.output, /hola-shell/);
602
+
603
+ const missing = await fetch(`${base}/api/shell`, {
604
+ method: 'POST',
605
+ headers: json,
606
+ body: JSON.stringify({ command: ' ' }),
607
+ });
608
+ assert.equal(missing.status, 400);
609
+
610
+ const badPort = await fetch(`${base}/api/procs/kill`, {
611
+ method: 'POST',
612
+ headers: json,
613
+ body: JSON.stringify({ port: 0 }),
614
+ });
615
+ assert.equal(badPort.status, 400);
616
+
617
+ const none = await fetch(`${base}/api/procs/kill`, {
618
+ method: 'POST',
619
+ headers: json,
620
+ body: JSON.stringify({ port: 47555 }),
621
+ });
622
+ assert.equal(none.status, 200);
623
+ assert.deepEqual((await none.json()).pids, []);
624
+ } finally {
625
+ await instance.close();
626
+ }
627
+ });
628
+
573
629
  test('the agent can install a skill from chat (with approval)', async () => {
574
630
  const calls = { n: 0 };
575
631
  const toolProvider = {
@@ -775,6 +831,56 @@ test('lists and previews workspace files, confining to the root', async () => {
775
831
  }
776
832
  });
777
833
 
834
+ test('reports git status and per-file diffs', { skip: !HAS_GIT }, async () => {
835
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-git-'));
836
+ gitInit(root);
837
+ await writeFile(path.join(root, 'app.js'), 'const a = 1;\n');
838
+ execFileSync('git', ['add', 'app.js'], { cwd: root });
839
+ execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: root });
840
+ await writeFile(path.join(root, 'app.js'), 'const a = 2;\n');
841
+ await writeFile(path.join(root, 'nuevo.txt'), 'hola\n');
842
+
843
+ const { instance, base } = await startServer();
844
+ try {
845
+ const status = await (
846
+ await fetch(`${base}/api/git/status?${new URLSearchParams({ cwd: root })}`)
847
+ ).json();
848
+ assert.equal(status.isRepo, true);
849
+ assert.ok(status.branch);
850
+ assert.deepEqual(
851
+ status.entries.map((e) => e.path).sort(),
852
+ ['app.js', 'nuevo.txt']
853
+ );
854
+ assert.equal(status.entries.find((e) => e.path === 'app.js').status, 'modified');
855
+ assert.equal(status.entries.find((e) => e.path === 'nuevo.txt').status, 'untracked');
856
+
857
+ const diff = await (
858
+ await fetch(`${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: 'app.js' })}`)
859
+ ).json();
860
+ assert.match(diff.diff, /-const a = 1;/);
861
+ assert.match(diff.diff, /\+const a = 2;/);
862
+
863
+ const untracked = await (
864
+ await fetch(`${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: 'nuevo.txt' })}`)
865
+ ).json();
866
+ assert.equal(untracked.untracked, true);
867
+ assert.match(untracked.diff, /\+hola/);
868
+
869
+ const outside = await fetch(
870
+ `${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: '../fuera' })}`
871
+ );
872
+ assert.equal(outside.status, 400);
873
+
874
+ const plain = await mkdtemp(path.join(tmpdir(), 'tamnora-nogit-'));
875
+ const none = await (
876
+ await fetch(`${base}/api/git/status?${new URLSearchParams({ cwd: plain })}`)
877
+ ).json();
878
+ assert.equal(none.isRepo, false);
879
+ } finally {
880
+ await instance.close();
881
+ }
882
+ });
883
+
778
884
  test('previews an HTML file through a static server', async () => {
779
885
  const root = await mkdtemp(path.join(tmpdir(), 'tamnora-html-'));
780
886
  await writeFile(path.join(root, 'index.html'), '<h1>hola</h1>');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",